Capture current mission, AI, map and administration development

Include the current addon, terrain integrations, tools, regression tests and recorded development evidence.
This commit is contained in:
2026-10-13 22:52:53 +02:00
parent a951a96edc
commit 6a667d8deb
8104 changed files with 2124945 additions and 2061 deletions
@@ -8,12 +8,13 @@ class ERR_AdminMenuFields
{
static array<string> Keys()
{
return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent"};
return {"aiDoctrine", "aiShadow", "automaticEquipmentChancePercent", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent", "heliTimerMinutes"};
}
static bool Available(string key, bool hq, bool intel)
{
if (key == "aiDoctrine" || key == "aiShadow") return hq;
if (key == "aiIntelChancePercent") return intel;
if (key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
if (key == "automaticEquipmentChancePercent" || key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "heliTimerMinutes" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
return Keys().Contains(key);
}
// A short journal has no older off-screen lines, even if its slider is zero.
@@ -29,27 +30,56 @@ class ERR_AdminMenuFields
}
static string Display(string key, string value)
{
if (key == "aiDoctrine") { if (value == "1") return "Coordinated"; return "Current"; }
if (key == "aiShadow") { if (value == "1") return "On"; return "Off"; }
if (value == "-1" || value == "-2") return ERR_Settings.SelectionText(value.ToInt());
if (key == "startMode") { if (value == "1") return "Crash"; return "Prison"; }
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());
if (key == "viewDistanceMetres" && value == "0") return "default";
return value;
}
static string StartSummary(int saved, int current)
{
return "Next start: " + Display("startMode", saved.ToString()) + " | Current round: " + Display("startMode", current.ToString());
}
static string Command(string key, string value)
{
string prefix;
if (key == "radioDefenceSeconds") prefix = "mission radio";
if (key == "aiDoctrine") prefix = "mission doctrine";
else if (key == "aiShadow") prefix = "mission aishadow";
else if (key == "automaticEquipmentChancePercent") prefix = "hq equipment";
else if (key == "motorbikeCount") prefix = "mission motorbikes";
else if (key == "planeReconEnabled") prefix = "mission plane";
else if (key == "planeAltitudeMetres") prefix = "mission planealtitude";
else if (key == "planeReplacementSeconds") prefix = "mission planereplacement";
else if (key == "planeSectorSeconds") prefix = "mission planesector";
else if (key == "startWeaponCount") prefix = "mission startweapons";
else if (key == "startMagazineCount") prefix = "mission startammo";
else if (key == "objectGuardEvery") prefix = "mission objectevery";
else if (key == "objectGuardHQMode") prefix = "mission objecthq";
else if (key == "objectGuardCount") prefix = "mission objectguards";
else if (key == "hunterSpawnDistanceMetres") prefix = "mission hunterdistance";
else if (key == "fieldCampCount") prefix = "mission camps";
else if (key == "fieldHospitalCount") prefix = "mission hospitals";
else if (key == "roadblockCount") prefix = "mission roadblocks";
else if (key == "civilianTrafficCount") prefix = "mission traffic";
else if (key == "startMode") prefix = "mission start";
else if (key == "radioDefenceSeconds") prefix = "mission radio";
else if (key == "aiIntelChancePercent") prefix = "intel carrying";
else if (key == "intelRevealChancePercent") prefix = "intel reveal";
else if (key == "startTimeMinutes") prefix = "mission time";
else if (key == "timeMultiplier") prefix = "mission speed";
else if (key == "startDateCode") prefix = "mission date";
else if (key == "weatherProfile") prefix = "mission weather";
else if (key == "viewDistanceMetres") prefix = "mission viewdistance";
else if (key == "reportIntervalSeconds") prefix = "hq interval";
else if (key == "supportSquadLimit") prefix = "hq squads";
else if (key == "responseRadiusMetres") prefix = "hq radius";
else if (key == "antiAirChancePercent") prefix = "hq aa";
else if (key == "antiPersonnelMineChancePercent") prefix = "hq mines";
else if (key == "heliTimerMinutes") prefix = "hq heli timer";
else if (key == "convoyCount") prefix = "hq convoy count";
value = value.Trim();
if (prefix.IsEmpty() || value.IsEmpty() || value.Length() > 32) return "";
@@ -100,12 +130,21 @@ 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", "OpenSettings", "OpenWorld", "OpenFactions", "OpenHQ", "OpenJournal", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus"};
array<string> names = {"PlaneOn", "PlaneOff", "Close", "OpenSettings", "OpenWorld", "OpenFactions", "OpenHQ", "OpenJournal", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS"};
names.Insert("RandomDay_startTimeMinutes");
names.Insert("Default_viewDistanceMetres");
names.Insert("OpenForces");
names.Insert("OpenGuards"); names.Insert("GuardsBack");
names.Insert("OpenSites"); names.Insert("SitesBack");
names.Insert("OpenPlane"); names.Insert("PlaneBack");
names.Insert("OpenAI"); names.Insert("AIBack");
names.Insert("EquipmentOverview"); names.Insert("OpenAllowedItems"); names.Insert("OpenItemPresets"); names.Insert("OpenWeights"); names.Insert("OpenRecommendations"); names.Insert("RecommendationsBack"); names.Insert("RecommendedPlane"); names.Insert("RecommendedWeapons");
for (int density = 1; density <= 4; density++) names.Insert("GuardEvery_" + density.ToString());
for (int policy = 0; policy <= 2; policy++) names.Insert("GuardHQ_" + policy.ToString());
foreach (string key : ERR_AdminMenuFields.Keys())
{
names.Insert("Apply_" + key);
names.Insert("Random_" + key);
if (ERR_Settings.Randomizable(key)) names.Insert("Random_" + key);
}
foreach (string name : names)
{
@@ -195,8 +234,8 @@ class ERR_AdminMenu : ChimeraMenuBase
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"}; }
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI"}; }
protected void ShowPage(int page)
{
array<string> pages = Pages();
@@ -206,31 +245,42 @@ class ERR_AdminMenu : ChimeraMenuBase
for (int i = 0; i < pages.Count(); i++)
{
GetRootWidget().FindAnyWidget(pages[i]).SetVisible(i == page);
GetRootWidget().FindAnyWidget("Selected_" + navigation[i]).SetVisible(i == page);
bool selected = i == page || ((page == 12 || page == 13) && i == 11) || ((page == 16 || page == 17 || page == 18) && i == 9) || ((page == 14 || page == 19) && i == 1);
GetRootWidget().FindAnyWidget("Selected_" + navigation[i]).SetVisible(selected);
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText(navigation[i], GetRootWidget()), selected);
}
GetRootWidget().FindAnyWidget("EquipmentNavigation").SetVisible(page == 9 || page == 16 || page == 17 || page == 18);
GetRootWidget().FindAnyWidget("Selected_EquipmentOverview").SetVisible(page == 9);
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("EquipmentOverview", GetRootWidget()), page == 9);
if (page == 2) m_LogPoll = 1;
}
protected int m_KeepWeaponState = -1;
protected void UpdateActions()
{
ERR_SelectionStyle.Pair(GetRootWidget(), "KeepWeaponOn", "KeepWeaponOff", m_KeepWeaponState);
foreach (SCR_ButtonBaseComponent button : m_Buttons)
{
string name = button.GetRootWidget().GetName();
if (name.IndexOf("Apply_") == 0 || name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0)
if (name.IndexOf("Apply_") == 0 || name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0 || name.IndexOf("Default_") == 0)
{
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 == "PlaneOn" || name == "PlaneOff") button.SetEnabled(m_Ready && m_HQ && m_AvailableFields.Contains("planeReconEnabled"));
else if (name.IndexOf("GuardEvery_") == 0) button.SetEnabled(m_Ready && m_HQ && m_AvailableFields.Contains("objectGuardEvery"));
else if (name.IndexOf("GuardHQ_") == 0) button.SetEnabled(m_Ready && m_HQ && m_AvailableFields.Contains("objectGuardHQMode"));
else if (name == "RerollIntel" || name == "IntelSummary") button.SetEnabled(m_Ready && m_Intel);
else if (name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ);
else if (name == "KeepWeaponOn" || name == "KeepWeaponOff") button.SetEnabled(m_Ready);
else if (name == "KeepWeaponOn") button.SetEnabled(m_Ready && m_KeepWeaponState == 0);
else if (name == "KeepWeaponOff") button.SetEnabled(m_Ready && m_KeepWeaponState == 1);
}
}
void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
if (!authorized) { Close(); return; }
if (!values) return;
m_HQ = false; m_Intel = false; m_AvailableFields.Clear();
m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1;
foreach (string entry : values)
{
if (entry == "supports=hq") { m_HQ = true; continue; }
@@ -238,23 +288,58 @@ class ERR_AdminMenu : ChimeraMenuBase
int divider = entry.IndexOf("=");
if (divider <= 0) continue;
string key = entry.Substring(0, divider);
if (key == "recommendedBikeRuntime") { SetText("RecommendedBikeRuntime", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
if (key.IndexOf("recommended_") == 0)
{
SetText(key, entry.Substring(divider + 1, entry.Length() - divider - 1)); continue;
}
if (key.IndexOf("recommendedReady_") == 0)
{
string addonKey = key.Substring(17, key.Length() - 17);
Widget state = GetRootWidget().FindAnyWidget("recommended_" + addonKey);
if (state)
{
if (entry.Substring(divider + 1, entry.Length() - divider - 1) == "1") state.SetColor(Color.FromRGBA(105, 190, 140, 255));
else state.SetColor(Color.FromRGBA(220, 115, 115, 255));
}
continue;
}
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 (key == "planeReconEnabled")
{
int planeState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
ERR_SelectionStyle.Pair(GetRootWidget(), "PlaneOn", "PlaneOff", planeState);
m_AvailableFields.Insert(key); continue;
}
if (key == "keepWeaponOnUnconscious")
{
m_KeepWeaponState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
string retention = "Disabled: held guns drop on unconsciousness.";
if (entry.Substring(divider + 1, entry.Length() - divider - 1) == "1") retention = "Enabled: retain your gun and equip it after revival.";
SetText("Saved_keepWeaponOnUnconscious", retention);
continue;
}
if (!ERR_AdminMenuFields.Keys().Contains(key)) continue;
string value = ERR_AdminMenuFields.Display(key, entry.Substring(divider + 1, entry.Length() - divider - 1));
string saved = entry.Substring(divider + 1, entry.Length() - divider - 1);
if (ERR_Settings.Randomizable(key)) ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("Random_" + key, GetRootWidget()), saved == "-1");
if (key == "startTimeMinutes") ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("RandomDay_startTimeMinutes", GetRootWidget()), saved == "-2");
if (key == "viewDistanceMetres") ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("Default_viewDistanceMetres", GetRootWidget()), saved == "0");
if (key == "objectGuardEvery")
for (int density = 1; density <= 4; density++)
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("GuardEvery_" + density.ToString(), GetRootWidget()), saved.ToInt() == density);
if (key == "objectGuardHQMode")
for (int policy = 0; policy <= 2; policy++)
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("GuardHQ_" + policy.ToString(), GetRootWidget()), saved.ToInt() == policy);
string value = ERR_AdminMenuFields.Display(key, saved);
m_AvailableFields.Insert(key);
SetText("Saved_" + key, "Saved: " + value);
if (key == "startMode") SetText("Saved_" + key, ERR_AdminMenuFields.StartSummary(entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt(), m_Mode.StartMode()));
else if (key == "aiDoctrine" || key == "aiShadow") SetText("Saved_" + key, "Next round: " + value);
else SetText("Saved_" + key, "Saved: " + value);
// Existing fields retain all draft text, including a temporarily empty edit.
if (!m_InitializedFields.Contains(key))
{
@@ -272,7 +357,7 @@ class ERR_AdminMenu : ChimeraMenuBase
}
if (summary.IsEmpty()) summary = "No mission status is available yet.";
SetText("Status", summary);
string connection = "Connected to server. Editing a value does not save it: press Apply. Unsaved edits are lost on close.";
string connection = "Connected to server. Use Apply for edited values. Selection buttons save immediately. Unsaved edits are lost on close.";
if (!m_HQ || !m_Intel) connection = "Connected. Controls for companion systems absent from this mission are disabled.";
SetText("Connection", connection);
m_Ready = true; m_Age = 0; UpdateActions();
@@ -324,6 +409,13 @@ class ERR_AdminMenu : ChimeraMenuBase
if (!button || !IsFocused()) return;
string name = button.GetRootWidget().GetName();
if (name == "Close") { Close(); return; }
if (name == "RecommendationsBack") { ShowPage(3); return; }
if (name == "RecommendedPlane") { ShowPage(14); return; }
if (name == "EquipmentOverview") { ShowPage(9); return; }
if (name == "RecommendedWeapons") { ShowPage(9); return; }
if (name == "PlaneBack") { ShowPage(1); return; }
if (name == "AIBack") { ShowPage(1); return; }
if (name == "GuardsBack" || name == "SitesBack") { ShowPage(11); return; }
int page = Navigation().Find(name);
if (page >= 0) { ShowPage(page); return; }
if (name == "JournalDetails")
@@ -354,6 +446,24 @@ class ERR_AdminMenu : ChimeraMenuBase
return;
}
if (!m_Controller || !m_Ready || m_Age > 6 || m_ClickDelay > 0) return;
if (name.IndexOf("GuardEvery_") == 0 || name.IndexOf("GuardHQ_") == 0)
{
string guardKey = "objectGuardEvery";
if (name.IndexOf("GuardHQ_") == 0) guardKey = "objectGuardHQMode";
if (!m_HQ || !m_AvailableFields.Contains(guardKey)) return;
int guardPrefix = name.IndexOf("_") + 1;
SCR_EditBoxComponent guardEdit = SCR_EditBoxComponent.GetEditBoxComponent(guardKey, GetRootWidget());
if (guardEdit) guardEdit.SetValue(name.Substring(guardPrefix, name.Length() - guardPrefix));
SetText("Feedback", "Choice selected. Press Apply to save for next round. Green marks the saved choice.");
return;
}
if (name == "PlaneOn" || name == "PlaneOff")
{
if (!m_HQ || !m_AvailableFields.Contains("planeReconEnabled")) return;
string enabled = "0"; if (name == "PlaneOn") enabled = "1";
m_ClickDelay = 0.3; SetText("Feedback", "Saving reconnaissance plane selection for next round...");
m_Controller.ERR_AdminSubmit("mission plane set " + enabled, true); return;
}
if (name == "KeepWeaponOn" || name == "KeepWeaponOff")
{
string retain = "0"; if (name == "KeepWeaponOn") retain = "1";
@@ -370,7 +480,7 @@ class ERR_AdminMenu : ChimeraMenuBase
if (pause) pause.SetText("Pause live");
return;
}
if (name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0)
if (name.IndexOf("Random_") == 0 || name.IndexOf("RandomDay_") == 0 || name == "Default_viewDistanceMetres")
{
int prefix = name.IndexOf("_") + 1;
string key = name.Substring(prefix, name.Length() - prefix);
@@ -379,6 +489,7 @@ class ERR_AdminMenu : ChimeraMenuBase
if (edit)
{
string choice = "Random";
if (name == "Default_viewDistanceMetres") choice = "default";
if (name.IndexOf("RandomDay_") == 0) choice = "Random day";
edit.SetValue(choice);
SetText("Feedback", choice + " selected. Press Apply to save.");
@@ -4,6 +4,24 @@ class ERR_CommandArguments
{
static string CoreSetting(string section, string option)
{
if (section == "mission" && option == "doctrine") return "aiDoctrine";
if (section == "mission" && option == "aishadow") return "aiShadow";
if (section == "mission" && option == "motorbikes") return "motorbikeCount";
if (section == "mission" && option == "planealtitude") return "planeAltitudeMetres";
if (section == "mission" && option == "planereplacement") return "planeReplacementSeconds";
if (section == "mission" && option == "planesector") return "planeSectorSeconds";
if (section == "mission" && option == "plane") return "planeReconEnabled";
if (section == "mission" && option == "camps") return "fieldCampCount";
if (section == "mission" && option == "hospitals") return "fieldHospitalCount";
if (section == "mission" && option == "startweapons") return "startWeaponCount";
if (section == "mission" && option == "startammo") return "startMagazineCount";
if (section == "mission" && option == "objectevery") return "objectGuardEvery";
if (section == "mission" && option == "objecthq") return "objectGuardHQMode";
if (section == "mission" && option == "objectguards") return "objectGuardCount";
if (section == "mission" && option == "hunterdistance") return "hunterSpawnDistanceMetres";
if (section == "mission" && option == "roadblocks") return "roadblockCount";
if (section == "mission" && option == "traffic") return "civilianTrafficCount";
if (section == "mission" && option == "start") return "startMode";
if (section == "mission" && option == "keepweapon") return "keepWeaponOnUnconscious";
if (section == "mission" && option == "radio") return "radioDefenceSeconds";
if (section == "intel" && option == "reveal") return "intelRevealChancePercent";
@@ -11,11 +29,18 @@ class ERR_CommandArguments
}
static bool Selection(string key, string text, out int value)
{
if (key == "aiDoctrine" && text == "current") { value = 0; return true; }
if (key == "aiDoctrine" && text == "coordinated") { value = 1; return true; }
if (key == "aiShadow" && text == "off") { value = 0; return true; }
if (key == "aiShadow" && text == "on") { value = 1; return true; }
if (key == "startMode" && text == "prison") { value = 0; return true; }
if (key == "startMode" && text == "crash") { value = 1; return true; }
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);
if (key == "viewDistanceMetres" && text == "default") { value = 0; return true; }
return Integer(text, value);
}
static bool Integer(string text, out int value)
@@ -81,6 +106,37 @@ class ERR_AdminService
}
return handled;
}
protected bool ViewDistanceCommand(ERR_GameMode mode, array<string> parts, array<string> replies)
{
if (parts.Count() < 2 || parts[0] != "mission" || parts[1] != "viewdistance") return false;
if (parts.Count() == 3 && parts[2] == "show")
{
replies.Insert("Mission view distance: " + ERR_AdminMenuFields.Display("viewDistanceMetres", mode.Settings().Get("viewDistanceMetres").ToString()) + ". Server limit: " + GetGame().GetViewDistanceServerLimit().ToString() + " m. Platform limits also apply.");
return true;
}
int value;
if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Selection("viewDistanceMetres", parts[3], value) && mode.Settings().Set("viewDistanceMetres", value))
{
mode.PublishViewDistance();
replies.Insert("Saved mission view distance: " + ERR_AdminMenuFields.Display("viewDistanceMetres", value.ToString()) + ". Applies now when players close menus, and to later joins; server/platform limits apply.");
}
else replies.Insert("Use /esc mission viewdistance show|set 500..10000|default. Invalid value or save failure leaves the previous setting unchanged.");
return true;
}
protected bool StartCommand(ERR_GameMode mode, array<string> parts, array<string> replies)
{
if (parts.Count() < 2 || parts[0] != "mission" || parts[1] != "start") return false;
if (parts.Count() == 3 && parts[2] == "show")
{
replies.Insert(ERR_AdminMenuFields.StartSummary(mode.Settings().Get("startMode"), mode.StartMode()));
return true;
}
int value;
if (parts.Count() == 4 && parts[2] == "set" && ERR_CommandArguments.Selection("startMode", parts[3], value) && mode.Settings().Set("startMode", value))
replies.Insert(ERR_AdminMenuFields.StartSummary(value, mode.StartMode()) + ". Saved for next round; current start and players are unchanged.");
else replies.Insert("Use /esc mission start show|set prison|crash (or 0|1). No random start. Invalid value or save failure leaves the previous selection unchanged.");
return true;
}
protected bool TimeCommand(ERR_GameMode mode, array<string> parts, array<string> replies)
{
if (parts.Count() < 2 || parts[0] != "mission" || (parts[1] != "time" && parts[1] != "speed")) return false;
@@ -107,6 +163,23 @@ class ERR_AdminService
}
protected string SettingHelp(string key)
{
if (key == "aiDoctrine") return "Current (0) or Coordinated (1); default Current. Saved for next round.";
if (key == "aiShadow") return "On (1) records coordinated proposals without issuing their orders; Off (0) uses the selected doctrine. Saved for next round.";
if (key == "startWeaponCount") return "0..12 pistols in the shared prison/crash supply backpack; default 5. Applies next round.";
if (key == "startMagazineCount") return "0..48 spare pistol magazines in the shared prison/crash supply backpack; default 8. Applies next round.";
if (key == "motorbikeCount") return "0-6; default 2; 0 disabled. Optional WCS Motorbikes, two response specialists per bike. Applies next round; losses are not replaced.";
if (key == "planeAltitudeMetres") return "150-500; default 250. Applies next round.";
if (key == "planeReplacementSeconds") return "30-1800; default 60. Applies next round.";
if (key == "planeSectorSeconds") return "30-600; default 120. Applies next round.";
if (key == "planeReconEnabled") return "0 disabled, 1 enabled if Cessna 172 and Propeller Flight Core are loaded. Applies next round. A clear airport departure area is required.";
if (key == "fieldCampCount") return "1..16 field camps; default 12. Applies next round. Counts are requested: unsafe optional sites may be omitted.";
if (key == "fieldHospitalCount") return "1..8 field hospitals; default 6. Applies next round. The required radio-clue hospital is retained.";
if (key == "objectGuardEvery") return "Guard every 1st, 2nd, 3rd or 4th eligible site; default every third. Nearby buildings share one site. Next round.";
if (key == "objectGuardHQMode") return "0: all hold their posts; 1: half hold, half respond to HQ; 2: all may respond to HQ. All report contacts. Next round.";
if (key == "objectGuardCount") return "0..16 soldiers per factory/industrial, military or infrastructure site; default 8, zero off. Next round. HQ participation is configured separately; all report. Towers and chimneys use suitable platforms. Lost guards are not replaced.";
if (key == "hunterSpawnDistanceMetres") return "Opening UAZ and troop-truck patrol placement. Applies next round.";
if (key == "roadblockCount") return "Set 0..12 roadblocks (default 4; 0 disables). Applies next round. Each has four guards; unsuitable sites are skipped.";
if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved.";
if (key == "keepWeaponOnUnconscious") return "Set 1 to retain the held firearm during unconsciousness and equip it after recovery; 0 keeps normal dropping. Applies to the next knockout.";
if (key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent")
return "Set 0..100 percent or Random. Applies to newly equipped enemy soldiers; existing loadouts are not rerolled or replenished.";
@@ -114,14 +187,25 @@ class ERR_AdminService
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 string RoadblockTiming(string key)
{
if (key == "aiDoctrine" || key == "aiShadow") return " for next round";
if (key == "motorbikeCount" || key == "planeAltitudeMetres" || key == "planeReplacementSeconds" || key == "planeSectorSeconds" || key == "planeReconEnabled" || key == "fieldCampCount" || key == "fieldHospitalCount" || key == "startWeaponCount" || key == "startMagazineCount" || key == "objectGuardCount" || key == "objectGuardEvery" || key == "objectGuardHQMode" || key == "hunterSpawnDistanceMetres" || key == "roadblockCount") return " for next round";
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 + "=" + ERR_Settings.SelectionText(settings.Get(key))); return; }
if (parts.Count() == 3 && parts[2] == "show")
{
string displayed = ERR_Settings.SelectionText(settings.Get(key));
if (key == "aiDoctrine" || key == "aiShadow") displayed = ERR_AdminMenuFields.Display(key, settings.Get(key).ToString()) + " (next round)";
replies.Insert(key + "=" + displayed); 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.Selection(key, parts[3], value) && settings.Set(key, value))
replies.Insert(key + "=" + ERR_Settings.SelectionText(value) + " saved");
replies.Insert(key + "=" + ERR_Settings.SelectionText(value) + " saved" + RoadblockTiming(key));
else
{
string failure = "Invalid value or settings could not be saved; previous setting retained.";
@@ -136,6 +220,10 @@ class ERR_AdminService
if (topic == "time" || topic == "mission")
{
replies.Insert("/esc mission radio show|set N|random | mission abandon");
replies.Insert("/esc mission doctrine show|set current|coordinated | mission aishadow show|set off|on; next round only.");
replies.Insert("/esc mission start show|set prison|crash: save the next round start; current round unchanged.");
replies.Insert("/esc mission startweapons show|set 0..12 | mission startammo show|set 0..48; shared backpack, next round.");
replies.Insert("/esc mission viewdistance show|set 500..10000|default; applies immediately after closing menus.");
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;
@@ -153,7 +241,8 @@ class ERR_AdminService
}
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 mission date|weather|speed show|set VALUE | mission viewdistance show|set 500..10000|default | intel reveal show|set N|help");
replies.Insert("/esc mission start show|set prison|crash; next round only.");
replies.Insert("/esc spectate next|previous; optional systems add their own command topics.");
}
protected bool Unavailable(array<string> parts, array<string> replies)
@@ -193,6 +282,8 @@ class ERR_AdminService
foreach (string key : ERR_Settings.Keys())
{
string line = key + "=" + mode.Settings().Get(key).ToString();
if (key == "startMode") line = ERR_AdminMenuFields.StartSummary(mode.Settings().Get(key), mode.StartMode());
if (key == "aiDoctrine" || key == "aiShadow") line = key + "=" + ERR_AdminMenuFields.Display(key, mode.Settings().Get(key).ToString()) + " (next round)";
if (key == "automaticConvoyEnabled") line += " (saved for next round; current startup unchanged)";
replies.Insert(line);
}
@@ -219,7 +310,9 @@ class ERR_AdminService
return;
}
if (ERR_EnvironmentCommands.Execute(mode.Settings(), mode.Environment(), parts, replies)) return;
if (ViewDistanceCommand(mode, parts, replies)) return;
if (TimeCommand(mode, parts, replies)) return;
if (StartCommand(mode, parts, replies)) return;
if (parts.Count() == 3 || parts.Count() == 4)
{
string key = Setting(parts[0], parts[1]);
@@ -9,6 +9,8 @@ class ERR_AdminSession
protected bool m_Stopped;
protected bool m_Live;
protected int m_Cursor;
protected string m_ItemAddon;
protected int m_ItemPage;
protected float m_LastRequest = -1000;
protected float m_LastSnapshot = -1000;
protected float m_LastLog = -1000;
@@ -66,11 +68,43 @@ class ERR_AdminSession
return;
}
array<string> replies = {};
if (!JournalCommand(arguments, replies) && !m_Tools.Execute(m_Mode, m_Controller, arguments, replies))
if (!ItemCatalogCommand(arguments, replies) && !JournalCommand(arguments, replies) && !m_Tools.Execute(m_Mode, m_Controller, arguments, replies))
m_Mode.Admin().Execute(m_Mode, true, arguments, replies);
foreach (string reply : replies) m_Controller.ERR_AdminDeliverReply(m_Token, reply, menu);
if (menu) Snapshot(true);
}
// Per-session paging: another administrator cannot change this menu's selection.
protected bool ItemCatalogCommand(string arguments, array<string> replies)
{
array<string> parts = {}; arguments.Split(" ", parts, true);
if (parts.IsEmpty() || parts[0] != "items") return false;
int page;
ERR_ServerItemCatalog catalog = GetGame().ERR_ServerItems(); catalog.Start(m_Mode);
if (parts.Count() == 4 && parts[1] == "preset")
{
if (GetGame().ERR_ItemSelections().Preset(parts[2], parts[3])) replies.Insert("Preset " + parts[2] + " completed: " + parts[3] + ". Future random equipment uses the saved selection.");
else replies.Insert("Preset not saved/applied. Use an existing name to load/delete, or save a name of 1-24 letters, digits, _ or -. Maximum 16 presets. Previous selection retained.");
return true;
}
if (parts.Count() == 5 && parts[1] == "set" && catalog.Find(parts[2]) && (parts[3] == "*" || catalog.Contains(parts[2], parts[3])) && (parts[4] == "0" || parts[4] == "1"))
{
if (GetGame().ERR_ItemSelections().Set(parts[2], parts[3], parts[4] == "1")) replies.Insert("Item selection saved. Applies to future random equipment; existing inventories remain unchanged.");
else replies.Insert("Could not save item selection. Previous selection retained.");
return true;
}
if (parts.Count() == 5 && parts[1] == "weight" && catalog.Find(parts[2]) && (parts[3] == "*" || catalog.Contains(parts[2], parts[3])))
{
int weight;
if (!ERR_CommandArguments.Integer(parts[4], weight) || weight < 0 || weight > 100) replies.Insert("Relative weight must be 0-100; 1 is normal, 0 excludes from extra equipment.");
else if (GetGame().ERR_ItemSelections().SetWeight(parts[2], parts[3], weight)) replies.Insert("Probability weight saved. Applies to future extra-equipment rolls; disabled items remain disabled.");
else replies.Insert("Could not save weight. Previous settings retained.");
return true;
}
if (parts.Count() != 4 || parts[1] != "list" || !catalog.Find(parts[2]) || !ERR_CommandArguments.Integer(parts[3], page) || page < 0 || page > 2730)
{ replies.Insert("Choose a loaded server mod in Items, then press Show items."); return true; }
m_ItemAddon = parts[2]; m_ItemPage = page;
return true;
}
protected bool JournalCommand(string arguments, array<string> replies)
{
if (arguments != "hq log" && arguments != "hq log show" && arguments != "hq log next" && arguments != "hq log set on" && arguments != "hq log set off") return false;
@@ -99,6 +133,8 @@ class ERR_AdminSession
if (m_Mode.Settings().Get(key) == -1) values.Insert("resolved_" + key + "=" + m_Mode.Settings().Runtime(key).ToString());
}
m_Mode.Admin().Capabilities(values);
ERR_RecommendedAddons.Snapshot(m_Mode, values);
GetGame().ERR_ServerItems().Snapshot(m_Mode, values, m_ItemAddon, m_ItemPage);
m_Mode.Admin().Execute(m_Mode, true, "status", status);
m_Controller.ERR_AdminDeliverSnapshot(m_Token, true, values, status);
}
@@ -3,19 +3,53 @@
class ERR_AirfieldPlacement
{
protected bool m_NearAirport;
// Optional terrain/adapter anchors; positions are centres, not trusted pads.
// Numbered names allow several airports without matching unrelated map labels.
static void AuthoredAirports(array<vector> positions)
{
if (!GetGame().GetWorldEntity()) return;
vector low, high;
GetGame().GetWorldEntity().GetWorldBounds(low, high);
for (int index = -1; index < 16; index++)
{
string name = "ERR_ReconAirport";
if (index >= 0) name = "ERR_Airfield_" + index.ToString();
IEntity marker = GetGame().GetWorld().FindEntityByName(name);
if (!marker) continue;
vector position = ERR_Actors.Ground(marker.GetOrigin());
if (position[0] < low[0] || position[0] > high[0] || position[2] < low[2] || position[2] > high[2]) continue;
if (ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) continue;
positions.Insert(position);
}
}
static bool AirportPrefab(string path)
{
path.ToLower();
return path.Contains("prefabs/structures/") && (path.Contains("/runway") || path.Contains("/lightrunway_") || path.Contains("/aircontroltower") || path.Contains("/airportcontroltower") || path.Contains("/controltower"));
}
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;
if (AirportPrefab(path) || 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);
array<vector> authored = {};
AuthoredAirports(authored);
foreach (vector centre : authored)
if (vector.DistanceXZ(centre, position) <= 600) survey.m_NearAirport = true;
if (!survey.m_NearAirport) GetGame().GetWorld().QueryEntitiesBySphere(position, 600, survey.Airport, null, EQueryEntitiesFlags.ALL);
if (!survey.m_NearAirport) return false;
return ClearGround(position);
}
// Same aircraft footprint/vertical sweep, without requiring airport scenery.
static bool ClearGround(vector position)
{
if (!Replication.IsServer() || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), position)) return false;
for (int edge = 0; edge < 4; edge++)
{
float side = edge * Math.PI / 2;
@@ -1,5 +1,5 @@
// Conservative fallback for a sleeping, engine-off Mi8 whose native simulation
// has not yet populated contact flags. Reads physical support; never wakes it.
// Conservative fallback for audited sleeping, engine-off rescue aircraft whose
// native simulation has not populated contact flags. Reads support; never wakes it.
class ERR_CasualtyGroundSupport
{
static bool SupportHit(float fraction, float normalY, bool selfHit, bool dynamicHit)
@@ -59,14 +59,37 @@ class ERR_CasualtyGroundSupport
}
return supported;
}
static int LandingGearKind(string prefab)
{
if (prefab.Contains("/Mi8MT/") || prefab.EndsWith("Prefabs/ERR/RescueHelicopter.et") || prefab.EndsWith("Prefabs/ERR/RescueHelicopterFIA.et") || prefab.EndsWith("Prefabs/ERR/RescueHelicopterAFRF.et")) return 1;
if (prefab.Contains("/UH1H/") || prefab.EndsWith("Prefabs/ERR/RescueHelicopterWest.et") || prefab.EndsWith("Prefabs/ERR/RescueHelicopterION.et")) return 2;
return 0;
}
static bool SkidsSupported(Vehicle aircraft, bool diagnostics = false)
{
vector leftFront, rightFront, leftRear, rightRear;
if (!WheelCenter(aircraft, "v_ground_contact_L01", leftFront, diagnostics) || !WheelCenter(aircraft, "v_ground_contact_R01", rightFront, diagnostics) || !WheelCenter(aircraft, "v_ground_contact_L02", leftRear, diagnostics) || !WheelCenter(aircraft, "v_ground_contact_R02", rightRear, diagnostics)) return false;
// Installed UH1H_base.et defines these four skid pivots without wheel radii.
// Both skids and both ends need direct static support. Missing/collapsed
// pivots and a helicopter suspended above the ground fail closed.
if (vector.DistanceXZ(leftFront, rightFront) < 1 || vector.DistanceXZ(leftRear, rightRear) < 1 || vector.DistanceXZ(leftFront, leftRear) < 1 || vector.DistanceXZ(rightFront, rightRear) < 1) return false;
bool a = WheelSupported(aircraft, leftFront, 0, diagnostics);
bool b = WheelSupported(aircraft, rightFront, 0, diagnostics);
bool c = WheelSupported(aircraft, leftRear, 0, diagnostics);
bool d = WheelSupported(aircraft, rightRear, 0, diagnostics);
return a && b && c && d;
}
static bool Parked(Vehicle aircraft, bool diagnostics = false)
{
if (!aircraft || !aircraft.GetPrefabData() || !aircraft.GetPrefabData().GetPrefabName().Contains("/Mi8MT/")) return false;
if (!aircraft || !aircraft.GetPrefabData()) return false;
int kind = LandingGearKind(aircraft.GetPrefabData().GetPrefabName());
if (!kind) return false;
Physics physics = aircraft.GetPhysics();
HelicopterControllerComponent controller = HelicopterControllerComponent.Cast(aircraft.FindComponent(HelicopterControllerComponent));
if (!physics || !controller || !controller.GetSimulation()) return false;
if (diagnostics) PrintFormat("ERR PARKED SUPPORT: physicsAwake=%1 engineOn=%2 nativeContact=%3", physics.IsActive(), controller.GetSimulation().EngineIsOn(), controller.GetSimulation().HasAnyGroundContact());
if (physics.IsActive() || controller.GetSimulation().EngineIsOn()) return false;
if (kind == 2) return SkidsSupported(aircraft, diagnostics);
vector front, left, right;
if (!WheelCenter(aircraft, "v_wheel_F", front, diagnostics) || !WheelCenter(aircraft, "v_wheel_bL", left, diagnostics) || !WheelCenter(aircraft, "v_wheel_bR", right, diagnostics)) return false;
// Fail closed if native pivot resolution collapses to an owner origin or
@@ -4,7 +4,7 @@ class ERR_CasualtyFeedback
static bool Visible(ERR_GameMode mode, IEntity caller, IEntity casualty)
{
if (!mode || !mode.Active() || mode.Phase() != ERR_Phase.EXTRACTION || !caller || caller == casualty || !ERR_CasualtyLoadingRules.Down(casualty)) return false;
Vehicle aircraft = mode.CasualtyLoadingAircraft();
Vehicle aircraft = mode.CasualtyLoadingAircraftFor(caller, casualty);
Faction faction = mode.PlayerFaction();
return aircraft && faction && SCR_FactionManager.SGetFaction(caller) == faction && SCR_FactionManager.SGetFaction(casualty) == faction && ERR_CasualtyLoadingRules.Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin()));
}
@@ -14,7 +14,7 @@ class ERR_CasualtyFeedback
if (!caller || caller == casualty || !ERR_Actors.CanFight(caller)) return "A living, conscious rescuer is required";
if (CompartmentAccessComponent.GetVehicleIn(caller)) return "Leave the helicopter before loading a casualty";
if (!ERR_CasualtyLoadingRules.GroundSettled(casualty)) return "Place the living, unconscious casualty on stable ground";
Vehicle aircraft = mode.CasualtyLoadingAircraft();
Vehicle aircraft = mode.CasualtyLoadingAircraftFor(caller, casualty);
if (!aircraft || !ERR_CasualtyLoadingRules.Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin()))) return "Stay beside the casualty and within twelve metres of the extraction helicopter";
if (!ERR_CasualtyLoadingRules.AircraftReady(aircraft)) return "The helicopter must be upright, grounded and stationary";
if (!ERR_CasualtyLoadingRules.Nearby(mode, caller, casualty)) return "Keep clear access to a fellow prisoner's body";
@@ -1,9 +1,11 @@
// Round-owned server service; only the aircraft reference is replicated for action visibility.
// Round-owned server service; only registered transport references are replicated.
modded class ERR_GameMode
{
protected ref ERR_CasualtyLoadingService m_ERRCasualtyLoading;
[RplProp()]
protected RplId m_ERRCasualtyAircraftId = RplId.Invalid();
[RplProp()]
protected RplId m_ERRCasualtySecondAircraftId = RplId.Invalid();
ERR_CasualtyLoadingService CasualtyLoading() { return m_ERRCasualtyLoading; }
Vehicle CasualtyLoadingAircraft()
{
@@ -12,17 +14,61 @@ modded class ERR_GameMode
if (!component) return null;
return Vehicle.Cast(component.GetEntity());
}
int CasualtyLoadingAircrafts(out array<Vehicle> aircrafts)
{
aircrafts.Clear();
if (Replication.IsServer())
{
if (Extraction()) return Extraction().Vehicles(aircrafts);
return 0;
}
Vehicle first = CasualtyLoadingAircraft();
if (first) aircrafts.Insert(first);
Vehicle second = Vehicle.Cast(ERR_CasualtyLoadingRules.Entity(m_ERRCasualtySecondAircraftId));
if (second && second != first) aircrafts.Insert(second);
return aircrafts.Count();
}
bool CasualtyLoadingTransport(Vehicle aircraft)
{
if (!aircraft) return false;
if (Replication.IsServer()) return Extraction() && Extraction().IsTransport(aircraft);
return aircraft == CasualtyLoadingAircraft() || (m_ERRCasualtySecondAircraftId != RplId.Invalid() && ERR_CasualtyLoadingRules.EntityId(aircraft) == m_ERRCasualtySecondAircraftId);
}
// Prefer a usable nearby transport; retain the nearest ineligible aircraft for
// a useful denial reason. An existing casualty reservation wins over distance.
Vehicle CasualtyLoadingAircraftFor(IEntity caller, IEntity casualty)
{
if (!caller || !casualty) return null;
array<Vehicle> aircrafts = {}; CasualtyLoadingAircrafts(aircrafts);
Vehicle nearest, available;
float nearestDistance = float.MAX, availableDistance = float.MAX;
foreach (Vehicle aircraft : aircrafts)
{
if (!ERR_Actors.Alive(aircraft)) continue;
float distance = vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin());
if (distance < nearestDistance) { nearest = aircraft; nearestDistance = distance; }
if (!ERR_CasualtyLoadingRules.Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), distance) || !ERR_CasualtyLoadingRules.AircraftReady(aircraft)) continue;
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(aircraft.FindComponent(BaseCompartmentManagerComponent));
if (!manager) continue;
array<BaseCompartmentSlot> seats = {}; manager.GetCompartments(seats);
foreach (BaseCompartmentSlot seat : seats)
{
if (!ERR_CasualtyLoadingRules.TransportBench(seat, aircraft) || seat.GetOccupant() || !seat.IsCompartmentAccessible() || seat.IsGetInLockedFor(casualty)) continue;
if (seat.IsReservedBy(casualty)) return aircraft;
if (!seat.IsReserved() && distance < availableDistance) { available = aircraft; availableDistance = distance; }
}
}
if (available) return available;
return nearest;
}
protected void ERR_UpdateCasualtyAircraft(float now)
{
Vehicle aircraft = ExtractionVehicle();
if (!aircraft)
{
if (m_ERRCasualtyAircraftId != RplId.Invalid()) { m_ERRCasualtyAircraftId = RplId.Invalid(); Replication.BumpMe(); }
return;
}
RplComponent component = RplComponent.Cast(aircraft.FindComponent(RplComponent));
if (!component || m_ERRCasualtyAircraftId == component.Id()) return;
m_ERRCasualtyAircraftId = component.Id();
array<Vehicle> aircrafts = {}; CasualtyLoadingAircrafts(aircrafts);
RplId first = RplId.Invalid(), second = RplId.Invalid();
if (!aircrafts.IsEmpty()) first = ERR_CasualtyLoadingRules.EntityId(aircrafts[0]);
if (aircrafts.Count() > 1) second = ERR_CasualtyLoadingRules.EntityId(aircrafts[1]);
if (first == m_ERRCasualtyAircraftId && second == m_ERRCasualtySecondAircraftId) return;
m_ERRCasualtyAircraftId = first; m_ERRCasualtySecondAircraftId = second;
Replication.BumpMe();
}
protected void ERR_StopCasualtyLoading()
@@ -52,7 +52,7 @@ modded class SCR_PlayerController
IEntity caller = ERR_CasualtyLoadingRules.Entity(callerId);
Vehicle vehicle = Vehicle.Cast(ERR_CasualtyLoadingRules.Entity(vehicleId));
IEntity owner = ERR_CasualtyLoadingRules.Entity(ownerId);
if (lease && mode && ERR_CasualtyLoadingRules.EntityId(mode) == modeId && ERR_CasualtyLoadingRules.EntityId(actor) == actorId && vehicle == mode.CasualtyLoadingAircraft() && ERR_CasualtyLoadingRules.Nearby(mode, caller, actor) && owner)
if (lease && mode && ERR_CasualtyLoadingRules.EntityId(mode) == modeId && ERR_CasualtyLoadingRules.EntityId(actor) == actorId && mode.CasualtyLoadingTransport(vehicle) && ERR_CasualtyLoadingRules.NearbyAircraft(mode, caller, actor, vehicle) && owner)
{
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(owner.FindComponent(BaseCompartmentManagerComponent));
SCR_CompartmentAccessComponent access = SCR_CompartmentAccessComponent.Cast(actor.FindComponent(SCR_CompartmentAccessComponent));
@@ -1,5 +1,5 @@
// First release supports only the registered Mi8 extraction aircraft's physical
// child transport benches. Nothing changes health, ownership, or seat classes.
// Only registered rescue transports and audited native child benches are eligible.
// Nothing changes health, ownership, or seat classes.
class ERR_CasualtyLoadingRules
{
static int RoundAction(int phase)
@@ -86,20 +86,30 @@ class ERR_CasualtyLoadingRules
return false;
}
static bool Nearby(ERR_GameMode mode, IEntity caller, IEntity casualty)
{
if (!mode) return false;
return NearbyAircraft(mode, caller, casualty, mode.CasualtyLoadingAircraftFor(caller, casualty));
}
static bool NearbyAircraft(ERR_GameMode mode, IEntity caller, IEntity casualty, Vehicle aircraft)
{
if (!mode || !mode.Active() || mode.Phase() != ERR_Phase.EXTRACTION || !caller || caller == casualty) return false;
CharacterControllerComponent rescuer = CharacterControllerComponent.Cast(caller.FindComponent(CharacterControllerComponent));
if (!ERR_Actors.CanFight(caller) || !rescuer || rescuer.GetLifeState() != ECharacterLifeState.ALIVE || !GroundSettled(casualty) || CompartmentAccessComponent.GetVehicleIn(caller)) return false;
Vehicle aircraft = mode.CasualtyLoadingAircraft();
if (!aircraft || !Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin())) || !AircraftReady(aircraft)) return false;
if (!mode.CasualtyLoadingTransport(aircraft) || !Ranges(vector.Distance(caller.GetOrigin(), casualty.GetOrigin()), vector.Distance(caller.GetOrigin(), aircraft.GetOrigin()), vector.Distance(casualty.GetOrigin(), aircraft.GetOrigin())) || !AircraftReady(aircraft)) return false;
Faction faction = mode.PlayerFaction();
return faction && SCR_FactionManager.SGetFaction(caller) == faction && SCR_FactionManager.SGetFaction(casualty) == faction && LineOfSight(caller, casualty);
}
static bool TransportBenchPrefab(string prefab)
{
// Exact authored bench owners, including the two unarmed Huey side seats.
// Aircraft-root pilot seats, turret owners and patient slots stay excluded.
return prefab.EndsWith("Prefabs/Vehicles/Helicopters/Mi8MT/VehParts/Seats/VehPart_MI8_seats_cargo.et") || prefab.EndsWith("Prefabs/Vehicles/Helicopters/Mi8MT/VehParts/Seats/VehPart_MI8_CIV_seats_cargo.et") || prefab.EndsWith("Prefabs/Vehicles/Helicopters/UH1H/VehParts/Seats/VehPart_UH1H_seats_cargo.et") || prefab.EndsWith("Prefabs/Vehicles/Helicopters/UH1H/VehParts/Seats/VehPart_UH1H_seats_cargo_openDoor.et") || prefab.EndsWith("Prefabs/Vehicles/Helicopters/UH1H/VehParts/Seats/VehPart_UH1H_seats_gunners.et") || prefab.EndsWith("Prefabs/Vehicles/Helicopters/UH1H/VehParts/Seats/VehPart_UH1H_CIV_seats_cargo.et") || prefab.EndsWith("Prefabs/Vehicles/Helicopters/UH1H/VehParts/Seats/VehPart_UH1H_CIV_seats_gunners.et");
}
static bool TransportBench(BaseCompartmentSlot seat, Vehicle aircraft)
{
if (!seat || !aircraft || seat.GetType() != ECompartmentType.CARGO || SCR_PatientCompartmentSlot.Cast(seat)) return false;
IEntity owner = seat.GetOwner();
if (!owner || owner == aircraft || !owner.GetPrefabData() || !owner.GetPrefabData().GetPrefabName().Contains("VehPart_MI8_seats")) return false;
if (!owner || owner == aircraft || !owner.GetPrefabData() || !TransportBenchPrefab(owner.GetPrefabData().GetPrefabName())) return false;
while (owner)
{
if (owner == aircraft) return true;
@@ -203,7 +213,7 @@ class ERR_CasualtyLoadingService
{
if (!Replication.IsServer() || m_Stopped || !ERR_CasualtyLoadingRules.Nearby(m_Mode, caller, casualty)) return false;
if (!CurrentLife(caller) || !CurrentLife(casualty) || !Settled(casualty, System.GetTickCount()) || PendingActor(caller, casualty)) return false;
return AvailableSeat(casualty, m_Mode.ExtractionVehicle()) != null;
return AvailableSeat(casualty, m_Mode.CasualtyLoadingAircraftFor(caller, casualty)) != null;
}
// Reasons never replace CanLoad/Begin authority or reserve a seat.
string RejectionReason(IEntity caller, IEntity casualty)
@@ -214,7 +224,7 @@ class ERR_CasualtyLoadingService
if (!CurrentLife(caller) || !CurrentLife(casualty)) return "Both prisoners must still be connected to this mission";
if (!Settled(casualty, System.GetTickCount())) return "Let the casualty rest on the ground for five seconds";
if (PendingActor(caller, casualty)) return "A casualty loading request is already in progress";
if (!AvailableSeat(casualty, m_Mode.ExtractionVehicle())) return "No accessible transport seat is available";
if (!AvailableSeat(casualty, m_Mode.CasualtyLoadingAircraftFor(caller, casualty))) return "No accessible transport seat is available";
return string.Empty;
}
protected void NotifyCaller(IEntity caller, string message)
@@ -238,10 +248,11 @@ class ERR_CasualtyLoadingService
if (!CanLoad(caller, casualty) || m_NextNonce >= 2147483646) return false;
ERR_PlayerLife callerLife = CurrentLife(caller);
ERR_PlayerLife casualtyLife = CurrentLife(casualty);
Vehicle aircraft = m_Mode.CasualtyLoadingAircraftFor(caller, casualty);
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(casualtyLife.ConnectionId));
if (!controller || ERR_CasualtyLoadingRules.EntityId(m_Mode) == RplId.Invalid() || ERR_CasualtyLoadingRules.EntityId(caller) == RplId.Invalid() || ERR_CasualtyLoadingRules.EntityId(casualty) == RplId.Invalid() || ERR_CasualtyLoadingRules.EntityId(m_Mode.ExtractionVehicle()) == RplId.Invalid()) return false;
if (!controller || ERR_CasualtyLoadingRules.EntityId(m_Mode) == RplId.Invalid() || ERR_CasualtyLoadingRules.EntityId(caller) == RplId.Invalid() || ERR_CasualtyLoadingRules.EntityId(casualty) == RplId.Invalid() || ERR_CasualtyLoadingRules.EntityId(aircraft) == RplId.Invalid()) return false;
ERR_CasualtyLoadTicket ticket = new ERR_CasualtyLoadTicket();
ticket.Caller = caller; ticket.Casualty = casualty; ticket.Aircraft = m_Mode.ExtractionVehicle();
ticket.Caller = caller; ticket.Casualty = casualty; ticket.Aircraft = aircraft;
ticket.Seat = AvailableSeat(casualty, ticket.Aircraft);
if (!ticket.Seat || ERR_CasualtyLoadingRules.EntityId(ticket.Seat.GetOwner()) == RplId.Invalid()) return false;
ticket.CallerAccount = callerLife.AccountId; ticket.CasualtyAccount = casualtyLife.AccountId;
@@ -269,7 +280,7 @@ class ERR_CasualtyLoadingService
ERR_CasualtyLoadTicket ticket = Ticket(controller, nonce);
if (!ticket || ticket.Committed) return;
int now = System.GetTickCount();
if (!ValidTicket(ticket) || !ERR_CasualtyLoadingRules.Within(ticket.Started, now, 5000) || !Settled(ticket.Casualty, now) || !ERR_CasualtyLoadingRules.Nearby(m_Mode, ticket.Caller, ticket.Casualty) || ticket.Seat.GetOccupant() || !ticket.Seat.IsCompartmentAccessible() || ticket.Seat.IsGetInLockedFor(ticket.Casualty) || !ticket.Seat.IsReservedBy(ticket.Casualty)) { Resolve(ticket, false); return; }
if (!ValidTicket(ticket) || !ERR_CasualtyLoadingRules.Within(ticket.Started, now, 5000) || !Settled(ticket.Casualty, now) || !ERR_CasualtyLoadingRules.NearbyAircraft(m_Mode, ticket.Caller, ticket.Casualty, ticket.Aircraft) || ticket.Seat.GetOccupant() || !ticket.Seat.IsCompartmentAccessible() || ticket.Seat.IsGetInLockedFor(ticket.Casualty) || !ticket.Seat.IsReservedBy(ticket.Casualty)) { Resolve(ticket, false); return; }
ticket.Committed = true; ticket.CommittedAt = now;
// Final authority/physical validation is the commit boundary. No retries.
controller.ERR_CommitCasualtyLoad(ERR_CasualtyLoadingRules.EntityId(m_Mode), nonce, ERR_CasualtyLoadingRules.EntityId(ticket.Caller), ERR_CasualtyLoadingRules.EntityId(ticket.Casualty), ERR_CasualtyLoadingRules.EntityId(ticket.Aircraft), ERR_CasualtyLoadingRules.EntityId(ticket.Seat.GetOwner()), ticket.Seat.GetCompartmentSlotID());
@@ -288,11 +299,11 @@ class ERR_CasualtyLoadingService
{
// Death cannot revoke an in-flight commit. Keep the vacant seat reserved
// while the captured body exists; its owner may still see the prior state.
return m_Mode && m_Mode.Active() && m_Mode.Phase() == ERR_Phase.EXTRACTION && ticket.Aircraft == m_Mode.ExtractionVehicle() && ERR_Actors.Alive(ticket.Aircraft) && ticket.Casualty != null && ERR_CasualtyLoadingRules.TransportBench(ticket.Seat, ticket.Aircraft);
return m_Mode && m_Mode.Active() && m_Mode.Phase() == ERR_Phase.EXTRACTION && m_Mode.CasualtyLoadingTransport(ticket.Aircraft) && ERR_Actors.Alive(ticket.Aircraft) && ticket.Casualty != null && ERR_CasualtyLoadingRules.TransportBench(ticket.Seat, ticket.Aircraft);
}
protected bool ValidTicket(ERR_CasualtyLoadTicket ticket)
{
return m_Mode && m_Mode.Active() && m_Mode.Phase() == ERR_Phase.EXTRACTION && ticket.Aircraft == m_Mode.ExtractionVehicle() && ERR_Actors.Alive(ticket.Aircraft) && ERR_Actors.Alive(ticket.Casualty) && SameLife(ticket.Caller, ticket.CallerAccount, ticket.CallerConnection) && SameLife(ticket.Casualty, ticket.CasualtyAccount, ticket.CasualtyConnection) && ERR_CasualtyLoadingRules.TransportBench(ticket.Seat, ticket.Aircraft);
return m_Mode && m_Mode.Active() && m_Mode.Phase() == ERR_Phase.EXTRACTION && m_Mode.CasualtyLoadingTransport(ticket.Aircraft) && ERR_Actors.Alive(ticket.Aircraft) && ERR_Actors.Alive(ticket.Casualty) && SameLife(ticket.Caller, ticket.CallerAccount, ticket.CallerConnection) && SameLife(ticket.Casualty, ticket.CasualtyAccount, ticket.CasualtyConnection) && ERR_CasualtyLoadingRules.TransportBench(ticket.Seat, ticket.Aircraft);
}
protected void NotifyResolution(ERR_CasualtyLoadTicket ticket, bool success)
{
@@ -353,7 +364,7 @@ class ERR_CasualtyLoadingService
// leaves room for replication. Timeouts do not issue compensating teleports.
if (!ERR_CasualtyLoadingRules.Within(ticket.CommittedAt, now, 8000)) Resolve(ticket, false);
}
else if (!ERR_CasualtyLoadingRules.Within(ticket.Started, now, 5000) || !ERR_CasualtyLoadingRules.Nearby(m_Mode, ticket.Caller, ticket.Casualty)) Resolve(ticket, false);
else if (!ERR_CasualtyLoadingRules.Within(ticket.Started, now, 5000) || !ERR_CasualtyLoadingRules.NearbyAircraft(m_Mode, ticket.Caller, ticket.Casualty, ticket.Aircraft)) Resolve(ticket, false);
}
}
void RosterChanged(int connection) { if (GetGame()) Tick(GetGame().GetWorld().GetWorldTime()); }
@@ -49,6 +49,7 @@ class ERR_ChatClient
if (m_Command) m_Command.Remove(OnCommand);
m_Command = null;
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu();
if (menu) menu.Close();
// A retained former controller must not close the current owner's panel.
if (menu && GetGame() && m_Controller == GetGame().GetPlayerController()) menu.Close();
}
}
@@ -23,6 +23,8 @@ modded class ERR_AdminMenu
SCR_ButtonTextComponent enable = SCR_ButtonTextComponent.GetButtonText("Enable", row);
SCR_ButtonTextComponent disable = SCR_ButtonTextComponent.GetButtonText("Disable", row);
if (!enable || !disable) { Close(); return; }
ERR_SelectionStyle.Action(enable, true); ERR_SelectionStyle.Action(disable, false);
ERR_SelectionStyle.Status(row, "SelectionState", -1);
enable.m_OnClicked.Insert(OnModButton); disable.m_OnClicked.Insert(OnModButton);
}
UpdateActions();
@@ -49,7 +51,11 @@ modded class ERR_AdminMenu
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);
int selected = -1; if (m_ModSaved.Contains(pack)) selected = m_ModSaved.Get(pack);
ERR_SelectionStyle.Pair(row, "Enable", "Disable", selected);
TextWidget state = TextWidget.Cast(row.FindAnyWidget("PackState"));
int badge = -1; if (ready) badge = m_ModActive.Get(pack);
ERR_SelectionStyle.Status(row, "SelectionState", badge);
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));
@@ -103,6 +109,24 @@ modded class PauseMenuUI
m_ERRModsButton = SCR_ButtonTextComponent.Cast(widget.FindHandler(SCR_ButtonTextComponent));
if (m_ERRModsButton) m_ERRModsButton.m_OnClicked.Insert(ERR_OpenMods);
}
// Vanilla disables this button while limited, but does not enable it again
// when an admin mode arrives asynchronously. Refresh from native permissions.
protected void ERR_RefreshGameMasterButton()
{
if (!ERR_GameMode.Get() || !m_EditorUnlimitedOpenButton) return;
SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance();
m_EditorUnlimitedOpenButton.SetEnabled(editor && !editor.IsLimited() && editor.CanOpen());
}
protected override void OnEditorModeChanged(SCR_EditorModeEntity modeEntity)
{
super.OnEditorModeChanged(modeEntity);
ERR_RefreshGameMasterButton();
}
override void OnMenuUpdate(float tDelta)
{
super.OnMenuUpdate(tDelta);
ERR_RefreshGameMasterButton();
}
protected void ERR_OpenMods()
{
// Unpause local simulation before requesting an authenticated server snapshot.
@@ -1,4 +1,4 @@
// Separate, config-driven preferences. Missing newly installed options use their
// Content preferences in the shared profile. Newly installed options use their
// authored defaults. Failed writes preserve both previous state and backup.
class ERR_ContentSettings
{
@@ -20,7 +20,7 @@ class ERR_ContentSettings
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"))
else if (ERR_ProfileConfig.Exists(m_Path) || ERR_ProfileConfig.Exists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Co10 Escape Reintroduced: invalid content preferences; authored defaults active, existing files preserved", LogLevel.ERROR);
@@ -30,7 +30,10 @@ class ERR_ContentSettings
protected bool Read(string path, out map<string, int> values)
{
JsonLoadContext json = new JsonLoadContext();
if (!json.LoadFromFile(path)) return false;
return ERR_ProfileConfig.Load(path, json) && Parse(json, values);
}
static bool Parse(JsonLoadContext json, out map<string, int> values)
{
values = new map<string, int>();
foreach (ERR_ContentOption option : ERR_ContentRegistry.Definition().Options)
{
@@ -65,6 +68,11 @@ class ERR_ContentSettings
if (option == id) saved = value;
json.WriteValue(option, saved);
}
if (m_Path == PATH)
{
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
m_Values.Set(id, value); return true;
}
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;
@@ -72,7 +80,7 @@ class ERR_ContentSettings
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);
if (ERR_ProfileConfig.Exists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
else if (!hadPrevious) FileIO.DeleteFile(m_Path);
return false;
}
@@ -0,0 +1,15 @@
// Owned static aftermath; no aircraft, crew, flight or inventory systems.
class ERR_CrashSiteResources
{
static ResourceName SitePrefab() { return "{174211DDE2F6422B}Prefabs/ERR/CrashSite.et"; }
static ResourceName WreckPrefab() { return "{486DDD0B811047EE}Prefabs/ERR/CrashWreck.et"; }
static bool Bounds(out vector minimum, out vector maximum)
{
// Exact owned static wrapper measured in WORKBENCH on 2026-10-03.
// Native bounds: (-1.43719, 0.512451, -8.79651) to (1.4325, 3.98193, 3.94385).
// Expand by 1mm then round outward to millimetres; terrain survey adds its own clearance.
minimum = "-1.439 0.511 -8.798";
maximum = "1.434 3.983 3.945";
return true;
}
}
@@ -15,6 +15,7 @@ class ERR_DiscoveryMapRules
class ERR_DiscoveryMarker : SCR_MapMarkerBase
{
protected string m_Icon = "supplies";
protected ref ERR_ObjectiveMapPresentation m_ERRPresentation;
static string Icon(ERR_LocationKind kind, string id)
{
if (kind == ERR_LocationKind.EXTRACTION) return "helicopters";
@@ -22,6 +23,7 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
if (kind == ERR_LocationKind.MORTAR) return "artilleryFireMission";
if (kind == ERR_LocationKind.OUTPOST) return "building";
if (kind == ERR_LocationKind.HOSPITAL || id.Contains("medical")) return "medical";
if (kind == ERR_LocationKind.PRISON) return "prison";
return "supplies";
}
void SetLocationIcon(ERR_LocationKind kind, string id, int knowledge = 2)
@@ -29,27 +31,37 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
m_Icon = "mark-question";
if (knowledge >= 2) m_Icon = Icon(kind, id);
ApplyIcon();
RefreshPresentation();
}
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));
widget.SetImage("{E0248EBF90751BA5}UI/Textures/ERR/SiteMarkers.imageset", m_Icon);
widget.SetGlowImage("{E0248EBF90751BA5}UI/Textures/ERR/SiteMarkers.imageset", m_Icon);
// Preserve the atlas colours instead of tinting the violet disc dark grey.
widget.SetColor(Color.FromSRGBA(255, 255, 255, 255));
}
// Production authored objectives use a separate map UI element. They never
// enter the player-annotation registry or change its content restrictions.
void StartPresentation()
{
if (!m_ERRPresentation) m_ERRPresentation = new ERR_ObjectiveMapPresentation();
RefreshPresentation();
m_ERRPresentation.Start();
}
void RefreshPresentation()
{
if (!m_ERRPresentation) return;
int point[2];
GetWorldPos(point);
m_ERRPresentation.SetData(Vector(point[0], 0, point[1]), GetCustomText(), m_Icon);
}
void ERR_DiscoveryMarker() { SetCanBeRemovedByOwner(false); }
void SetDiscoveryPosition(vector position, int knowledge)
{
SetWorldPos(ERR_DiscoveryMapRules.Coordinate(position[0], knowledge), ERR_DiscoveryMapRules.Coordinate(position[2], knowledge));
RefreshPresentation();
}
override void OnCreateMarker(bool skipProfanityFilter = false)
{
@@ -61,6 +73,8 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
}
override void OnDelete()
{
if (m_ERRPresentation) m_ERRPresentation.Stop();
m_ERRPresentation = null;
SCR_MapEntity.GetOnMapClose().Remove(OnMapClosed);
SCR_MapEntity.GetOnLayerChanged().Remove(OnMapLayerChanged);
super.OnDelete();
@@ -70,6 +84,7 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
override void SetCustomText(string text)
{
super.SetCustomText(text);
RefreshPresentation();
// Native SetCustomText updates data only; an already-open map needs its
// existing widget updated too. Closed maps use the saved text on creation.
SCR_MapMarkerWidgetComponent widget = GetMarkerComponent();
@@ -81,6 +96,10 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
// must also leave the disabled list or map movement can resurrect it later.
modded class SCR_MapMarkerManagerComponent
{
void ERR_InsertDiscoveryMarker(ERR_DiscoveryMarker marker)
{
if (marker) marker.StartPresentation();
}
void ERR_RemoveDiscoveryMarker(ERR_DiscoveryMarker marker)
{
if (!marker || marker.GetMarkerID() != -1) return;
@@ -115,6 +134,7 @@ modded class ERR_LocationComponent
m_ERRMarker = null; m_ERRShown = 0; m_ERRShownLabel = "";
return;
}
if (m_ERRMarker) m_ERRMarker.RefreshPresentation();
if (Knowledge() == m_ERRShown && Label() == m_ERRShownLabel) return;
SCR_MapMarkerManagerComponent manager = SCR_MapMarkerManagerComponent.GetInstance();
if (!manager || !manager.GetMarkerConfig()) return;
@@ -123,7 +143,7 @@ modded class ERR_LocationComponent
{
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.EXTRACTION) label = "Extraction pickup - throw smoke here";
else if (Kind() == ERR_LocationKind.DEPOT) label = "Supply cache";
else if (Kind() == ERR_LocationKind.MORTAR) label = "Mortar site";
else label = Id();
@@ -136,15 +156,13 @@ modded class ERR_LocationComponent
m_ERRMarker.SetMarkerConfigID(0);
m_ERRMarker.SetLocationIcon(Kind(), Id(), Knowledge());
m_ERRMarker.SetCustomText(label);
manager.InsertStaticMarker(m_ERRMarker, true);
manager.ERR_InsertDiscoveryMarker(m_ERRMarker);
}
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.
SCR_MapEntity mapEntity = SCR_MapEntity.GetMapInstance();
if (mapEntity && mapEntity.IsOpen()) manager.SetStaticMarkerDisabled(m_ERRMarker, false);
// The objective presentation performs its own normal viewport culling.
m_ERRMarker.RefreshPresentation();
m_ERRShown = Knowledge();
m_ERRShownLabel = Label();
}
@@ -53,6 +53,8 @@ class ERR_DiscoveryService
foreach (IEntity folder : folders)
{
if (!folder || m_Spent.Contains(folder) || m_Spent.Count() >= 4096) continue;
bool mineRecovered = false;
if (m_Mode.MineIntel()) mineRecovered = m_Mode.MineIntel().Recover(player, folder);
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
array<ERR_LocationComponent> eligible = {};
@@ -70,7 +72,7 @@ class ERR_DiscoveryService
}
if (eligible.IsEmpty())
{
if (!document && MarkSpent(folder)) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty());
if (!document && !m_Mode.WorldPreparing() && MarkSpent(folder) && !mineRecovered) m_Mode.ERR_IntelExhausted(!sealedTarget.IsEmpty());
continue;
}
ERR_LocationComponent chosen = eligible.GetRandomElement();
@@ -24,25 +24,82 @@ class ERR_EpinephrineEffect : SCR_DotDamageEffect
}
override EDamageType GetDefaultDamageType() { return EDamageType.HEALING; }
}
// Separate stronger field injector. Standard epinephrine above keeps its
// resilience-over-time behavior; neither item restores a dead character.
class ERR_CombatEpinephrineRules
{
static float Heal(float current, bool leg)
{
float amount = 0.15;
if (leg) amount = 0.5;
return Math.Min(current + amount, 1.0);
}
static float Blood(float current, float threshold) { return Math.Min(1, current + threshold + 0.01); }
}
[BaseContainerProps()]
class ERR_ConsumableCombatEpinephrine : SCR_ConsumableMorphine
{
override bool CanApplyEffect(notnull IEntity target, notnull IEntity user, out SCR_EConsumableFailReason failReason)
{
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(target.FindComponent(SCR_CharacterDamageManagerComponent));
if (!damage || damage.GetState() == EDamageState.DESTROYED) { failReason = SCR_EConsumableFailReason.UNDAMAGED; return false; }
if (damage.IsBleeding()) return true;
HitZone blood = damage.GetBloodHitZone(), resilience = damage.GetResilienceHitZone();
if ((blood && blood.GetHealthScaled() < 1) || (resilience && resilience.GetHealthScaled() < 1)) return true;
array<HitZone> zones = {}; damage.GetPhysicalHitZones(zones);
foreach (HitZone zone : zones) if (zone && zone.GetHealthScaled() < 1) return true;
failReason = SCR_EConsumableFailReason.UNDAMAGED; return false;
}
}
class ERR_CombatEpinephrineEffect : SCR_InstantDamageEffect
{
override EDamageType GetDefaultDamageType() { return EDamageType.HEALING; }
override void OnEffectAdded(SCR_ExtendedDamageManagerComponent dmgManager)
{
super.OnEffectAdded(dmgManager);
if (!Replication.IsServer()) return;
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(dmgManager);
if (!damage || damage.GetState() == EDamageState.DESTROYED) return;
foreach (ECharacterHitZoneGroup group : {ECharacterHitZoneGroup.HEAD, ECharacterHitZoneGroup.UPPERTORSO, ECharacterHitZoneGroup.LOWERTORSO, ECharacterHitZoneGroup.LEFTARM, ECharacterHitZoneGroup.RIGHTARM, ECharacterHitZoneGroup.LEFTLEG, ECharacterHitZoneGroup.RIGHTLEG}) damage.RemoveGroupBleeding(group);
SCR_CharacterBloodHitZone blood = damage.GetBloodHitZone();
if (blood && blood.GetHealthScaled() <= blood.GetDamageStateThreshold(ECharacterBloodState.FAINTING))
blood.SetHealthScaled(ERR_CombatEpinephrineRules.Blood(blood.GetHealthScaled(), blood.GetDamageStateThreshold(ECharacterBloodState.UNCONSCIOUS)));
SCR_CharacterResilienceHitZone resilience = damage.GetResilienceHitZone();
if (resilience) resilience.SetHealthScaled(1);
array<HitZone> zones = {}; damage.GetPhysicalHitZones(zones);
foreach (HitZone zone : zones)
{
SCR_CharacterHitZone physical = SCR_CharacterHitZone.Cast(zone);
if (!physical || zone.GetHealthScaled() >= 1) continue;
int group = physical.GetHitZoneGroup();
zone.SetHealthScaled(ERR_CombatEpinephrineRules.Heal(zone.GetHealthScaled(), group == ECharacterHitZoneGroup.LEFTLEG || group == ECharacterHitZoneGroup.RIGHTLEG));
}
CharacterControllerComponent controller = CharacterControllerComponent.Cast(damage.GetOwner().FindComponent(CharacterControllerComponent));
if (controller && controller.IsUnconscious()) controller.SetUnconscious(false);
}
}
// The held injector uses the existing native injection interaction and animation.
// Only its displayed name changes; ordinary morphine keeps its native behavior.
modded class SCR_MorphineUserAction
{
protected bool m_ERRHoldingEpinephrine;
protected bool m_ERRHoldingCombatEpinephrine;
override bool CanBeShownScript(IEntity user)
{
m_ERRHoldingEpinephrine = false;
m_ERRHoldingEpinephrine = false; m_ERRHoldingCombatEpinephrine = false;
bool shown = super.CanBeShownScript(user);
ChimeraCharacter character = ChimeraCharacter.Cast(user);
if (shown && character)
{
SCR_ConsumableItemComponent item = GetConsumableComponent(character);
m_ERRHoldingEpinephrine = item && ERR_ConsumableEpinephrine.Cast(item.GetConsumableEffect());
m_ERRHoldingCombatEpinephrine = item && ERR_ConsumableCombatEpinephrine.Cast(item.GetConsumableEffect());
}
return shown;
}
override bool GetActionNameScript(out string outName)
{
if (m_ERRHoldingCombatEpinephrine) { outName = "Administer combat epinephrine"; return true; }
if (m_ERRHoldingEpinephrine) { outName = "Administer epinephrine"; return true; }
return super.GetActionNameScript(outName);
}
@@ -11,7 +11,7 @@ class ERR_EscapeGameOverScreenInfo : SCR_BaseGameOverScreenInfo
{
string title = "Round ended";
ERR_PersonalEscapeResult result = PersonalResult(playerId);
if (result == ERR_PersonalEscapeResult.ESCAPED) title = "You escaped Everon";
if (result == ERR_PersonalEscapeResult.ESCAPED) title = "You escaped";
else if (result == ERR_PersonalEscapeResult.MISSED) title = "The helicopter escaped";
#ifdef WORKBENCH
PrintFormat("ERR RESULT UI: player=%1 title=%2", playerId, title);
@@ -0,0 +1,49 @@
// Finite objective announcements. Text always arrives; optional TTS shares the
// configured speech service. No provider key is replicated to players.
class ERR_ExtractionSpeechReceiver : ERR_SpeechReceiver
{
protected ERR_GameMode m_Mode;
protected int m_Stage;
void ERR_ExtractionSpeechReceiver(ERR_GameMode mode, int stage) { m_Mode = mode; m_Stage = stage; }
override void Complete(string url, string text)
{
if (GetGame() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active() && m_Mode.PickupStage() == m_Stage)
m_Mode.DeliverExtractionRadio(text, url);
}
}
modded class ERR_GameMode
{
[RplProp()] protected int m_PickupStage;
int PickupStage() { return m_PickupStage; }
void PublishPickupStage(int stage)
{
if (!Replication.IsServer() || stage == m_PickupStage) return;
m_PickupStage = stage; Replication.BumpMe();
}
void DeliverExtractionRadio(string text, string url = "")
{
if (!Replication.IsServer() || !Active()) return;
array<int> players = {}; GetGame().GetPlayerManager().GetPlayers(players);
foreach (int id : players)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(id));
if (controller) controller.ERR_ExtractionRadio(text, url);
}
}
}
modded class SCR_PlayerController
{
void ERR_ExtractionRadio(string text, string url)
{
if (Replication.IsServer()) Rpc(ERR_ReceiveExtractionRadio, text, url);
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_ReceiveExtractionRadio(string text, string url)
{
if (this != GetGame().GetPlayerController() || !ERR_GameMode.Get()) return;
if (!url.IsEmpty()) { GetGame().ERR_PlaySpeechPreview(url, text, "Extraction radio"); return; }
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (hints) hints.ShowCustom(text, "Extraction radio", 20);
SCR_ChatComponent.RadioProtocolMessage(text);
}
}
@@ -1,10 +1,229 @@
enum ERR_ExtractionState { WAITING, ESCAPED, LOST }
// Mission progression consumes the result; this service never spawns a vehicle.
class ERR_RescueRules
{
static float BoardingSince(int alive, int aboard, float since, float now)
{
if (alive <= 0 || aboard != alive || now < 0) return -1;
if (since < 0 || now < since) return now;
return since;
}
static bool CanDepart(float since, float now)
{
return since >= 0 && now >= since && now - since >= 8000;
}
}
// Round-owned smoke signal, friendly rescue dispatch, boarding and escape result.
class ERR_ExtractionService
{
static const int REQUIRED_SEATS = 12;
protected ref ERR_PlayerRoster m_Roster;
protected ERR_GameMode m_Mode;
protected vector m_Pickup;
protected bool m_PickupConfigured, m_Signal;
protected ref array<IEntity> m_PreviousSignals = {};
protected int m_Stage, m_DispatchAttempts;
protected float m_NextDispatch, m_AllAboardSince = -1;
protected ref array<ERR_RescueFlightComponent> m_Escorts = {};
protected ERR_RescueFlightComponent m_Transport;
protected ref array<ERR_RescueFlightComponent> m_Transports = {};
protected ref array<vector> m_TransportPickups = {};
protected string m_RescueFaction;
protected string m_RadioPending;
protected float m_RadioUntil;
bool CarryingSurvivorsToSafety()
{
if (m_Stopped || m_Stage != 5 || !FleetAlive()) return false;
array<ref ERR_PlayerLife> lives = {}; m_Roster.Participants(lives);
int aboard;
foreach (ERR_PlayerLife life : lives)
{
if (!ERR_Actors.Alive(life.Actor)) continue;
if (!IsTransport(Vehicle.Cast(CompartmentAccessComponent.GetVehicleIn(life.Actor)))) return false;
aboard++;
}
return aboard > 0;
}
bool PickupConfigured() { return m_PickupConfigured; }
vector PickupPosition() { return m_Pickup; }
bool ConfigurePickup(vector position)
{
if (!Replication.IsServer() || m_PickupConfigured || m_Begun || m_Stopped) return false;
m_Mode = ERR_GameMode.Get(); if (!m_Mode) return false;
if (!m_Mode.PlayerFaction()) return false;
m_RescueFaction = m_Mode.PlayerFaction().GetFactionKey();
if (!ERR_RescueLanding.Survey(position, ERR_RescueAssets.TransportCount(m_RescueFaction), m_TransportPickups))
{
PrintFormat("Reintroduced [extraction]: pickup rejected before preparation; faction=%1 required transports=%2 primary=%3; no safe secondary landing footprint", m_RescueFaction, ERR_RescueAssets.TransportCount(m_RescueFaction), position);
return false;
}
m_Pickup = position; m_PickupConfigured = true; return true;
}
protected void Stage(int value, string message)
{
m_Stage = value; m_Mode.PublishPickupStage(value);
PrintFormat("Reintroduced [extraction]: stage=%1 %2", value, message);
m_Mode.DeliverExtractionRadio(message);
m_RadioPending = message; m_RadioUntil = GetGame().GetWorld().GetWorldTime() + 30000;
}
void PreparingRoute()
{
if (!Replication.IsServer() || !m_Mode || m_Stopped) return;
Stage(0, "Escapists, this is rescue command. We are preparing your escape route and assembling transports with armed escort helicopters. Hold the radio position until preparations are complete. Keep your smoke grenades ready and stand by for pickup coordinates.");
}
void OpenPickup()
{
if (!Replication.IsServer() || !m_Mode || m_Stage != 0 || m_Stopped) return;
GetGame().GetWorld().QueryEntitiesBySphere(m_Pickup, 25, PreviousSmoke, null, EQueryEntitiesFlags.ALL);
Stage(1, "Your escape route is ready. The exact pickup point is marked on your map. Gather your team there and throw a smoke grenade within 25 metres of the mark. We will send the transport and armed escorts when we see your smoke.");
}
protected void Radio(float now)
{
if (m_RadioPending.IsEmpty()) return;
if (now > m_RadioUntil) { m_RadioPending = ""; return; }
if (m_Mode.ERR_Speech() && m_Mode.ERR_Speech().Request(m_RadioPending, new ERR_ExtractionSpeechReceiver(m_Mode, m_Stage))) m_RadioPending = "";
}
protected bool PreviousSmoke(IEntity entity)
{
if (!entity || entity.GetParent() || m_PreviousSignals.Count() >= 32) return true;
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(entity.FindComponent(BaseWeaponComponent));
BaseTriggerComponent trigger = BaseTriggerComponent.Cast(entity.FindComponent(BaseTriggerComponent));
if (weapon && weapon.GetWeaponType() == EWeaponType.WT_SMOKEGRENADE && trigger && trigger.WasTriggered()) m_PreviousSignals.Insert(entity);
return true;
}
protected bool Smoke(IEntity entity)
{
if (!entity || entity.GetParent() || m_PreviousSignals.Contains(entity)) return true;
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(entity.FindComponent(BaseWeaponComponent));
BaseTriggerComponent trigger = BaseTriggerComponent.Cast(entity.FindComponent(BaseTriggerComponent));
if (!weapon || weapon.GetWeaponType() != EWeaponType.WT_SMOKEGRENADE || !trigger || !trigger.WasTriggered()) return true;
Instigator instigator = trigger.GetInstigator();
if (!instigator || !m_Roster.CurrentActor(instigator.GetInstigatorEntity())) return true;
// Authority-side grenade state, independent of a dedicated server's renderer.
// Canisters already triggered at this point before the reveal are ignored.
if (Math.AbsFloat(entity.GetOrigin()[1] - m_Pickup[1]) > 8) return true;
m_Signal = true; return false;
}
protected bool Approach(out vector position)
{
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
array<IEntity> players = {}; ERR_Actors.Players(players, true);
// Smaller terrains can use a shorter approach; reserve room for both escorts.
for (int band = 0; band < 3; band++)
for (int attempt = 0; attempt < 12; attempt++)
{
float distance = 1600 - band * 400;
float angle = attempt * Math.PI2 / 12;
position = m_Pickup + Vector(Math.Cos(angle) * distance, 0, Math.Sin(angle) * distance);
if (position[0] < low[0] + 400 || position[0] > high[0] - 400 || position[2] < low[2] + 400 || position[2] > high[2] - 400) continue;
bool near = false;
foreach (IEntity player : players) if (vector.DistanceXZ(player.GetOrigin(), position) < Math.Min(1000, distance - 300)) near = true;
if (near) continue;
position[1] = SCR_TerrainHelper.GetTerrainY(position, GetGame().GetWorld(), noUnderwater: true) + 200;
return true;
}
return false;
}
protected bool LandingPads()
{
int count = ERR_RescueAssets.TransportCount(m_RescueFaction);
// A vehicle or other obstruction may occupy the reserved extra footprint
// during the round. Reuse it only while clear, otherwise survey once again
// before allocating any aircraft. The smoke marker is not rechecked against
// the signalling players gathered there; native landing safety remains active.
if (m_TransportPickups.Count() == count)
if (count == 1 || ERR_RescueLanding.SecondaryClear(m_TransportPickups[1])) return true;
return ERR_RescueLanding.Survey(m_Pickup, count, m_TransportPickups);
}
protected ERR_RescueFlightComponent SpawnFlight(vector origin, int serial, bool escort)
{
ResourceName prefab = ERR_RescueAssets.Aircraft(m_RescueFaction, escort);
Vehicle aircraft = Vehicle.Cast(ERR_Actors.Spawn(prefab, origin));
if (!aircraft) return null;
m_Mode.Own(aircraft);
ERR_RescueFlightComponent flight = ERR_RescueFlightComponent.Cast(aircraft.FindComponent(ERR_RescueFlightComponent));
if (!flight) { ERR_Actors.DeleteSpawned(aircraft); return null; }
vector pickup = m_Pickup;
if (!escort) pickup = m_TransportPickups[serial];
flight.StartRescue(m_Mode, origin, pickup, serial, escort);
PrintFormat("Reintroduced [extraction]: selected faction=%1 escort=%2 serial=%3 prefab=%4 pickup=%5", m_RescueFaction, escort, serial, prefab, pickup);
return flight;
}
protected void Dispatch(float now)
{
if (now < m_NextDispatch) return;
m_NextDispatch = now + 10000;
if (++m_DispatchAttempts > 6) { Stage(6, "Rescue command cannot establish a safe approach. Extraction has failed."); Resolve(ERR_ExtractionState.LOST); return; }
vector origin; if (!LandingPads() || !Approach(origin)) return;
for (int transportIndex = 0; transportIndex < ERR_RescueAssets.TransportCount(m_RescueFaction); transportIndex++)
{
vector transportOrigin = origin + (m_TransportPickups[transportIndex] - m_Pickup) + Vector(0, transportIndex * 40, 0);
ERR_RescueFlightComponent transport = SpawnFlight(transportOrigin, transportIndex, false);
if (!transport)
{
foreach (ERR_RescueFlightComponent partial : m_Transports) if (partial) partial.Release(false);
Stage(6, "Rescue command could not assemble the required transport capacity. Extraction has failed."); Resolve(ERR_ExtractionState.LOST); return;
}
m_Transports.Insert(transport);
}
m_Transport = m_Transports[0];
if (!Assign(m_Transport.Aircraft())) { foreach (ERR_RescueFlightComponent invalid : m_Transports) invalid.Release(false); Resolve(ERR_ExtractionState.LOST); return; }
// Different approach lanes and altitudes keep rotor discs well separated.
for (int index = 0; index < 2; index++)
{
float side = -180; if (index == 1) side = 180;
vector escortOrigin = origin + Vector(side, 60 + index * 40, 180);
ERR_RescueFlightComponent escort = SpawnFlight(escortOrigin, index + m_Transports.Count(), true);
if (escort) m_Escorts.Insert(escort);
}
string inbound = "Smoke confirmed. Rescue transport and armed cover are inbound. Clear the marked landing point. Bring your wounded; we wait for all surviving prisoners to board.";
if (m_Transports.Count() > 1) inbound = "Smoke confirmed. Two rescue transports and armed cover are inbound. The helicopters will land separately near the marker. Use either transport and bring your wounded; both wait until everyone is aboard.";
Stage(3, inbound);
}
protected void RescueTick(float now)
{
Radio(now);
if (m_Stage == 1)
{
GetGame().GetWorld().QueryEntitiesBySphere(m_Pickup, 25, Smoke, null, EQueryEntitiesFlags.ALL);
if (m_Signal) Stage(2, "Signal received. Launching the rescue flight. Keep the landing point clear.");
}
if (m_Stage == 2) Dispatch(now);
if (!m_Transport) return;
foreach (ERR_RescueFlightComponent transport : m_Transports) if (transport) transport.Update(now);
foreach (ERR_RescueFlightComponent escort : m_Escorts) if (escort && !escort.Lost()) escort.Update(now);
if (!FleetAlive()) { Stage(6, "A rescue transport is down or unable to continue. Extraction has failed."); Resolve(ERR_ExtractionState.LOST); return; }
bool landed = FleetLanded();
if (m_Stage == 3 && landed) Stage(4, "Rescue transports are on the ground. Everyone aboard, including your wounded. Use either rescue transport if there are two. Escorts are covering the pickup. We leave when all surviving prisoners are inside.");
if (m_Stage != 4) return;
if (!landed) { m_AllAboardSince = -1; return; }
array<ref ERR_PlayerLife> lives = {}; m_Roster.Participants(lives);
int alive, aboard;
foreach (ERR_PlayerLife life : lives)
{
if (!ERR_Actors.Alive(life.Actor)) continue;
alive++;
if (IsTransport(Vehicle.Cast(CompartmentAccessComponent.GetVehicleIn(life.Actor)))) aboard++;
}
m_AllAboardSince = ERR_RescueRules.BoardingSince(alive, aboard, m_AllAboardSince, now);
if (!ERR_RescueRules.CanDepart(m_AllAboardSince, now)) return;
vector direction = m_Pickup - m_Center; direction[1] = 0;
if (direction.Length() < 1) direction = "1 0 0";
direction.Normalize();
float safeDistance = Math.Max(m_Radius + 400, vector.DistanceXZ(m_Pickup, m_Center) + 2000);
vector safety = m_Center + direction * safeDistance;
safety[1] = SCR_TerrainHelper.GetTerrainY(safety, GetGame().GetWorld(), noUnderwater: true);
vector lateral = Vector(-direction[2], 0, direction[0]);
foreach (int departureIndex, ERR_RescueFlightComponent outbound : m_Transports)
{
vector destination = safety + lateral * (departureIndex * 120);
destination[1] = SCR_TerrainHelper.GetTerrainY(destination, GetGame().GetWorld(), noUnderwater: true);
outbound.Depart(destination);
}
foreach (ERR_RescueFlightComponent cover : m_Escorts) if (cover && !cover.Lost()) cover.Depart(safety + "0 100 200");
Stage(5, "All survivors aboard. Lifting now. Stay inside the transport; escorts are taking you to safety.");
}
protected Vehicle m_Vehicle;
protected bool m_Assigned;
protected bool m_Begun;
@@ -18,6 +237,34 @@ class ERR_ExtractionService
void ERR_ExtractionService(ERR_PlayerRoster roster) { m_Roster = roster; }
Vehicle VehicleEntity() { return m_Vehicle; }
int Vehicles(out array<Vehicle> vehicles)
{
vehicles.Clear();
foreach (ERR_RescueFlightComponent transport : m_Transports)
if (transport && transport.Aircraft() && !vehicles.Contains(transport.Aircraft())) vehicles.Insert(transport.Aircraft());
if (m_Assigned && m_Vehicle && !vehicles.Contains(m_Vehicle)) vehicles.Insert(m_Vehicle);
return vehicles.Count();
}
bool IsTransport(Vehicle aircraft)
{
if (!aircraft || !m_Assigned) return false;
array<Vehicle> vehicles = {}; Vehicles(vehicles);
return vehicles.Contains(aircraft);
}
protected bool FleetAlive()
{
if (!m_Transport || m_Transports.IsEmpty()) return false;
foreach (ERR_RescueFlightComponent transport : m_Transports)
if (!transport || transport.Lost() || !ERR_Actors.Alive(transport.Aircraft())) return false;
return true;
}
protected bool FleetLanded()
{
bool landed = true;
foreach (ERR_RescueFlightComponent transport : m_Transports)
if (!transport || !transport.Landed()) landed = false;
return landed && !m_Transports.IsEmpty();
}
ERR_ExtractionState State() { return m_State; }
ScriptInvoker OnResolved() { return m_OnResolved; }
int EscapedCount() { return m_Escapees.Count(); }
@@ -34,18 +281,23 @@ class ERR_ExtractionService
int Seats(out array<BaseCompartmentSlot> seats)
{
seats.Clear();
if (!m_Vehicle) return 0;
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(m_Vehicle.FindComponent(BaseCompartmentManagerComponent));
if (!manager) return 0;
array<Vehicle> vehicles = {}; Vehicles(vehicles);
foreach (Vehicle aircraft : vehicles) CollectSeats(aircraft, seats);
return seats.Count();
}
protected void CollectSeats(IEntity entity, array<BaseCompartmentSlot> seats)
{
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(entity.FindComponent(BaseCompartmentManagerComponent));
array<BaseCompartmentSlot> all = {};
manager.GetCompartments(all);
if (manager) manager.GetCompartments(all);
foreach (BaseCompartmentSlot slot : all)
{
if (!slot) continue;
ECompartmentType type = SCR_CompartmentAccessComponent.GetCompartmentType(slot);
if (type == ECompartmentType.PILOT || type == ECompartmentType.CARGO) seats.Insert(slot);
if ((type == ECompartmentType.PILOT || type == ECompartmentType.CARGO) && !seats.Contains(slot)) seats.Insert(slot);
}
return seats.Count();
IEntity child = entity.GetChildren();
while (child) { CollectSeats(child, seats); child = child.GetSibling(); }
}
bool Ready()
{
@@ -58,7 +310,7 @@ class ERR_ExtractionService
}
bool Begin(vector center, float radius)
{
if (!Replication.IsServer() || m_Begun || m_Stopped || !m_Roster || radius <= 0 || !Ready()) return false;
if (!Replication.IsServer() || m_Begun || m_Stopped || !m_Roster || radius <= 0 || !m_PickupConfigured) return false;
m_Center = center;
m_Radius = radius;
m_Begun = true;
@@ -68,13 +320,17 @@ class ERR_ExtractionService
{
if (!Replication.IsServer() || !m_Begun || m_Stopped || m_State != ERR_ExtractionState.WAITING) return;
if (phase < ERR_Phase.PRISON || ERR_MissionRules.Terminal(phase)) return;
if (!ERR_Actors.Alive(m_Vehicle)) { Resolve(ERR_ExtractionState.LOST); return; }
if (phase != ERR_Phase.EXTRACTION || vector.DistanceXZ(m_Vehicle.GetOrigin(), m_Center) <= m_Radius) return;
if (m_Mode) RescueTick(GetGame().GetWorld().GetWorldTime());
if (m_State != ERR_ExtractionState.WAITING || !m_Assigned) return;
if (!FleetAlive()) { Resolve(ERR_ExtractionState.LOST); return; }
if (m_Stage != 5 || phase != ERR_Phase.EXTRACTION) return;
foreach (ERR_RescueFlightComponent departing : m_Transports)
if (vector.DistanceXZ(departing.Aircraft().GetOrigin(), m_Pickup) < 1000 || vector.DistanceXZ(departing.Aircraft().GetOrigin(), m_Center) <= m_Radius) return;
array<ref ERR_PlayerLife> lives = {};
m_Roster.Participants(lives);
foreach (ERR_PlayerLife life : lives)
{
if (!ERR_Actors.Alive(life.Actor) || CompartmentAccessComponent.GetVehicleIn(life.Actor) != m_Vehicle) continue;
if (!ERR_Actors.Alive(life.Actor) || !IsTransport(Vehicle.Cast(CompartmentAccessComponent.GetVehicleIn(life.Actor)))) continue;
// Reserved disconnected characters and unconscious passengers remain alive.
// A recycled connection ID is never an identity for the escape snapshot.
m_Escapees.Insert(life.AccountId);
@@ -97,5 +353,11 @@ class ERR_ExtractionService
foreach (ERR_PlayerLife life : lives)
if (life.Connected && m_Roster.Get(life.ConnectionId) == life && m_Escapees.Contains(life.AccountId)) winners.Insert(life.ConnectionId);
}
void Stop() { m_Stopped = true; }
void Stop()
{
m_Stopped = true; m_RadioPending = ""; m_PreviousSignals.Clear();
foreach (ERR_RescueFlightComponent transport : m_Transports) if (transport) transport.Release(false);
foreach (ERR_RescueFlightComponent escort : m_Escorts) if (escort) escort.Release(false);
m_Escorts.Clear(); m_Transports.Clear(); m_Transport = null;
}
}
@@ -30,6 +30,9 @@ modded class ERR_AdminMenu
bool ready = m_Ready && m_FactionSaved.Count() == 3 && m_FactionActive.Count() == 3;
foreach (SCR_ButtonTextComponent button : m_FactionButtons)
if (button) button.SetEnabled(ready);
bool allRandom = ready;
foreach (string savedRole : ERR_FactionChoices.Roles()) if (m_FactionSaved.Get(savedRole) != "random") allRandom = false;
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("FactionsRandomAll", GetRootWidget()), allRandom);
foreach (string role : ERR_FactionChoices.Roles())
{
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget());
@@ -96,13 +96,16 @@ class ERR_FactionSelection
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"))
else if (ERR_ProfileConfig.Exists(m_Path) || ERR_ProfileConfig.Exists(m_Path + ".bak"))
{ m_ReadOnly = true; Print("Co10 Escape Reintroduced: 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;
return ERR_ProfileConfig.Load(path, json) && Parse(json, values);
}
static bool Parse(JsonLoadContext json, out map<string, string> values)
{
values = new map<string, string>();
foreach (string role : ERR_FactionChoices.Roles())
{
@@ -146,6 +149,11 @@ class ERR_FactionSelection
if (!ERR_FactionChoices.Distinct(values)) return false;
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
foreach (string role : roles) json.WriteValue(role, values.Get(role));
if (m_Path == PATH)
{
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
m_Saved = values; return true;
}
map<string, string> checked;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || !Same(values, checked)) return false;
map<string, string> previous;
@@ -26,6 +26,23 @@ class ERR_GameMode : SCR_BaseGameMode
m_SelectedPlayerFaction = player; m_SelectedEnemyFaction = enemy; Replication.BumpMe();
}
protected ref ERR_Settings m_Settings;
[RplProp()] protected int m_StartMode;
protected bool m_StartModeFrozen;
protected bool m_StartPrepared;
int StartMode() { return m_StartMode; }
protected bool FreezeStartMode()
{
if (!Replication.IsServer() || m_Stopped || m_NativeEndHandled || m_Phase != ERR_Phase.PREPARING || !m_Settings) return false;
if (m_StartModeFrozen) return true;
int selected = m_Settings.Get("startMode");
if (!ERR_StartRules.Valid(selected)) return false;
m_StartMode = selected; m_StartModeFrozen = true;
Replication.BumpMe();
return true;
}
[RplProp(onRplName: "ApplyLocalViewDistance")]
protected int m_MissionViewDistance;
protected ref ERR_ViewDistance m_LocalViewDistance = new ERR_ViewDistance();
protected ref ERR_ContentPolicy m_Content;
protected ref ERR_ContentSettings m_ContentSettings;
protected ref ERR_TimeService m_Time = new ERR_TimeService();
@@ -35,6 +52,9 @@ class ERR_GameMode : SCR_BaseGameMode
protected ref ERR_PlayerRoster m_Roster = new ERR_PlayerRoster();
protected ref ERR_AdminService m_Admin = new ERR_AdminService();
protected ref ERR_CommandService m_Command;
protected ref ERR_Performance m_Performance;
ERR_Performance Performance() { return m_Performance; }
protected ref ERR_CivilianTraffic m_CivilianTraffic;
protected ref ERR_ForceLedger m_Forces = new ERR_ForceLedger();
protected ref ERR_ForceSimulation m_ForceSimulation;
protected ref array<AIAgent> m_ForceAgents = {};
@@ -73,6 +93,27 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_PlayerRoster Players() { return m_Roster; }
ERR_AdminService Admin() { return m_Admin; }
ERR_Settings Settings() { return m_Settings; }
// Publish the server-bounded value; replicated state also reaches late joiners.
void PublishViewDistance()
{
if (!Replication.IsServer() || !m_Settings || m_Stopped || m_NativeEndHandled) return;
m_MissionViewDistance = ERR_ViewDistance.ServerDistance(m_Settings.Get("viewDistanceMetres"), GetGame().GetViewDistanceServerLimit());
Replication.BumpMe();
ApplyLocalViewDistance();
}
protected void ApplyLocalViewDistance()
{
if (!GetGame() || System.IsConsoleApp() || m_Stopped || m_NativeEndHandled) return;
// Inventory and video menus temporarily own the native camera distance.
if (GetGame().GetMenuManager() && GetGame().GetMenuManager().GetTopMenu()) return;
m_LocalViewDistance.Apply(m_MissionViewDistance);
}
protected void StopLocalViewDistance()
{
if (!GetGame()) return;
GetGame().GetCallqueue().Remove(ApplyLocalViewDistance);
m_LocalViewDistance.Restore();
}
ERR_ContentPolicy Content() { return m_Content; }
ERR_ContentSettings ContentSettings() { return m_ContentSettings; }
bool IsMissionEnemy(Faction faction)
@@ -88,6 +129,7 @@ class ERR_GameMode : SCR_BaseGameMode
string RecoveryStatus() { return m_RecoveryStatus; }
ERR_ForceLedger Forces() { return m_Forces; }
ERR_CommandService Command() { return m_Command; }
ERR_CivilianTraffic CivilianTraffic() { return m_CivilianTraffic; }
ERR_Objectives Objectives() { return m_Objectives; }
ERR_ExtractionService Extraction() { return m_Extraction; }
string EndReason() { return m_EndReason; }
@@ -130,14 +172,18 @@ class ERR_GameMode : SCR_BaseGameMode
ERR_FactionCatalog Factions() { return m_Factions; }
Faction PlayerFaction()
{
if (!m_Factions) return null;
if (!m_SelectedPlayerFaction.IsEmpty()) return GetGame().GetFactionManager().GetFactionByKey(m_SelectedPlayerFaction);
if (!m_Factions || !GetGame()) return null;
FactionManager manager = GetGame().GetFactionManager();
if (!manager) return null;
if (!m_SelectedPlayerFaction.IsEmpty()) return manager.GetFactionByKey(m_SelectedPlayerFaction);
return m_Factions.PlayerFaction();
}
Faction EnemyFaction()
{
if (!m_Factions) return null;
if (!m_SelectedEnemyFaction.IsEmpty()) return GetGame().GetFactionManager().GetFactionByKey(m_SelectedEnemyFaction);
if (!m_Factions || !GetGame()) return null;
FactionManager manager = GetGame().GetFactionManager();
if (!manager) return null;
if (!m_SelectedEnemyFaction.IsEmpty()) return manager.GetFactionByKey(m_SelectedEnemyFaction);
return m_Factions.EnemyFaction();
}
string PreparationError() { return m_PreparationError; }
@@ -201,7 +247,7 @@ class ERR_GameMode : SCR_BaseGameMode
}
void RegisterLocation(ERR_LocationComponent location)
{
if (!location || location.Id().IsEmpty() || m_Locations.Contains(location)) return;
if (!location || !location.Prepared() || location.Id().IsEmpty() || m_Locations.Contains(location)) return;
foreach (ERR_LocationComponent existing : m_Locations)
{
if (existing.Id() == location.Id())
@@ -212,9 +258,29 @@ class ERR_GameMode : SCR_BaseGameMode
}
m_Locations.Insert(location);
}
bool WorldPreparing()
{
ERR_ScenarioComponent scenario = ERR_ScenarioComponent.Cast(FindComponent(ERR_ScenarioComponent));
return scenario && scenario.BackgroundPending();
}
void WorldPreparationFailed(string reason)
{
if (!Replication.IsServer() || !Active()) return;
m_EndReason = "World generation failed: " + reason;
Print(m_EndReason, LogLevel.ERROR);
Transition(ERR_Phase.DEFEAT);
}
bool HasLocation(ERR_LocationComponent location) { return location && m_Locations.Contains(location); }
void UnregisterLocation(ERR_LocationComponent location) { m_Locations.RemoveItem(location); }
void Locations(out array<ERR_LocationComponent> locations) { locations.Copy(m_Locations); }
// Read only, once per telemetry window. Does not query/stream the terrain.
int OwnedRootCount()
{
int count;
foreach (IEntity entity : m_OwnedEntities) if (entity) count++;
return count;
}
void Own(IEntity entity) { if (entity && !m_OwnedEntities.Contains(entity)) m_OwnedEntities.Insert(entity); }
bool SetExtractionVehicle(Vehicle vehicle)
{
@@ -285,10 +351,11 @@ class ERR_GameMode : SCR_BaseGameMode
}
bool Transition(ERR_Phase phase)
{
if (!Replication.IsServer() || !m_PreparationError.IsEmpty() || !ERR_MissionRules.CanTransition(m_Phase, phase)) return false;
if (!Replication.IsServer() || !m_PreparationError.IsEmpty() || !ERR_MissionRules.CanTransition(m_Phase, phase, m_StartModeFrozen && m_StartPrepared && m_StartMode == 1)) return false;
if (m_Phase == ERR_Phase.PREPARING && !m_StartPrepared) return false;
ERR_Phase previous = m_Phase;
m_Phase = phase;
if (previous == ERR_Phase.PRISON && phase == ERR_Phase.RADIO_SEARCH)
if ((previous == ERR_Phase.PRISON || previous == ERR_Phase.PREPARING) && phase == ERR_Phase.RADIO_SEARCH)
{
// Dormant patrols must not catch up the time spent waiting in prison.
for (int i = 0; i < m_Forces.Count(); i++)
@@ -330,13 +397,14 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (!Replication.IsServer() || !m_RadioDefence || location != m_RadioDefence.Location()) return;
if (!Transition(ERR_Phase.RADIO_DEFENCE)) m_RadioDefence.Stop();
else m_Extraction.PreparingRoute();
}
protected void RadioCompleted(ERR_LocationComponent location)
{
if (!Replication.IsServer() || m_Phase != ERR_Phase.RADIO_DEFENCE || !m_RadioDefence || m_RadioDefence.State() != ERR_RadioDefenceState.COMPLETED || location != m_RadioDefence.Location()) return;
foreach (ERR_LocationComponent candidate : m_Locations)
if (candidate.Kind() == ERR_LocationKind.EXTRACTION) candidate.Discover(0, 100);
Transition(ERR_Phase.EXTRACTION);
if (Transition(ERR_Phase.EXTRACTION)) m_Extraction.OpenPickup();
}
void PlayerSpawned(int id, IEntity actor)
@@ -387,13 +455,17 @@ class ERR_GameMode : SCR_BaseGameMode
if (SCR_Global.IsEditMode()) return;
m_Factions = ERR_FactionCatalog.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(m_FactionDefinition));
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(BindLocalObserver, 250, true);
if (!System.IsConsoleApp()) GetGame().GetCallqueue().CallLater(ApplyLocalViewDistance, 1000, true);
if (!Replication.IsServer()) return;
m_Settings = new ERR_Settings(SettingsPath());
if (!System.IsCLIParam("errNoPerf")) m_Performance = new ERR_Performance();
PublishViewDistance();
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());
m_Admin.Register(new ERR_TerrainAdminProvider());
OnStop().Insert(m_Environment.Stop);
m_Recovery = new ERR_RecoveryService(this);
m_Recovery.OnChanged().Insert(RecoveryChanged);
@@ -421,6 +493,8 @@ class ERR_GameMode : SCR_BaseGameMode
OnStop().Insert(m_Objectives.Stop);
m_ForceSimulation = new ERR_ForceSimulation(m_Forces);
m_Command = CreateCommandService();
m_CivilianTraffic = new ERR_CivilianTraffic(this);
OnStop().Insert(m_CivilianTraffic.Stop);
GetGame().GetCallqueue().CallLater(ServerTick, 1000, true);
GetGame().GetCallqueue().CallLater(StartScenario, 1500, false);
}
@@ -435,7 +509,7 @@ class ERR_GameMode : SCR_BaseGameMode
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller) return;
controller.ERR_StartObserverMonitor();
GetGame().GetCallqueue().Remove(BindLocalObserver);
// Keep watching for a replacement local controller; Start is idempotent.
}
protected void StartScenario()
@@ -444,6 +518,7 @@ class ERR_GameMode : SCR_BaseGameMode
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; }
if (!FreezeStartMode()) { FailPreparation("The selected mission start is invalid."); 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;
@@ -462,12 +537,14 @@ class ERR_GameMode : SCR_BaseGameMode
}
if (!m_Extraction || !m_Extraction.Begin(m_EscapeCenter, m_EscapeRadius))
{
FailPreparation("The extraction vehicle must be a helicopter with a pilot position and at least twelve pilot/passenger seats, with a valid escape boundary.");
FailPreparation("The extraction pickup must be a prepared landing zone with a valid escape boundary.");
return;
}
if (!m_Environment.Start(this)) { FailPreparation("The selected date or vanilla weather profile could not be initialized."); return; }
if (!m_Time.Start(this)) { FailPreparation("The world clock could not be initialized."); return; }
Transition(ERR_Phase.PRISON);
m_StartPrepared = true;
if (m_StartMode == 1) Transition(ERR_Phase.RADIO_SEARCH);
else Transition(ERR_Phase.PRISON);
}
protected void ExtractionResolved(ERR_ExtractionState result)
@@ -486,6 +563,15 @@ class ERR_GameMode : SCR_BaseGameMode
}
protected void ServerTick()
{
if (!Replication.IsServer() || !Active()) return;
int started;
if (m_Performance) started = m_Performance.BeginTick();
ServerTickMeasured();
if (m_Performance) m_Performance.EndTick(started);
}
protected void ServerTickMeasured()
{
if (!Replication.IsServer() || !Active()) return;
float now = GetWorld().GetWorldTime();
@@ -502,27 +588,41 @@ class ERR_GameMode : SCR_BaseGameMode
Transition(ERR_Phase.DEFEAT);
return;
}
int perfStamp;
if (m_Performance) perfStamp = System.GetTickCount();
if (m_Command) m_Command.Maintain(now);
if (m_Performance) perfStamp = m_Performance.Mark(ERR_PerfMetric.MAINTENANCE, perfStamp);
if (m_CivilianTraffic) m_CivilianTraffic.Tick(now);
if (m_Performance) perfStamp = m_Performance.Mark(ERR_PerfMetric.CIVILIANS, perfStamp);
// Maintenance can finish or stop the round; do not resume tactical work.
if (!Active()) return;
if (m_Phase >= ERR_Phase.RADIO_SEARCH) TickForces(now);
if (GetGame().GetPlayerManager().GetPlayerCount() == 0)
{
m_NoSurvivorsSince = -1;
return;
}
if (m_Phase >= ERR_Phase.RADIO_SEARCH)
{
TickForces(now);
if (m_Performance) m_Performance.Mark(ERR_PerfMetric.FORCES, perfStamp);
}
if ((m_StartedPlayers || InitialAdmissionsExhausted()) && !m_Roster.HasSurvivor())
{
if (m_NoSurvivorsSince < 0) m_NoSurvivorsSince = now;
if (m_NoSurvivorsSince < 0)
{
m_NoSurvivorsSince = now;
PrintFormat("Reintroduced [round lifecycle]: no survivors; starting 30s grace; previouslyPlayed=%1 connected=%2", m_StartedPlayers, GetGame().GetPlayerManager().GetPlayerCount());
}
if (now - m_NoSurvivorsSince >= 30000)
{
m_EndReason = "No surviving prisoners remain.";
if (!m_StartedPlayers) m_EndReason = "No prisoners could be admitted to this round.";
PrintFormat("Reintroduced [round lifecycle]: ending exhausted round; connected=%1; %2", GetGame().GetPlayerManager().GetPlayerCount(), m_EndReason);
Transition(ERR_Phase.DEFEAT);
return;
}
}
else m_NoSurvivorsSince = -1;
// A played round must finish after its last reserved body expires, even
// with nobody connected. Keep tactical/observer work idle until players
// return; a fresh unplayed round still waits without a restart loop.
if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
if (m_Performance) perfStamp = System.GetTickCount();
array<int> connected = {};
GetGame().GetPlayerManager().GetPlayers(connected);
foreach (int id : connected)
@@ -530,8 +630,14 @@ class ERR_GameMode : SCR_BaseGameMode
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(id));
if (controller) controller.ERR_UpdateObserverServer();
}
if (m_Command && m_Phase >= ERR_Phase.RADIO_SEARCH) m_Command.Tick(now);
if (m_Performance) perfStamp = m_Performance.Mark(ERR_PerfMetric.OBSERVERS, perfStamp);
if (m_Command && m_Phase >= ERR_Phase.RADIO_SEARCH)
{
m_Command.Tick(now);
if (m_Performance) perfStamp = m_Performance.Mark(ERR_PerfMetric.HQ_TICK, perfStamp);
}
m_OnTick.Invoke(now);
if (m_Performance) m_Performance.Mark(ERR_PerfMetric.LISTENERS, perfStamp);
}
// Vanilla editor/session termination enters POSTGAME directly, without our
@@ -540,6 +646,7 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (m_NativeEndHandled) return;
m_NativeEndHandled = true;
StopLocalViewDistance();
GetGame().GetCallqueue().Remove(BindLocalObserver);
if (Replication.IsServer())
{
@@ -564,11 +671,13 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (m_Stopped) return;
m_Stopped = true;
StopLocalViewDistance();
GetGame().GetCallqueue().Remove(StartScenario);
GetGame().GetCallqueue().Remove(ServerTick);
GetGame().GetCallqueue().Remove(BindLocalObserver);
if (m_Command) m_Command.Stop();
m_OnStop.Invoke();
if (m_Performance) m_Performance.Stop(m_Phase);
m_Forces.Clear();
m_ForceAgents.Clear();
if (Replication.IsServer())
@@ -1,5 +1,5 @@
// 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.
// Searches create no entities; startup placements can precede the rest of the island.
class ERR_CampTerrain
{
protected ref array<vector> m_Airfields = {};
@@ -12,6 +12,7 @@ class ERR_CampTerrain
protected bool AddAirport(IEntity entity) { m_Airfields.Insert(entity.GetOrigin()); return true; }
void Collect(vector low, vector high)
{
ERR_AirfieldPlacement.AuthoredAirports(m_Airfields);
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)
@@ -24,7 +25,7 @@ class ERR_CampTerrain
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)
bool Clear(vector center, float heading, float halfSize, float maximumRange, out float lowest, float clearanceHeight = 4)
{
lowest = float.MAX; float highest = -float.MAX;
for (int x = -1; x <= 1; x++)
@@ -48,7 +49,7 @@ class ERR_CampTerrain
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";
trace.Start = Vector(cell[0], ground + 0.12, cell[2]); trace.End = trace.Start + Vector(0, clearanceHeight, 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;
@@ -69,6 +70,13 @@ class ERR_CampTerrain
aiWorld.GetRoadNetworkManager().GetClosestRoad(position, road, distance, skipNavlinks: true);
if (road && distance < 12) return false;
if (!Clear(position, heading, 10, 0.6, baseHeight)) return false;
if (ERR_StartSetup.IsCrash(site))
{
vector offset, center; float halfSize, height;
if (!ERR_StartSetup.Geometry(offset, center, halfSize, height)) return false;
float wreckFloor;
if (!Clear(position + Rotate(center, heading), heading, halfSize, 0.6, wreckFloor, height)) return false;
}
float guardHeight;
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return false;
if (site.ResponseCount > 0 && !Clear(position + Rotate(site.ResponseOffset, heading), heading, 6, 0.6, guardHeight)) return false;
@@ -86,12 +94,31 @@ class ERR_GeneratedSites
protected ERR_PrepareResult m_Result = ERR_PrepareResult.PENDING;
protected string m_Error;
string Error() { return m_Error; }
protected bool m_StartupOnly;
static bool StartupSite(ERR_SiteDefinition site)
{
return site && (site.Kind == ERR_LocationKind.PRISON || site.Kind == ERR_LocationKind.EXTRACTION);
}
void StartupOnly(bool value)
{
if (m_StartupOnly && !value) m_Began = System.GetTickCount();
m_StartupOnly = value;
}
bool StartupReady()
{
if (!m_Started || !m_Definition || m_Result == ERR_PrepareResult.FAILED) return false;
foreach (ERR_SiteDefinition site : m_Definition.Sites)
if (StartupSite(site) && (site.RandomClear || m_Definition.TerrainGenerated) && !site.PlacementResolved) return false;
return true;
}
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 extraction : definition.Sites) if (extraction.RandomClear && extraction.Kind == ERR_LocationKind.EXTRACTION) m_Pending.Insert(extraction);
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);
foreach (ERR_SiteDefinition optional : definition.Sites)
@@ -138,6 +165,12 @@ class ERR_GeneratedSites
PrintFormat("Co10 Escape Reintroduced [site skipped]: %1 (%2): no safe placement after %3 attempts and authored fallback rejected; omitted for this round; mission preparation continues", retained.Label, retained.Id, m_Attempts);
return true;
}
protected bool RescueFits(ERR_SiteDefinition site, vector position, float heading)
{
if (site.Kind != ERR_LocationKind.EXTRACTION) return true;
vector primary = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.AircraftOffset, heading));
return ERR_RescueLanding.Fits(primary);
}
ERR_PrepareResult Tick()
{
if (m_Result != ERR_PrepareResult.PENDING) return m_Result;
@@ -145,6 +178,7 @@ class ERR_GeneratedSites
int tick = System.GetTickCount();
while (m_Index < m_Pending.Count())
{
if (m_StartupOnly && StartupReady()) return ERR_PrepareResult.PENDING;
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;
@@ -154,7 +188,7 @@ class ERR_GeneratedSites
heading = Math.RandomFloat(0, 360); m_Attempts++;
}
float floor;
if (Separated(site, position, fallback) && m_Terrain.Site(site, position, heading, floor))
if (Separated(site, position, fallback) && m_Terrain.Site(site, position, heading, floor) && RescueFits(site, position, heading))
{
position[1] = floor + 0.02;
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
@@ -0,0 +1,49 @@
// Vanilla damage-state invokers supply one argument. Preserve the native UI
// update while supplying its optional attachment-refresh argument explicitly.
modded class SCR_InventoryHitZonePointContainerUI
{
protected ERR_GameMode m_ERRHitZoneMode;
protected ref array<SCR_HitZone> m_ERRHitZoneSources = {};
override void InitializeHitZoneUI(BaseInventoryStorageComponent storage, SCR_InventoryMenuUI menuManager, int hitZoneId, IEntity player, bool updateAttachmentStorage = true)
{
ERR_ClearHitZoneCallbacks();
super.InitializeHitZoneUI(storage, menuManager, hitZoneId, player, updateAttachmentStorage);
m_ERRHitZoneMode = ERR_GameMode.Get();
if (!m_ERRHitZoneMode) return;
ERR_BindHitZoneCallback(m_pBloodHitZone);
foreach (HitZone hz : m_aGroupHitZones)
ERR_BindHitZoneCallback(SCR_CharacterHitZone.Cast(hz));
}
protected void ERR_BindHitZoneCallback(SCR_HitZone hz)
{
if (!hz || m_ERRHitZoneSources.Contains(hz)) return;
ScriptInvoker changed = hz.GetOnDamageStateChanged(false);
if (!changed) return;
changed.Remove(UpdateHitZoneState);
changed.Insert(ERR_HitZoneStateChanged);
m_ERRHitZoneSources.Insert(hz);
}
protected void ERR_HitZoneStateChanged(SCR_HitZone hz)
{
if (!m_ERRHitZoneMode || m_ERRHitZoneMode != ERR_GameMode.Get() || !m_ERRHitZoneSources.Contains(hz)) return;
UpdateHitZoneState(hz, true);
}
protected void ERR_ClearHitZoneCallbacks()
{
m_ERRHitZoneMode = null;
foreach (SCR_HitZone hz : m_ERRHitZoneSources)
{
if (!hz) continue;
ScriptInvoker changed = hz.GetOnDamageStateChanged(false);
if (changed) changed.Remove(ERR_HitZoneStateChanged);
}
m_ERRHitZoneSources.Clear();
}
override void HandlerDeattached(Widget w)
{
ERR_ClearHitZoneCallbacks();
super.HandlerDeattached(w);
}
void ~SCR_InventoryHitZonePointContainerUI() { ERR_ClearHitZoneCallbacks(); }
}
@@ -0,0 +1,230 @@
// One server-owned working selection and named snapshots. Absent addons stay
// dormant: evaluation only visits the sources of a currently loaded resource.
class ERR_ItemPreferences
{
static const string PATH = "$profile:EscapistsItems.json";
protected string m_Path;
protected bool m_ReadOnly;
protected int m_Revision;
protected ref array<string> m_Records = {"active"};
protected ref map<string, int> m_Rules = new map<string, int>();
protected ref map<string, string> m_Loaded = new map<string, string>();
void ERR_ItemPreferences(string path = PATH)
{
m_Path = path;
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
array<string> addons = {}; GameProject.GetLoadedAddons(addons);
foreach (string guid : addons)
{
string id = guid; id.ToLower();
m_Loaded.Set(GameProject.GetAddonID(guid), id);
}
array<string> loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Records = loaded;
else if (ERR_ProfileConfig.Exists(m_Path) || ERR_ProfileConfig.Exists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Reintroduced [items]: invalid preferences; defaults active, damaged files preserved", LogLevel.ERROR);
}
Rebuild();
}
static bool Name(string value)
{
if (value.IsEmpty() || value.Length() > 24) return false;
string allowed = "abcdefghijklmnopqrstuvwxyz0123456789_-";
for (int i = 0; i < value.Length(); i++) if (allowed.IndexOf(value.Substring(i, 1)) < 0) return false;
return true;
}
static bool Guid(string value)
{
if (value.Length() != 16) return false;
for (int i = 0; i < 16; i++) if ("0123456789abcdef".IndexOf(value.Substring(i, 1)) < 0) return false;
return true;
}
static bool WeightKey(string key)
{
return key == "w*" || (key.Length() == 17 && key.Substring(0, 1) == "w" && Guid(key.Substring(1, 16)));
}
static bool WeightValue(string text)
{
int value;
return ERR_CommandArguments.Integer(text, value) && value >= 0 && value <= 100;
}
static bool Valid(array<string> records)
{
if (!records || records.Count() > 65536 || !records.Contains("active")) return false;
array<string> names = {};
set<string> keys = new set<string>();
foreach (string row : records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts.Count() == 1)
{
if (!Name(row) || names.Contains(row)) return false;
names.Insert(row);
}
else
{
if (parts.Count() != 4 || !Name(parts[0]) || !Guid(parts[1])) return false;
if (WeightKey(parts[2])) { if (!WeightValue(parts[3])) return false; }
else if ((parts[2] != "*" && !Guid(parts[2])) || (parts[3] != "0" && parts[3] != "1")) return false;
}
}
if (names.Count() > 17) return false;
foreach (string row : records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts.Count() == 1) continue;
string key = parts[0] + "|" + parts[1] + "|" + parts[2];
if (!names.Contains(parts[0]) || keys.Contains(key)) return false;
keys.Insert(key);
}
return true;
}
protected bool Read(string path, out array<string> records)
{
JsonLoadContext json = new JsonLoadContext();
return ERR_ProfileConfig.Load(path, json) && Parse(json, records);
}
static bool Parse(JsonLoadContext json, out array<string> records)
{
records = {}; int version;
return json.ReadValue("version", version) && (version == 1 || version == 2) && json.ReadValue("records", records) && Valid(records);
}
protected bool Equal(array<string> a, array<string> b)
{
if (!a || !b || a.Count() != b.Count()) return false;
for (int i = 0; i < a.Count(); i++) if (a[i] != b[i]) return false;
return true;
}
protected void Rebuild()
{
m_Rules.Clear();
foreach (string row : m_Records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts.Count() == 4 && parts[0] == "active") m_Rules.Set(parts[1] + "|" + parts[2], parts[3].ToInt());
}
m_Revision++;
}
protected bool Save(array<string> records)
{
if (!Replication.IsServer() || m_ReadOnly || !Valid(records)) return false;
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
json.WriteValue("version", 2); json.WriteValue("records", records);
if (m_Path == PATH)
{
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
m_Records = records; Rebuild(); return true;
}
array<string> checked, previous;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || !Equal(records, checked)) return false;
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) || !Equal(records, checked))
{
if (hadPrevious) FileIO.CopyFile(m_Path + ".bak", m_Path);
else FileIO.DeleteFile(m_Path);
return false;
}
m_Records = checked; Rebuild(); return true;
}
int Revision() { return m_Revision; }
bool Enabled(string addon, string item)
{
string key = addon + "|" + item;
if (m_Rules.Contains(key)) return m_Rules.Get(key) == 1;
key = addon + "|*";
return !m_Rules.Contains(key) || m_Rules.Get(key) == 1;
}
bool Allows(ResourceName prefab)
{
string item = ERR_TerrainCatalog.Key(prefab);
if (item.IsEmpty()) return true;
array<string> sources = SCR_AddonTool.GetResourceAddons(prefab);
foreach (string project : sources)
if (m_Loaded.Contains(project) && !Enabled(m_Loaded.Get(project), item)) return false;
return true;
}
bool Set(string addon, string item, bool enabled)
{
if (!Guid(addon) || (item != "*" && !Guid(item))) return false;
array<string> next = {};
foreach (string row : m_Records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts.Count() == 4 && parts[0] == "active" && parts[1] == addon && (item == "*" || parts[2] == item) && !WeightKey(parts[2])) continue;
next.Insert(row);
}
string value = "0"; if (enabled) value = "1";
next.Insert("active|" + addon + "|" + item + "|" + value);
return Save(next);
}
// Relative lottery weights, separate from enable/disable permissions. Shared
// resource contributors use the highest multipliers, never duplicate tickets.
// Zero from any contributor excludes the item, just like a disabled source.
int WeightSetting(string addon, string item)
{
string key = addon + "|w" + item;
if (m_Rules.Contains(key)) return m_Rules.Get(key);
return 1;
}
int Weight(ResourceName prefab)
{
string item = ERR_TerrainCatalog.Key(prefab);
int modWeight = 1, itemWeight = 1;
array<string> sources = SCR_AddonTool.GetResourceAddons(prefab);
foreach (string project : sources)
{
if (!m_Loaded.Contains(project)) continue;
string addon = m_Loaded.Get(project);
int modValue = WeightSetting(addon, "*"), itemValue = WeightSetting(addon, item);
if (modValue == 0 || itemValue == 0) return 0;
modWeight = Math.Max(modWeight, modValue); itemWeight = Math.Max(itemWeight, itemValue);
}
return modWeight * itemWeight;
}
bool SetWeight(string addon, string item, int weight)
{
if (!Guid(addon) || (item != "*" && !Guid(item)) || weight < 0 || weight > 100) return false;
string key = "w" + item;
array<string> next = {};
foreach (string row : m_Records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts.Count() == 4 && parts[0] == "active" && parts[1] == addon && parts[2] == key) continue;
next.Insert(row);
}
if (weight != 1) next.Insert("active|" + addon + "|" + key + "|" + weight.ToString());
return Save(next);
}
bool Preset(string action, string name)
{
if (!Name(name) || name == "active" || (action != "save" && action != "load" && action != "delete")) return false;
if (action != "save" && !m_Records.Contains(name)) return false;
string target = name, source = "active";
if (action == "load") { target = "active"; source = name; }
array<string> next = {};
foreach (string row : m_Records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts[0] != target) next.Insert(row);
}
if (action != "delete")
{
next.Insert(target);
foreach (string row : m_Records)
{
array<string> parts = {}; row.Split("|", parts, false);
if (parts.Count() == 4 && parts[0] == source) next.Insert(target + "|" + parts[1] + "|" + parts[2] + "|" + parts[3]);
}
}
return Save(next);
}
void Snapshot(array<string> values)
{
foreach (string row : m_Records) if (row != "active" && row.IndexOf("|") < 0) values.Insert("itemPreset=" + row);
if (m_ReadOnly) values.Insert("itemsStorage=Read-only: preference file needs repair");
else values.Insert("itemsStorage=Auto-saved; applies to future random equipment. Presets include selections and probability weights for all mods.");
}
}
@@ -0,0 +1,57 @@
// A support station's cleanup must not require a still-living item catalog.
// Remember the subscriptions made by successful native initialization, rather
// than rerunning startup validation after that catalog has been destroyed.
modded class SCR_BaseItemSupportStationComponent
{
protected bool m_ERRItemStationScoped;
protected bool m_ERRItemStationInitializing;
protected bool m_ERRItemStationInitialized;
protected bool m_ERRItemStationDeleted;
protected SCR_ResourceComponent m_ERRItemStationResource;
protected SCR_SupportStationManagerComponent m_ERRItemStationManager;
override void OnPostInit(IEntity owner)
{
m_ERRItemStationScoped = !SCR_Global.IsEditMode() && (ERR_GameMode.Get() || ERR_TerrainTakeoverSettings.Enabled());
super.OnPostInit(owner);
}
protected override void DelayedInit(IEntity owner)
{
if (m_ERRItemStationDeleted) return;
// A preloader/preview is not a playable world. Its transient equipment may
// be created before any game mode/catalog exists and immediately discarded.
if (m_ERRItemStationScoped && owner && GetGame() && owner.GetWorld() != GetGame().GetWorld() && !SCR_EntityCatalogManagerComponent.GetInstance()) return;
m_ERRItemStationInitializing = m_ERRItemStationScoped;
super.DelayedInit(owner);
m_ERRItemStationInitializing = false;
if (m_ERRItemStationInitialized && UsesRange()) m_ERRItemStationManager = SCR_SupportStationManagerComponent.GetInstance();
}
protected override void AddRemoveOnDamageStateChanged(IEntity owner, bool subscribe)
{
super.AddRemoveOnDamageStateChanged(owner, subscribe);
// This hook is reached only after the complete native virtual validation.
if (!subscribe || !m_ERRItemStationInitializing) return;
m_ERRItemStationInitialized = true;
if (m_ResourceConsumer || m_ResourceGenerator) m_ERRItemStationResource = GetResourceComponent();
}
override void OnDelete(IEntity owner)
{
if (!m_ERRItemStationScoped || SCR_Global.IsEditMode())
{
super.OnDelete(owner);
return;
}
if (m_ERRItemStationDeleted) return;
m_ERRItemStationDeleted = true;
if (GetGame()) GetGame().GetCallqueue().Remove(DelayedInit);
if (m_ERRItemStationInitialized)
{
AddRemoveOnDamageStateChanged(owner, false);
// Use the managers this station joined, never a new round's singleton.
if (m_ERRItemStationManager) m_ERRItemStationManager.RemoveSupportStation(this);
if (m_ERRItemStationResource) m_ERRItemStationResource.TEMP_GetOnInteractorReplicated().Remove(TEMP_OnInteractorReplicated);
}
m_ERRItemStationManager = null;
m_ERRItemStationResource = null;
}
}
@@ -32,6 +32,7 @@ class ERR_JournalText
if (end < 0) return Short(message);
string result = message.Substring(0, end);
result.Replace("aggression-uaz-", "UAZ patrol ");
result.Replace("aggression-truck-", "Troop-truck patrol ");
return result;
}
static string YesNo(string value)
@@ -45,7 +46,7 @@ class ERR_JournalText
if (kind == "REGISTER") return "Group " + Field(message, "group") + " joined HQ: " + Field(message, "members") + " soldiers, faction " + Field(message, "faction") + ". Assignment: " + Field(message, "force") + ".";
if (kind == "SPAWN MEMBER") return "Group " + Field(message, "group") + ": soldier spawned at " + Field(message, "position") + ".";
if (kind == "CONTACT LOCAL") return "Group " + Field(message, "group") + " spotted a target at " + Field(message, "observed") + ". Local combat has started; the report still needs to reach HQ.";
if (kind == "AGGRESSION") return "HQ has identified " + Field(message, "bodies") + " bodies. Aggression: " + Field(message, "aggression") + ". Total hunter UAZ allowance: " + Field(message, "allowance") + ".";
if (kind == "AGGRESSION") return "HQ has identified " + Field(message, "bodies") + " bodies. Aggression: " + Field(message, "aggression") + ". Total hunting vehicle allowance: " + Field(message, "allowance") + ".";
if (kind == "HUNTER SPAWN") return Subject(message) + " deployed with " + Field(message, "crew") + " crew at " + Field(message, "position") + ". Deployment source: " + Field(message, "base") + ".";
if (kind == "HUNTER READY") return Subject(message) + ": driver and gunner are seated; registered with HQ.";
if (kind == "HUNTER ORDER") return Subject(message) + " received its radio order. Drive to " + Field(message, "destination") + ".";
@@ -87,8 +88,17 @@ class ERR_JournalText
if (kind == "MORTAR SITE STATUS") return "Mortar " + Field(message, "site") + ": operational: " + YesNo(Field(message, "operational")) + "; combat-capable crew: " + Field(message, "fighting") + "; HE shells: " + Field(message, "inventory HE") + "; HQ radio connected: " + YesNo(Field(message, "radio connected")) + ".";
if (kind == "MORTAR ORDER STATUS") return "Mortar " + Field(message, "site") + ": " + Field(message, "order shots") + " shots in this order; " + Field(message, "total shots") + " shots fired in total.";
if (kind == "BODY SEARCH COMPLETE") return "A recovery team finished searching a body. " + Short(message);
if (kind == "BODY RECOVERY TEAM") return "Recovery team assigned. Soldier preparation may still be pending. " + Short(message);
if (kind == "BODY RECOVERY READY") return "Recovery team has soldiers and a working manpack; waiting for its search order. " + Short(message);
if (kind == "BODY RECOVERY STATUS") return "Body recovery waiting: " + Short(message);
if (kind == "BODY RECOVERY END")
{
if (Field(message, "delivered") == "1") return "HQ received the completed body inspection. " + Short(message);
return "Body recovery ended without an inspection report reaching HQ. " + Short(message);
}
if (kind == "CONTACT REPORT") return "Squad reporting a captured sighting to HQ: " + Short(message);
if (kind == "GUNFIRE REPORT") return "Squad reporting heard gunfire to HQ: " + Short(message);
if (kind == "ALARM REPORT") return "Squad reporting a heard vehicle alarm to HQ: " + Short(message);
if (kind == "INFANTRY COMBAT") return message;
if (kind == "INTEL COMPROMISE") return "HQ learned that intel is missing: " + Short(message);
if (kind == "OBJECTIVE DEFENCE") return "Objective security: " + Short(message);
@@ -13,6 +13,14 @@ class ERR_LocationComponent : ScriptComponent
[RplProp()]
protected int m_Knowledge;
[RplProp(onRplName: "IdentityChanged")] protected bool m_Prepared = true;
bool Prepared() { return m_Prepared; }
void SetPrepared(bool ready)
{
if (!Replication.IsServer()) return;
m_Prepared = ready;
IdentityChanged(); Replication.BumpMe();
}
string Id() { return m_Id; }
string Label() { return m_Label; }
ERR_LocationKind Kind() { return m_Kind; }
@@ -23,7 +31,7 @@ class ERR_LocationComponent : ScriptComponent
{
if (!Replication.IsServer() || id.IsEmpty()) return false;
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || mode.Phase() != ERR_Phase.PREPARING) return false;
if (!mode || (mode.Phase() != ERR_Phase.PREPARING && !mode.Active())) return false;
mode.UnregisterLocation(this);
m_Id = id;
m_Label = label;
@@ -41,7 +49,7 @@ class ERR_LocationComponent : ScriptComponent
}
bool Discover(int roll, int chance)
{
if (!Replication.IsServer() || m_Knowledge >= 2) return false;
if (!Replication.IsServer() || !m_Prepared || m_Knowledge >= 2) return false;
int previous = m_Knowledge;
m_Knowledge = ERR_MissionRules.Discovery(m_Knowledge, roll, chance);
Replication.BumpMe();
@@ -17,11 +17,15 @@ class ERR_MissionRules
return phase == ERR_Phase.VICTORY || phase == ERR_Phase.DEFEAT;
}
static bool CanTransition(int from, int to)
static bool CanTransition(int from, int to, bool crashStart = false)
{
if (Terminal(from) || from == to) return false;
if (to == ERR_Phase.DEFEAT) return from >= ERR_Phase.PRISON && from <= ERR_Phase.EXTRACTION;
if (from == ERR_Phase.PREPARING) return to == ERR_Phase.PRISON;
if (from == ERR_Phase.PREPARING)
{
if (crashStart) return to == ERR_Phase.RADIO_SEARCH;
return to == ERR_Phase.PRISON;
}
if (from == ERR_Phase.PRISON) return to == ERR_Phase.RADIO_SEARCH;
if (from == ERR_Phase.RADIO_SEARCH) return to == ERR_Phase.RADIO_DEFENCE;
if (from == ERR_Phase.RADIO_DEFENCE) return to == ERR_Phase.EXTRACTION;
@@ -54,14 +54,23 @@ modded class ERR_AdminMenu
foreach (string key : ERR_AdminMenuFields.Keys())
{
ERR_ProbeFieldPage(key);
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("Random_" + key, GetRootWidget());
string prefix = "Random_";
string choice = "Random";
if (key == "viewDistanceMetres") { prefix = "Default_"; choice = "default"; }
else if (!ERR_Settings.Randomizable(key))
{
// Fixed-only settings (counts, start mode) deliberately have no Random button.
if (GetRootWidget().FindAnyWidget("Random_" + key) || !SCR_EditBoxComponent.GetEditBoxComponent(key, GetRootWidget())) return false;
continue;
}
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(prefix + 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;
if (edit.GetValue() != choice) return false;
edit.SetValue(previous);
}
ERR_ProbeFieldPage("startTimeMinutes");
@@ -57,6 +57,12 @@ class ERR_NativeAdminSettingsProbe
ERR_Settings settings = new ERR_Settings(path);
foreach (string key : ERR_AdminMenuFields.Keys())
{
if (!ERR_Settings.Randomizable(key))
{
int unsupported;
if (!Check(!ERR_CommandArguments.Selection(key, "random", unsupported) && !settings.Set(key, -1), "fixed-only setting rejects random: " + key)) return false;
continue;
}
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);
@@ -87,7 +87,8 @@ modded class ERR_GameMode
float now = GetWorld().GetWorldTime();
if (m_ERRAggressionStage == 0)
{
if (!ERR_AggressionCheck(UnlockPrison(player), "native prison release")) return;
// The shared smoke flow may already have lockpicked the prison; either way the round must be past it.
if (!ERR_AggressionCheck(UnlockPrison(player) || Phase() >= ERR_Phase.RADIO_SEARCH, "native prison release")) return;
player.SetOrigin(ERR_Actors.Ground(Sites().Find("everon-medical-1").Location.Position() + "300 0 0"));
if (!ERR_AggressionCheck(hq.Watch().HunterTarget() == 3, "opening allowance is three without identified bodies")) 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;
@@ -20,6 +20,7 @@ modded class ERR_GameMode
protected ref ERR_NativeLocalAdmissionProbe m_ERRLocalAdmissionProbe;
protected ref ERR_NativePoweredFlightProbe m_ERRPoweredFlightProbe;
protected ref ERR_NativePrisonBreachProbe m_ERRPrisonBreachProbe;
protected ref ERR_NativeInventoryHitZoneProbe m_ERRInventoryHitZoneProbe;
protected ref ERR_NativeItemStationTeardownProbe m_ERRItemStationTeardownProbe;
protected ref ERR_NativeObjectiveLossProbe m_ERRObjectiveLossProbe;
protected ref ERR_NativeMedicalProbe m_ERRBaseMedicalProbe;
@@ -54,7 +55,7 @@ modded class ERR_GameMode
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string nativeEndCase;
array<string> independentFixtures = {"optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "native-end-active", "native-end-preparing", "generated-sites", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
array<string> independentFixtures = {"coordinated-ai", "optional-site-skip", "infantry-combat", "medical-combat", "hunter-faction", "unconscious-camera", "objective-map", "town-garrisons", "site-garrisons", "radio-interaction", "enemy-loadout", "aggression", "native-end-active", "native-end-preparing", "generated-sites", "mortar-station", "opening-tools", "airfield-markers", "air-logistics", "air-assault", "air-recon"};
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && independentFixtures.Contains(nativeEndCase)) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
@@ -100,6 +101,7 @@ modded class ERR_GameMode
if (m_ERRLocalAdmissionProbe) m_ERRLocalAdmissionProbe.Stop();
if (m_ERRPoweredFlightProbe) m_ERRPoweredFlightProbe.Stop();
if (m_ERRPrisonBreachProbe) m_ERRPrisonBreachProbe.Stop();
if (m_ERRInventoryHitZoneProbe) m_ERRInventoryHitZoneProbe.Stop();
if (m_ERRItemStationTeardownProbe) m_ERRItemStationTeardownProbe.Stop();
if (m_ERRCasualtyServiceProbe) m_ERRCasualtyServiceProbe.Stop();
if (m_ERRObjectiveLossProbe) m_ERRObjectiveLossProbe.Stop();
@@ -172,6 +174,13 @@ modded class ERR_GameMode
{
if (!ERR_MissionRules.Terminal(current)) return;
GetGame().GetCallqueue().Remove(ERR_BaseTick);
if (m_ERRExtractionCase == "inventory-hitzone")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 2102, "native inventory callback fixture finished before explicit reset")) return;
ERR_StopHealthProbes();
Print("ERR SMOKE COMPLETE: real one-argument blood event reaches native inventory updater and detached points stay silent; synthetic notification, no injury/manual UI/multiplayer proof");
return;
}
if (m_ERRExtractionCase == "station-teardown")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 2002, "native station teardown fixture finished before explicit reset")) return;
@@ -336,6 +345,13 @@ modded class ERR_GameMode
Print("ERR SMOKE COMPLETE: expected startup-failure");
return;
}
if (m_ERRBaseStep == 2100)
{
int result = m_ERRInventoryHitZoneProbe.Tick(now);
if (result < 0) { ERR_BaseCheck(false, "native inventory hitzone callback fixture"); return; }
if (result == 1) { m_ERRBaseStep = 2102; Transition(ERR_Phase.DEFEAT); }
return;
}
if (m_ERRBaseStep == 2000)
{
int result = m_ERRItemStationTeardownProbe.Tick(now);
@@ -510,6 +526,12 @@ modded class ERR_GameMode
if (!ERR_NativeDiscoveryMapProbe.Run()) { GetGame().GetCallqueue().Remove(ERR_BaseTick); return; }
m_ERRDiscoveryMapChecked = true;
}
if (m_ERRExtractionCase == "inventory-hitzone")
{
m_ERRInventoryHitZoneProbe = new ERR_NativeInventoryHitZoneProbe(this, m_ERRBasePlayer);
m_ERRBaseStep = 2100;
return;
}
if (m_ERRExtractionCase == "station-teardown")
{
m_ERRItemStationTeardownProbe = new ERR_NativeItemStationTeardownProbe();
@@ -45,7 +45,9 @@ class ERR_NativeContractsProbe
mode.Admin().Execute(mode, true, "settings", replies);
if (!Check(replies.Count() == ERR_Settings.Keys().Count(), "base settings listing available")) return false;
mode.Admin().Execute(mode, true, "status", replies);
if (!Check(!replies.IsEmpty() && replies[0] == "Escape the prison", "base mission status available before companions")) return false;
string initialStatus = "Escape the prison";
if (mode.StartMode() == 1) initialStatus = "Locate the radio station";
if (!Check(!replies.IsEmpty() && replies[0] == initialStatus, "base mission status available before companions")) return false;
mode.Admin().Execute(mode, true, "mission radio set " + original.ToString(), replies);
if (!Check(mode.Settings().Get("radioDefenceSeconds") == original, "test setting restored")) return false;
return true;
@@ -0,0 +1,542 @@
#ifdef WORKBENCH
// Explicit local fixture: existing infantry, native movement/perception/projectiles.
// Orders and the prisoner's firing-range placement are arranged inputs. This is
// not proof of HQ radio delivery, multiplayer balance or natural target discovery.
modded class ERR_ScenarioComponent
{
protected override bool ERR_ConfigurePatrolHQFixture(string outcome)
{
if (outcome != "coordinated-ai") return super.ERR_ConfigurePatrolHQFixture(outcome);
if (m_TerrainIndependent || !m_RuntimeDefinition) return false;
// The historical native fixture predates mobile site squads. Populate these
// normal finite responders before the mission starts, never replace a squad
// or create soldiers while an execution is being measured.
array<string> selected = {"everon-outpost-1", "everon-supply-2"};
foreach (string siteId : selected)
{
ERR_SiteDefinition site = m_RuntimeDefinition.Find(siteId);
if (!site || !site.Guarded || site.ResponseCount != 0) return false;
site.ResponseCount = 2; site.ResponseOffset = "-20 0 -20";
}
return m_RuntimeDefinition.Valid();
}
}
modded class ERR_HQ
{
bool ERR_CoordinatedFixtureUnits(array<ref ERR_HQUnit> selected)
{
string outcome;
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "coordinated-ai" || !m_Mode || m_Mode.Phase() != ERR_Phase.PRISON) return false;
selected.Clear();
array<string> sites = {"everon-supply-2", "everon-outpost-1"};
foreach (string id : sites)
{
ERR_SiteRuntime site = m_Mode.Sites().Find(id);
if (!site || !site.Responders || !site.Responders.Group) { selected.Clear(); return false; }
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group != site.Responders.Group || unit.HomeGuard || !unit.Eligible(m_Mode) || !unit.StatusSampler.OwnsRoster(unit.Group)
|| unit.Assignment || unit.PendingTask || unit.Downlink || unit.StatusSampler.InitialStrength() != 2) continue;
selected.Insert(unit);
break;
}
}
if (selected.Count() == 2) return true;
selected.Clear(); return false;
}
}
modded class ERR_HQExecution
{
protected int m_FixtureLastState = -2;
protected int m_FixtureLastShots = -1;
protected bool m_FixtureLastOwned;
protected int m_FixtureRawProjectiles;
override void Shot(IEntity actor, int slot, BaseWeaponComponent weapon)
{
m_FixtureRawProjectiles++;
super.Shot(actor, slot, weapon);
}
int ERR_FixtureActualSuppressShots() { return m_ShotCount; }
void ERR_FixtureSuppressChanges(float now)
{
int state = -1; bool activityOwned;
if (m_Suppress) state = m_Suppress.GetActionState();
if (m_Unit && m_Unit.Utility) activityOwned = m_Suppress && m_Unit.Utility.GetExecutedAction() == m_Suppress;
if (state == m_FixtureLastState && m_ShotCount == m_FixtureLastShots && activityOwned == m_FixtureLastOwned) return;
m_FixtureLastState = state; m_FixtureLastShots = m_ShotCount; m_FixtureLastOwned = activityOwned;
ERR_FixtureSuppressDiagnostic(now);
if (m_Unit && m_Unit.Utility) PrintFormat("ERR COORDINATED NATIVE GROUP ACTION: %1", m_Unit.Utility.GetExecutedAction());
}
void ERR_FixtureSuppressDiagnostic(float now)
{
int state = -1; bool activityOwned;
if (m_Suppress) state = m_Suppress.GetActionState();
if (m_Unit && m_Unit.Utility) activityOwned = m_Suppress && m_Unit.Utility.GetExecutedAction() == m_Suppress;
PrintFormat("ERR COORDINATED SUPPRESSION: activity=%1 state=%2 executingOwned=%3 ammoReady=%4 contactAge=%5 attempted=%6 shots=%7", m_Suppress != null, state, activityOwned, m_AmmoReady, now - m_ContactAt, m_SuppressAttempted, m_ShotCount);
PrintFormat("ERR COORDINATED SUPPRESSION BUDGET: callbacks=%1 attempts=%2 areaAge=%3 support=%4", m_FixtureRawProjectiles, m_SuppressAttempts, now - m_SuppressSeenAt, m_SupportReady);
if (!m_Unit || !m_Unit.StatusSampler) return;
for (int slot = 0; slot < m_Unit.StatusSampler.InitialStrength() && slot < 2; slot++)
{
IEntity actor = m_Unit.StatusSampler.InitialMember(slot);
if (!actor || !m_Unit.StatusSampler.CanAttribute(slot, actor, false)) continue;
AIAgent agent = SCR_AIUtils.GetAIAgent(actor);
if (!agent) continue;
SCR_AIUtilityComponent utility = SCR_AIUtilityComponent.Cast(agent.FindComponent(SCR_AIUtilityComponent));
if (!utility) continue;
SCR_AISuppressBehavior behavior = SCR_AISuppressBehavior.Cast(utility.GetExecutedAction());
bool sameActivity = behavior && behavior.GetGroupActivityContext() == m_Suppress;
PrintFormat("ERR COORDINATED SOLDIER ACTION: slot=%1 behavior=%2 exactActivity=%3", slot, utility.GetExecutedAction(), sameActivity);
}
}
}
modded class SCR_AIGroupUtilityComponent
{
IEntity ERR_FixtureVisualObserver(IEntity subject, float now, IEntity required = null, float observedAfter = -1)
{
foreach (SCR_AIInfoComponent info : m_aInfoComponents)
{
if (!info || !info.m_Perception) continue;
AIAgent agent = AIAgent.Cast(info.GetOwner());
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity()) || (required && agent.GetControlledEntity() != required)) continue;
array<BaseTarget> targets = {}; info.m_Perception.GetTargetsList(targets, ETargetCategory.ENEMY);
foreach (BaseTarget target : targets)
{
ERR_HQVisualContact seen = ERR_HQObservation.Capture(target, now);
if (seen && seen.Entity == subject && seen.ObservedAt >= observedAfter && vector.DistanceXZ(seen.Position, subject.GetOrigin()) <= 5) return agent.GetControlledEntity();
}
}
return null;
}
void ERR_FixturePerceptionDiagnostic()
{
int members; int enemies;
foreach (SCR_AIInfoComponent info : m_aInfoComponents)
{
if (!info || !info.m_Perception) continue;
members++;
array<BaseTarget> targets = {}; info.m_Perception.GetTargetsList(targets, ETargetCategory.ENEMY);
foreach (BaseTarget target : targets)
{
if (!target) continue;
enemies++;
if (enemies > 4) continue;
PrintFormat("ERR COORDINATED PERCEPTION TARGET: age=%1 trace=%2 exposure=%3 distance=%4 position=%5", target.GetTimeSinceSeen(), target.GetTraceFraction(), target.GetExposure(), target.GetDistance(), target.GetLastSeenPosition());
}
}
PrintFormat("ERR COORDINATED PERCEPTION: members=%1 enemyEntries=%2", members, enemies);
}
}
class ERR_NativeCoordinatedAIProbe
{
protected ERR_GameMode m_Mode;
protected IEntity m_Player;
protected DamageManagerComponent m_PlayerDamage;
protected bool m_PlayerDamageEnabled;
protected vector m_PlayerOrigin;
protected ref array<ref ERR_HQUnit> m_Units = {};
protected ref array<ref ERR_HQExecution> m_Executions = {};
protected ref array<vector> m_Origins = {};
protected ref array<vector> m_Destinations = {};
protected int m_Step;
protected int m_ExecutionId;
protected int m_MessageId;
protected float m_Deadline;
protected float m_NextDiagnostic;
protected float m_SupportDeadline;
protected int m_AmmoBefore;
protected int m_AmmoAfter;
protected int m_NativeMoveFailures;
protected bool m_FixOrdered;
protected bool m_ApproachSeen;
protected int m_ApproachArrangements;
protected float m_NextApproach;
protected float m_ArrangementAt = -1;
protected IEntity m_FiringWitness;
protected bool m_Stopped;
protected bool m_BlockedOnly;
protected bool m_ArrivalProved;
protected vector m_SettlePosition;
protected float m_SettleSince;
protected vector m_Unreachable;
void ERR_NativeCoordinatedAIProbe(ERR_GameMode mode, IEntity player)
{
m_Mode = mode; m_Player = player; m_PlayerOrigin = player.GetOrigin();
string blocked;
m_BlockedOnly = System.GetCLIParam("errCoordinatedBlockedOnly", blocked) && blocked == "1";
m_PlayerDamage = DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent));
if (m_PlayerDamage) { m_PlayerDamageEnabled = m_PlayerDamage.IsDamageHandlingEnabled(); m_PlayerDamage.EnableDamageHandling(false); }
m_Deadline = GetGame().GetWorld().GetWorldTime() + 30000;
}
bool BlockedOnly() { return m_BlockedOnly; }
protected bool Check(bool success, string detail)
{
if (success) { Print("ERR SMOKE PASS: coordinated AI " + detail); return true; }
Print("ERR SMOKE FAIL: coordinated AI " + detail, LogLevel.ERROR); return false;
}
protected ERR_HQExecutionReport Report(int slot, float now)
{
if (slot < 0 || slot >= m_Executions.Count()) return null;
return m_Executions[slot].Snapshot(m_Units[slot].Id, ++m_MessageId, now);
}
protected void PlacePrisoner(vector position)
{
ChimeraCharacter character = ChimeraCharacter.Cast(m_Player);
if (!character) return;
vector transform[4]; m_Player.GetWorldTransform(transform); transform[3] = position;
// The character physics controller owns its body transform; entity origin
// alone is not a lasting teleport. This matches the native HQ response proof.
character.Teleport(transform);
}
protected bool Destination(ERR_HQUnit unit, vector origin, float radius, out vector chosen)
{
AIPathfindingComponent path = AIPathfindingComponent.Cast(unit.Group.FindComponent(AIPathfindingComponent));
if (!path) return false;
NavmeshWorldComponent nav = path.GetNavmeshComponent();
if (!nav) return false;
for (int direction = 0; direction < 8; direction++)
{
float bearing = direction * 45 * Math.DEG2RAD;
vector candidate = ERR_Actors.Ground(origin + Vector(Math.Cos(bearing) * radius, 0, Math.Sin(bearing) * radius));
if (candidate[1] < 1 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), candidate)) continue;
if (!nav.IsTileLoaded(candidate)) { nav.LoadTileIn(candidate); continue; }
vector reachable;
if (!path.GetClosestPositionOnNavmesh(candidate, "3 3 3", reachable) || vector.DistanceXZ(origin, reachable) < radius - 5) continue;
if (reachable[1] < 1 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), reachable)) continue;
chosen = ERR_Actors.Ground(reachable); return true;
}
return false;
}
protected bool Order(int slot, vector destination, ERR_HQOperationRole role, float now, float supportUntil = 0, vector supportPosition = "0 0 0")
{
ERR_HQUnit unit = m_Units[slot];
if (slot < m_Executions.Count()) m_Executions[slot].Stop(now);
unit.ReleaseAssignment();
ERR_HQTask task = new ERR_HQTask();
task.OperationId = 9001; task.OperationRevision = 1; task.ExecutionId = ++m_ExecutionId;
task.BeliefId = 9001; task.BeliefRevision = 1; task.HypothesisId = slot + 1;
task.OperationRole = role; task.Role = ERR_HQPursuitRole.SEARCH;
task.Destination = destination; task.QueuedAt = now; task.ExpiresAt = now + 300000;
task.SupportUntil = supportUntil; task.SupportPosition = supportPosition;
task.ExpectedOutcome = "explicit native execution fixture";
unit.Task = task; unit.Assignment = new ERR_HQReport();
ResourceName waypointResource = ERR_Resources.DefendWaypoint();
if (role == ERR_HQOperationRole.FLANK) waypointResource = ERR_Resources.MoveWaypoint();
unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(waypointResource, destination));
if (!unit.Waypoint) return false;
unit.Waypoint.SetCompletionRadius(25);
if (role == ERR_HQOperationRole.FLANK) unit.Waypoint.SetCompletionRadius(5);
unit.Group.AddWaypointAt(unit.Waypoint, 0);
ERR_HQExecution execution = new ERR_HQExecution(); execution.Receive(task, now);
if (slot < m_Executions.Count()) m_Executions[slot] = execution;
else m_Executions.Insert(execution);
// Production DeliverOrder samples immediately on receipt, while the current
// native contact is still fresh; the fixture must preserve that timing.
execution.Update(unit, now);
if (role == ERR_HQOperationRole.FIX) execution.ERR_FixtureSuppressChanges(now);
PrintFormat("ERR COORDINATED FIXTURE ORDER: unit=%1 execution=%2 role=%3 origin=%4 destination=%5", unit.Id, task.ExecutionId, role, unit.StatusSampler.InitialMember(0).GetOrigin(), destination);
return true;
}
protected int Rounds(ERR_HQUnit unit)
{
ERR_HQAmmoSnapshot ammo = new ERR_HQAmmoSnapshot(); ammo.Complete = true;
for (int slot = 0; slot < unit.StatusSampler.InitialStrength(); slot++)
{
IEntity actor = unit.StatusSampler.InitialMember(slot);
if (!ERR_Actors.CanFight(actor) || !unit.StatusSampler.CanAttribute(slot, actor, false)) return -1;
ERR_HQAmmoSampler.Inspect(actor, unit.Group, ammo);
}
if (!ammo.Complete || ammo.Members != unit.StatusSampler.InitialStrength()) return -1;
return ammo.SmallArmsRounds + ammo.SmallArmsChambers;
}
protected bool FiringArea(ERR_HQUnit unit, out vector chosen, float radius = 75)
{
vector origin = unit.StatusSampler.InitialMember(0).GetOrigin();
vector basis[4]; unit.StatusSampler.InitialMember(0).GetTransform(basis);
float facing = Math.Atan2(basis[2][2], basis[2][0]);
array<float> angles = {0, -15, 15, -30, 30, -45, 45, -60, 60};
foreach (float angle : angles)
{
float bearing = facing + angle * Math.DEG2RAD;
vector point = ERR_Actors.Ground(origin + Vector(Math.Cos(bearing) * radius, 0, Math.Sin(bearing) * radius));
if (point[1] < 1 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) continue;
// Candidate geometry is not visibility proof. Native perception below must
// separately confirm the real target after every arranged placement.
TraceParam trace = new TraceParam(); trace.Start = origin + "0 0.65 0"; trace.End = point + "0 1.2 0";
trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS; trace.Exclude = unit.StatusSampler.InitialMember(0);
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
// Start only where a low-eye continuation is geometrically possible. The
// subsequent retreat still requires a real original native visual witness.
vector continuation = ERR_Actors.Ground(origin + Vector(Math.Cos(bearing) * 50, 0, Math.Sin(bearing) * 50));
if (continuation[1] < 1 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), continuation)) continue;
trace.End = continuation + "0 1.4 0";
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
chosen = point; return true;
}
return false;
}
protected bool RetreatArea(ERR_HQUnit unit, float now, out vector chosen)
{
array<float> ranges = {80, 70, 60, 50, 45};
array<float> angles = {0, -5, 5, -10, 10, -20, 20, -30, 30, -45, 45};
// At most two original observers and55 detached geometric candidates each.
// No geometric success substitutes for actual native reacquisition afterward.
for (int member = 0; member < unit.StatusSampler.InitialStrength() && member < 2; member++)
{
IEntity actor = unit.StatusSampler.InitialMember(member);
if (!actor || !unit.StatusSampler.CanAttribute(member, actor, false)) continue;
IEntity observer = unit.Utility.ERR_FixtureVisualObserver(m_Player, now, actor, m_ArrangementAt + 100);
if (!observer || vector.DistanceXZ(observer.GetOrigin(), m_Player.GetOrigin()) > 20) continue;
vector origin = observer.GetOrigin();
vector away = m_Player.GetOrigin() - origin;
float facing = Math.Atan2(away[2], away[0]);
foreach (float radius : ranges)
{
foreach (float angle : angles)
{
float bearing = facing + angle * Math.DEG2RAD;
vector point = ERR_Actors.Ground(origin + Vector(Math.Cos(bearing) * radius, 0, Math.Sin(bearing) * radius));
if (point[1] < 1 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) continue;
TraceParam trace = new TraceParam(); trace.Start = origin + "0 0.65 0"; trace.End = point + "0 1.4 0";
trace.Flags = TraceFlags.WORLD | TraceFlags.ENTS;
array<IEntity> excluded = {observer, m_Player}; trace.ExcludeArray = excluded;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) continue;
m_FiringWitness = observer; chosen = point; return true;
}
}
}
return false;
}
protected void MoveFailed(int result, IEntity vehicleUsed, bool isWaypointRelated, vector destination)
{
if (m_Step != 5 || vehicleUsed || !isWaypointRelated || vector.DistanceXZ(destination, m_Unreachable) > 50) return;
m_NativeMoveFailures++;
PrintFormat("ERR COORDINATED NATIVE MOVE FAILURE: result=%1 destination=%2 callbacks=%3", result, destination, m_NativeMoveFailures);
}
protected bool BeginBlocked(float now)
{
m_Executions[0].Stop(now); m_Units[0].ReleaseAssignment();
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
m_Unreachable = Vector(low[0] - 250, 0, low[2] - 250);
m_Units[0].Utility.GetOnMoveFailed().Insert(MoveFailed);
// A surviving authored route can still make real progress toward the same
// broad direction. Allow the unmodified task's 300s expiry as the final bound.
m_Step = 5; m_Deadline = now + 310000;
return Check(Order(0, m_Unreachable, ERR_HQOperationRole.BLOCK, now), "explicit unreachable waypoint queued to test native failure handling");
}
protected void Diagnostic(float now)
{
if (now < m_NextDiagnostic) return;
m_NextDiagnostic = now + 10000;
for (int i = 0; i < m_Units.Count(); i++)
{
ERR_HQExecutionReport report = Report(i, now);
if (!report) continue;
PrintFormat("ERR COORDINATED FIXTURE STATUS: stage=%1 unit=%2 state=%3 distance=%4 contact=%5 support=%6 shots=%7 physical=%8", m_Step, report.SourceId, report.State, report.DistanceRemaining, report.HasContact, report.SupportReady, m_Executions[i].ERR_FixtureActualSuppressShots(), report.Position);
PrintFormat("ERR COORDINATED FIXTURE LOD: unit=%1 group=%2 active=%3", report.SourceId, m_Units[i].Group.GetLOD(), m_Units[i].Group.IsAIActivated());
if (m_Step >= 2) m_Executions[i].ERR_FixtureSuppressDiagnostic(now);
if (m_Step == 2 && i == 0)
{
m_Units[i].Utility.ERR_FixturePerceptionDiagnostic();
Faction prisonerFaction = SCR_FactionManager.SGetFaction(m_Player);
bool enemyFaction = prisonerFaction && m_Units[i].Group.GetFaction().IsFactionEnemy(prisonerFaction);
PrintFormat("ERR COORDINATED TARGET: actual=%1 enemyFaction=%2 range=%3", m_Player.GetOrigin(), enemyFaction, vector.DistanceXZ(m_Player.GetOrigin(), report.Position));
}
}
}
int Tick(float now)
{
if (m_Stopped) return -1;
if (now > m_Deadline) { Check(false, "bounded stage deadline " + m_Step.ToString()); return -1; }
if (m_Step == 0)
{
ERR_HQ hq = ERR_HQ.Get(); if (!hq || !hq.ERR_CoordinatedFixtureUnits(m_Units)) return 0;
m_Origins.Clear(); m_Destinations.Clear();
foreach (ERR_HQUnit initial : m_Units)
{
vector origin = initial.StatusSampler.InitialMember(0).GetOrigin(), destination;
if (!Destination(initial, origin, 90, destination)) { m_Units.Clear(); return 0; }
m_Origins.Insert(origin); m_Destinations.Insert(destination);
}
if (!Check(m_Units.Count() == 2 && m_Units[0].Group != m_Units[1].Group, "two original registered infantry squads selected; no soldier spawn or teleport")) return -1;
for (int n = 0; n < 2; n++) if (!Check(Order(n, m_Destinations[n], ERR_HQOperationRole.OBSERVE, now), "native waypoint and production execution received")) return -1;
m_Step = 1; m_Deadline = now + 180000; return 0;
}
for (int index = 0; index < m_Executions.Count(); index++) m_Executions[index].Update(m_Units[index], now);
if (m_Step == 2 && m_FixOrdered) m_Executions[0].ERR_FixtureSuppressChanges(now);
Diagnostic(now);
if (m_Step == 1)
{
bool ready = true;
for (int j = 0; j < 2; j++)
{
ERR_HQExecutionReport observed = Report(j, now);
if (!observed || (observed.State != ERR_HQExecutionState.ARRIVED && observed.State != ERR_HQExecutionState.ACTING && observed.State != ERR_HQExecutionState.COMPLETED)) { ready = false; continue; }
if (vector.DistanceXZ(m_Origins[j], observed.Position) < 45) { Check(false, "arrival requires real movement from original position"); return -1; }
}
if (!ready) return 0;
if (!m_ArrivalProved)
{
if (!Check(true, "both native squads moved over45m and production execution recorded physical arrival")) return -1;
m_ArrivalProved = true;
m_SettlePosition = m_Units[0].StatusSampler.InitialMember(0).GetOrigin(); m_SettleSince = now;
}
if (m_BlockedOnly)
{
m_Executions[1].Stop(now); ERR_HQExecutionReport cancelled = Report(1, now);
if (!Check(cancelled && cancelled.State == ERR_HQExecutionState.SUPERSEDED, "cancellation produces terminal execution report")) return -1;
m_Units[1].ReleaseAssignment();
if (!BeginBlocked(now)) return -1;
return 0;
}
// Physical arrival allows a 40m quorum, while the native hold can still be
// travelling. Arrange the encounter only after its lead actor has stayed
// within a 2m patch for ten seconds; never freeze or teleport the infantry.
vector settled = m_Units[0].StatusSampler.InitialMember(0).GetOrigin();
if (vector.DistanceXZ(settled, m_SettlePosition) > 2) { m_SettlePosition = settled; m_SettleSince = now; }
if (now - m_SettleSince < 10000) return 0;
if (!Check(true, "native hold actor physically settled before arranged target placement")) return -1;
vector target;
if (!Check(FiringArea(m_Units[0], target, 8), "unobstructed arranged close native sighting area found")) return -1;
PlacePrisoner(target); m_ArrangementAt = now;
PrintFormat("ERR COORDINATED TARGET ARRANGEMENT: target=%1 actualAfterMove=%2", target, m_Player.GetOrigin());
m_AmmoBefore = Rounds(m_Units[0]);
if (!Check(m_AmmoBefore > m_Units[0].StatusSampler.InitialStrength() * 20, "actual existing rifle ammunition exceeds protected reserve")) return -1;
m_Step = 2; m_Deadline = now + 120000; m_ApproachArrangements = 1; m_NextApproach = now + 15000; return 0;
}
if (m_Step == 2)
{
if (!m_FixOrdered)
{
// Bounded fixture-only repair for native Defend looking away or changing
// stance. Move only the canonical prisoner; never seed a target or move AI.
if (!m_ApproachSeen && m_ApproachArrangements < 3 && now >= m_NextApproach)
{
vector approach;
m_ApproachArrangements++; m_NextApproach = now + 15000;
if (FiringArea(m_Units[0], approach, 8))
{
PlacePrisoner(approach); m_ArrangementAt = now;
PrintFormat("ERR COORDINATED APPROACH RETRY: arrangement=%1 target=%2 originalDeadline=%3; actual native sighting still required", m_ApproachArrangements, approach, m_Deadline);
}
}
array<ref ERR_HQVisualContact> visible = {}; ERR_HQObservation.Gather(m_Units[0].Utility, now, visible);
foreach (ERR_HQVisualContact seen : visible)
{
if (seen.Entity != m_Player || seen.ObservedAt < m_ArrangementAt + 100
|| vector.DistanceXZ(seen.Position, m_Player.GetOrigin()) > 5) continue;
if (!m_ApproachSeen)
{
vector distant;
if (!Check(RetreatArea(m_Units[0], now, distant), "genuine original observer found; clear retreat extends its actual sight line")) return -1;
PlacePrisoner(distant); m_ArrangementAt = now; m_ApproachSeen = true;
PrintFormat("ERR COORDINATED TARGET ARRANGEMENT: observed close approach followed by arranged target retreat=%1; native perception must reacquire", distant);
return 0;
}
if (!m_FiringWitness || vector.DistanceXZ(seen.Position, m_Player.GetOrigin()) > 5 || vector.DistanceXZ(seen.Position, m_FiringWitness.GetOrigin()) < 40) continue;
if (!Check(Order(0, m_FiringWitness.GetOrigin(), ERR_HQOperationRole.FIX, now, 0, seen.Position), "fix order follows genuine native visual acquisition of the arranged target")) return -1;
m_FixOrdered = true; m_Deadline = now + 90000; break;
}
return 0;
}
ERR_HQExecutionReport support = Report(0, now);
if (!support || !support.SupportReady) return 0;
m_AmmoAfter = Rounds(m_Units[0]);
if (!Check(support.HasContact && m_Executions[0].ERR_FixtureActualSuppressShots() > 0 && m_AmmoAfter >= 0 && m_AmmoAfter < m_AmmoBefore,
"native visual contact, owned suppression activity, real projectile callbacks and finite ammo decrease prove support")) return -1;
vector flankOrigin = m_Units[1].StatusSampler.InitialMember(0).GetOrigin(), bound;
if (!Check(Destination(m_Units[1], flankOrigin, 90, bound), "deliberately overlong90m bound lies on native navigation for lease-expiry test")) return -1;
m_SupportDeadline = support.SupportUntil;
if (!Check(Order(1, bound, ERR_HQOperationRole.FLANK, now, m_SupportDeadline, support.ContactPosition), "overlong expiry-test bound copies actual support lease without refreshing it")) return -1;
m_Step = 3; m_Deadline = now + 30000; return 0;
}
if (m_Step == 3)
{
if (now <= m_SupportDeadline + 2500) return 0;
ERR_HQExecutionReport flank = Report(1, now); array<AIWaypoint> queue = {}; m_Units[1].Group.GetWaypoints(queue);
if (!Check(flank && flank.State == ERR_HQExecutionState.BLOCKED && !queue.Contains(m_Units[1].Waypoint), "expired delivered support stops bound by removing its native waypoint")) return -1;
m_Executions[1].Stop(now); ERR_HQExecutionReport stopped = Report(1, now);
if (!Check(stopped && stopped.State == ERR_HQExecutionState.SUPERSEDED, "cancellation produces terminal execution report")) return -1;
m_Units[1].ReleaseAssignment();
PlacePrisoner(m_PlayerOrigin);
m_Step = 4; m_Deadline = now + 15000; return 0;
}
if (m_Step == 4)
{
ERR_HQExecutionReport lost = Report(0, now);
if (!lost || lost.SupportReady) return 0;
if (lost.HasContact && now <= m_SupportDeadline + 10000) return 0;
if (!Check(true, "support validity expires after target removal; no permanent ready flag")) return -1;
if (!BeginBlocked(now)) return -1;
return 0;
}
if (m_Step == 5)
{
ERR_HQExecutionReport blocked = Report(0, now);
if (!blocked) return 0;
if (blocked.State == ERR_HQExecutionState.ARRIVED || blocked.State == ERR_HQExecutionState.ACTING || blocked.State == ERR_HQExecutionState.COMPLETED)
{ Check(false, "unreachable destination never becomes arrival or searched evidence"); return -1; }
if (blocked.State != ERR_HQExecutionState.FAILED) return 0;
if (!Check(m_NativeMoveFailures > 0 && blocked.SearchedRadius == 0, "native movement failure reaches bounded execution failure without negative-search evidence")) return -1;
PrintFormat("ERR COORDINATED TERMINAL FAILURE: detail=%1 distance=%2 searchedRadius=%3 nativeCallbacks=%4", blocked.Detail, blocked.DistanceRemaining, blocked.SearchedRadius, m_NativeMoveFailures);
return 1;
}
return 0;
}
void Stop()
{
if (m_Stopped) return; m_Stopped = true;
float now = GetGame().GetWorld().GetWorldTime();
for (int slot = 0; slot < m_Executions.Count(); slot++)
{
m_Executions[slot].Stop(now);
if (m_Units[slot] && m_Units[slot].Group) m_Units[slot].ReleaseAssignment();
}
if (!m_Units.IsEmpty() && m_Units[0].Utility) m_Units[0].Utility.GetOnMoveFailed().Remove(MoveFailed);
if (m_Player) PlacePrisoner(m_PlayerOrigin);
if (m_PlayerDamage) m_PlayerDamage.EnableDamageHandling(m_PlayerDamageEnabled);
}
}
modded class ERR_GameMode
{
protected ref ERR_NativeCoordinatedAIProbe m_ERRCoordinatedProbe;
protected float m_ERRCoordinatedDeadline;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string enabled, outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1"
|| !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "coordinated-ai") return;
m_ERRCoordinatedDeadline = System.GetTickCount() + 720000;
OnStop().Insert(ERR_StopCoordinatedProbe);
GetGame().GetCallqueue().CallLater(ERR_CoordinatedProbeTick, 500, true);
}
protected void ERR_StopCoordinatedProbe()
{
GetGame().GetCallqueue().Remove(ERR_CoordinatedProbeTick);
if (m_ERRCoordinatedProbe) m_ERRCoordinatedProbe.Stop();
}
protected void ERR_CoordinatedProbeTick()
{
if (System.GetTickCount() >= m_ERRCoordinatedDeadline)
{ Print("ERR SMOKE FAIL: coordinated AI fixture startup or overall deadline", LogLevel.ERROR); ERR_StopCoordinatedProbe(); return; }
if (Phase() != ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
if (!m_ERRCoordinatedProbe) m_ERRCoordinatedProbe = new ERR_NativeCoordinatedAIProbe(this, player);
int result = m_ERRCoordinatedProbe.Tick(GetWorld().GetWorldTime());
if (result == 0) return;
ERR_StopCoordinatedProbe();
if (result < 0) return;
Transition(ERR_Phase.DEFEAT);
if (m_ERRCoordinatedProbe.BlockedOnly())
Print("ERR SMOKE COMPLETE: coordinated native movement, physical arrival, cancellation and native blocked outcome only; suppression, multiplayer and HQ radio not qualified by this explicit variant");
else
Print("ERR SMOKE COMPLETE: coordinated native movement, physical arrival, projectile-proved support, support expiry and native blocked outcome; arranged orders, not multiplayer or HQ radio qualification");
}
}
#endif
@@ -1,4 +1,21 @@
#ifdef WORKBENCH
class ERR_ProbeAutomaticGrant : ERR_AutomaticEquipmentGrant
{
ResourceName Prefab;
bool Weapon;
override protected ERR_AutomaticEquipmentItem Choose(ERR_GameMode mode)
{
ERR_AutomaticEquipmentItem choice = new ERR_AutomaticEquipmentItem(); choice.Prefab = Prefab; choice.Weapon = Weapon; return choice;
}
int MagazinesRequested() { return m_Magazines; }
bool Carried(IEntity actor)
{
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
return m_Issued && inventory && inventory.Contains(m_Issued);
}
bool AmmunitionComplete() { return m_Magazines == 2 && m_Operation && m_Operation.Finished && m_Operation.Succeeded; }
}
// Real native inventory fixture; deliberately not registered with the ordinary equipment service.
class ERR_ProbeBrokenLauncherGrant : ERR_EnemyLauncherGrant
{
@@ -9,6 +26,8 @@ class ERR_ProbeBrokenLauncherGrant : ERR_EnemyLauncherGrant
}
modded class ERR_GameMode
{
protected ref ERR_ProbeAutomaticGrant m_ERRAutoGun = new ERR_ProbeAutomaticGrant();
protected ref ERR_ProbeAutomaticGrant m_ERRAutoItem = new ERR_ProbeAutomaticGrant();
protected int m_ERRLoadoutDeadline;
protected int m_ERRLoadoutStage;
protected SCR_AIGroup m_ERRLoadoutGroup;
@@ -49,12 +68,28 @@ modded class ERR_GameMode
m_ERRLoadoutGroup.m_aUnitPrefabSlots = {
"{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et",
"{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et"};
m_ERRLoadoutGroup.RequestSpawn(2); m_ERRLoadoutStage = 1; return;
m_ERRLoadoutGroup.m_aUnitPrefabSlots.Insert("{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et");
m_ERRLoadoutGroup.m_aUnitPrefabSlots.Insert("{5436629450D8387A}Prefabs/Characters/Factions/OPFOR/USSR_Army/Character_USSR_SL.et");
if (!ERR_LoadoutCheck(Settings().Set("automaticEquipmentChancePercent", 100) && ERR_AutomaticEquipmentCatalog.Count(this) > 0, "automatic equipment catalog and setting")) return;
// Choose a real discovered mod rifle, not a hardcoded Escape integration entry.
for (int candidate = 0; candidate < 512; candidate++)
{
ERR_AutomaticEquipmentItem discovered = ERR_AutomaticEquipmentCatalog.Pick(this);
if (!discovered || !discovered.Weapon || (!discovered.Prefab.Contains("/AK74M/") && !discovered.Prefab.Contains("/M4A1/"))) continue;
m_ERRAutoGun.Prefab = discovered.Prefab; m_ERRAutoGun.Weapon = true; break;
}
if (!ERR_LoadoutCheck(!m_ERRAutoGun.Prefab.IsEmpty(), "discovered mod rifle=" + m_ERRAutoGun.Prefab)) return;
m_ERRAutoItem.Prefab = ERR_EnemyMedicalCatalog.MORPHINE;
for (int sample = 0; sample < 12; sample++) Print("ERR AUTO CATALOG SAMPLE: " + ERR_AutomaticEquipmentCatalog.Pick(this).Prefab);
m_ERRLoadoutGroup.RequestSpawn(4); m_ERRLoadoutStage = 1; return;
}
array<AIAgent> agents = {}; m_ERRLoadoutGroup.GetAgents(agents);
if (agents.Count() != 2 || !agents[0].GetControlledEntity() || !agents[1].GetControlledEntity()) return;
if (agents.Count() != 4) return;
foreach (AIAgent ready : agents) if (!ready.GetControlledEntity()) return;
IEntity first = agents[0].GetControlledEntity(); IEntity second = agents[1].GetControlledEntity();
foreach (AIAgent agent : agents) agent.DeactivateAI();
m_ERRAutoGun.Check(this, m_ERRLoadoutGroup, agents[2].GetControlledEntity());
m_ERRAutoItem.Check(this, m_ERRLoadoutGroup, agents[3].GetControlledEntity());
if (m_ERRLoadoutStage == 1)
{
m_ERRNativeAA.Check(this, m_ERRLoadoutGroup, first);
@@ -77,6 +112,8 @@ modded class ERR_GameMode
Print("ERR ADMIN CAPTURE PAGE: 1"); m_ERRLoadoutStage = 3; return;
}
if (m_ERRLoadoutStage++ < 8) return;
if (!m_ERRAutoGun.Done() || !m_ERRAutoItem.Done()) return;
if (!ERR_LoadoutCheck(m_ERRAutoGun.Carried(agents[2].GetControlledEntity()) && m_ERRAutoGun.AmmunitionComplete() && m_ERRAutoItem.Carried(agents[3].GetControlledEntity()), "automatic catalog item and spare-slot rifle still carried; both matching magazine insertions completed")) return;
System.MakeScreenshot("$logs:err-enemy-loadout-menu");
GetGame().GetCallqueue().Remove(ERR_LoadoutTick);
Print("ERR SMOKE COMPLETE: enemy-loadout physical preferred/fallback launchers, AP mines and HQ menu");
@@ -62,6 +62,31 @@ class ERR_NativeEnvironmentProbe
m_OwnsSettings = false;
}
}
protected bool ViewDistanceChecks()
{
if (!Check(m_TestSettings.Get("viewDistanceMetres") == 0, "legacy file defaults view distance without an override")) return false;
if (!Check(m_TestSettings.Set("viewDistanceMetres", 3000), "view distance saves in isolated settings")) return false;
ERR_Settings reloaded = new ERR_Settings("$profile:erri-native-environment-probe.json");
if (!Check(reloaded.Get("viewDistanceMetres") == 3000 && !m_TestSettings.Set("viewDistanceMetres", 499) && !m_TestSettings.Set("viewDistanceMetres", 10001), "view distance reload and invalid bounds preserve saved value")) return false;
array<string> replies = {};
int previous = m_Mode.Settings().Get("viewDistanceMetres");
m_Mode.Admin().Execute(m_Mode, false, "mission viewdistance set 4000", replies);
if (!Check(m_Mode.Settings().Get("viewDistanceMetres") == previous && replies.Count() == 1 && replies[0].Contains("Administrator"), "view distance requires server-authorized admin")) return false;
float original = GetGame().GetViewDistance();
ERR_ViewDistance local = new ERR_ViewDistance();
local.Apply(0);
if (!Check(GetGame().GetViewDistance() == original, "default leaves native camera distance unchanged")) return false;
float expected = ERR_ViewDistance.LocalDistance(3000, GetGame().GetMinimumViewDistance(), GetGame().GetMaximumViewDistance(), GetGame().GetViewDistanceServerLimit());
local.Apply(3000);
float actual = GetGame().GetViewDistance();
local.Apply(0);
bool restored = Math.AbsFloat(GetGame().GetViewDistance() - original) <= 0.5;
PrintFormat("ERR VIEW DISTANCE: original=%1 requested=3000 expected=%2 actual=%3 restored=%4", original, expected, actual, restored);
if (!Check(Math.AbsFloat(actual - expected) <= 0.5 && restored, "native camera applies bounded mission distance and restores original")) return false;
local.Apply(10000);
local.Restore();
return Check(Math.AbsFloat(GetGame().GetViewDistance() - original) <= 0.5, "teardown releases temporary camera distance");
}
protected bool SettingsChecks()
{
string path = "$profile:erri-native-environment-probe.json";
@@ -70,7 +95,7 @@ class ERR_NativeEnvironmentProbe
JsonSaveContext json = new JsonSaveContext();
json.EnableTypeDiscriminator(false);
foreach (string key : ERR_Settings.Keys())
if (key != "startDateCode" && key != "weatherProfile") json.WriteValue(key, m_Mode.Settings().Get(key));
if (key != "startDateCode" && key != "weatherProfile" && key != "viewDistanceMetres") json.WriteValue(key, m_Mode.Settings().Get(key));
if (!Check(json.SaveToFile(path), "owned legacy settings fixture written")) return false;
m_TestSettings = new ERR_Settings(path);
JsonLoadContext unchanged = new JsonLoadContext();
@@ -112,7 +137,7 @@ class ERR_NativeEnvironmentProbe
m_Clock = m_Manager.GetTimeOfTheDay(); m_DayDuration = m_Manager.GetDayDuration(); m_DayAdvances = m_Manager.GetIsDayAutoAdvanced();
m_Snapshot = true;
m_Deadline = now + 15000;
if (!SettingsChecks()) return -1;
if (!SettingsChecks() || !ViewDistanceChecks()) return -1;
if (!Check(CurrentWeather() == m_Weather && m_Manager.GetYear() == m_Year && m_Manager.GetMonth() == m_Month && m_Manager.GetDay() == m_Day, "saving fixture environment settings does not mutate native world")) return -1;
for (int profile = 1; profile <= 3; profile++)
if (!Check(ERR_EnvironmentService.HasWeatherState(m_Manager, ERR_EnvironmentService.WeatherStateName(profile)), "selected vanilla weather family exists: " + ERR_EnvironmentService.ProfileName(profile))) return -1;
@@ -64,12 +64,14 @@ modded class ERR_GameMode
if (!controller || !controller.GetControlledEntity()) return;
if (m_ERRGeneratedStage == 0)
{
if (!ERR_GeneratedCheck(ERR_HQ.Get() && ERR_HQ.Get().Roadblocks().ERR_ProbePreparedRoadblocks(), "native roadblocks: four guards per post, finite ledger and available HQ squads")) return;
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());
PrintFormat("ERR SMOKE DETAIL: generated prison local=%1 heading=%2 gateRegistered=%3 player=%4 prison=%5", local, PrisonHeading(), PrisonEscape().Registered(), m_ERRGeneratedPlayer.GetOrigin(), PrisonPosition());
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);
@@ -0,0 +1,166 @@
#ifdef WORKBENCH
// Read-only fixture access to native UI identities. No replacement widgets.
modded class SCR_InventoryMenuUI
{
bool ERR_HitZoneProbeReady(IEntity actor)
{
return ERR_NativeInventoryHitZoneProbe.Enabled() && m_Player == actor && m_pStorageListUI
&& m_pStorageListUI.GetStorage() && m_PlayerRenderAttributes && m_wAttachmentPointsContainer;
}
bool ERR_HitZoneProbePoints(out array<ref SCR_InventoryHitZonePointContainerUI> points)
{
points.Clear();
if (!ERR_NativeInventoryHitZoneProbe.Enabled() || m_aHitZonePoints.Count() != 7) return false;
foreach (SCR_InventoryHitZonePointContainerUI point : m_aHitZonePoints) points.Insert(point);
return true;
}
}
modded class SCR_InventoryHitZonePointContainerUI
{
protected SCR_HitZone m_ERRProbeBlood;
protected int m_ERRProbeUpdates;
protected bool m_ERRProbeFalseRefresh;
protected override void UpdateHitZoneState(SCR_HitZone hz, bool updateAttachmentStorage = true)
{
super.UpdateHitZoneState(hz, updateAttachmentStorage);
if (m_ERRProbeBlood && hz == m_ERRProbeBlood)
{
m_ERRProbeUpdates++;
if (!updateAttachmentStorage) m_ERRProbeFalseRefresh = true;
}
}
bool ERR_HitZoneProbeWatch(ERR_GameMode mode, SCR_HitZone blood)
{
if (!ERR_NativeInventoryHitZoneProbe.Enabled() || !mode || mode != m_ERRHitZoneMode
|| !blood || !m_ERRHitZoneSources.Contains(blood)) return false;
m_ERRProbeBlood = blood; m_ERRProbeUpdates = 0; m_ERRProbeFalseRefresh = false;
return true;
}
bool ERR_HitZoneProbeOnce() { return m_ERRProbeUpdates == 1 && !m_ERRProbeFalseRefresh; }
bool ERR_HitZoneProbeDetached() { return !m_ERRHitZoneMode && m_ERRHitZoneSources.IsEmpty(); }
void ERR_HitZoneProbeStop() { m_ERRProbeBlood = null; }
}
// Emits one real one-argument damage-state notification without changing health.
// It proves callback/native UI integration, not a natural injury or manual UI use.
class ERR_NativeInventoryHitZoneProbe
{
protected ERR_GameMode m_Mode;
protected SCR_PlayerController m_Controller;
protected IEntity m_Actor;
protected SCR_InventoryStorageManagerComponent m_Inventory;
protected SCR_CharacterBloodHitZone m_Blood;
protected SCR_InventoryMenuUI m_Menu;
protected ref array<ref SCR_InventoryHitZonePointContainerUI> m_Points = {};
protected int m_Deadline;
protected int m_Stage;
protected int m_Result;
protected bool m_Stopped;
protected bool m_CloseRequested;
protected float m_Health;
static bool Enabled()
{
string enabled, outcome;
return Replication.IsServer() && System.GetCLIParam("errBaseSmoke", enabled) && enabled == "1"
&& System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "inventory-hitzone";
}
void ERR_NativeInventoryHitZoneProbe(ERR_GameMode mode, IEntity actor)
{
m_Mode = mode; m_Actor = actor; m_Deadline = System.GetTickCount() + 30000;
m_Controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
}
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: inventory hitzone " + detail); return true; }
Print("ERR SMOKE FAIL: inventory hitzone " + detail, LogLevel.ERROR);
m_Result = -1; Stop(); return false;
}
protected bool Context()
{
return Enabled() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active()
&& m_Controller && m_Controller == GetGame().GetPlayerController() && m_Controller.GetControlledEntity() == m_Actor
&& m_Mode.Players() && m_Mode.Players().CurrentActor(m_Actor) && ERR_Actors.CanFight(m_Actor);
}
protected bool OwnsMenu()
{
return m_Menu && GetGame().GetMenuManager().FindMenuByPreset(ChimeraMenuPreset.Inventory20Menu) == m_Menu;
}
protected bool UnchangedHealth() { return m_Blood && m_Blood.GetHealth() == m_Health; }
int Tick(float now)
{
if (m_Result != 0) return m_Result;
if (m_Stopped) return -1;
if (!Context()) { Check(false, "original conscious local round body changed"); return -1; }
if (System.GetTickCount() >= m_Deadline) { Check(false, "fixed 30-second UI/callback deadline"); return -1; }
if (m_Stage == 0)
{
if (!Check(!GetGame().GetMenuManager().GetTopMenu(), "no existing menu adopted")) return -1;
m_Inventory = SCR_InventoryStorageManagerComponent.Cast(m_Actor.FindComponent(SCR_InventoryStorageManagerComponent));
SCR_CharacterDamageManagerComponent damage = SCR_CharacterDamageManagerComponent.Cast(m_Actor.FindComponent(SCR_CharacterDamageManagerComponent));
if (damage) m_Blood = damage.GetBloodHitZone();
if (!Check(m_Inventory && m_Blood, "original native inventory and blood event source present")) return -1;
m_Health = m_Blood.GetHealth();
m_Inventory.OpenInventory();
m_Menu = SCR_InventoryMenuUI.Cast(GetGame().GetMenuManager().FindMenuByPreset(ChimeraMenuPreset.Inventory20Menu));
if (!Check(OwnsMenu(), "ordinary native inventory opens without input automation")) return -1;
m_Stage = 1; return 0;
}
if (m_Stage == 1)
{
if (!Check(OwnsMenu() && UnchangedHealth(), "owned inventory and original blood health retained")) return -1;
if (!m_Menu.ERR_HitZoneProbeReady(m_Actor)) return 0;
m_Menu.InspectItem(null); // Native character inspection creates its normal seven containers over several frames.
m_Stage = 2; return 0;
}
if (m_Stage == 2)
{
if (!Check(OwnsMenu() && UnchangedHealth(), "native inspection retains original menu and health")) return -1;
if (!m_Menu.ERR_HitZoneProbePoints(m_Points)) return 0;
array<SCR_InventoryHitZonePointContainerUI> seen = {};
foreach (SCR_InventoryHitZonePointContainerUI point : m_Points)
{
if (!Check(point && !seen.Contains(point) && point.ERR_HitZoneProbeWatch(m_Mode, m_Blood), "unique native point retains original blood subscription")) return -1;
seen.Insert(point);
}
if (!Check(UnchangedHealth() && m_Blood.GetOnDamageStateChanged(false), "original blood event and health retained before notification")) return -1;
m_Blood.GetOnDamageStateChanged(false).Invoke(m_Blood);
foreach (SCR_InventoryHitZonePointContainerUI updated : m_Points)
if (!Check(updated.ERR_HitZoneProbeOnce(), "one-argument event reached native updater once with explicit true")) return -1;
if (!Check(UnchangedHealth() && OwnsMenu(), "notification changes no blood health or menu ownership")) return -1;
bool closeAccepted = GetGame().GetMenuManager().CloseMenu(m_Menu);
m_CloseRequested = closeAccepted;
if (!Check(closeAccepted, "exact owned inventory close request accepted")) return -1;
m_Stage = 3; return 0;
}
if (m_Stage == 3)
{
MenuBase current = GetGame().GetMenuManager().FindMenuByPreset(ChimeraMenuPreset.Inventory20Menu);
if (current == m_Menu && m_Menu) return 0;
if (!Check(!current, "native inventory closes without adopting a replacement")) return -1;
foreach (SCR_InventoryHitZonePointContainerUI detached : m_Points)
if (!Check(detached && detached.ERR_HitZoneProbeDetached(), "native detach cleared original point subscriptions")) return -1;
if (!Check(UnchangedHealth() && m_Blood.GetOnDamageStateChanged(false), "original blood event and health retained before notification")) return -1;
m_Blood.GetOnDamageStateChanged(false).Invoke(m_Blood);
foreach (SCR_InventoryHitZonePointContainerUI oldPoint : m_Points)
if (!Check(oldPoint.ERR_HitZoneProbeOnce(), "closed original point receives no late native update")) return -1;
if (!Check(UnchangedHealth(), "blood health unchanged through complete callback proof")) return -1;
m_Menu = null; m_Result = 1; Stop(); return 1;
}
Check(false, "unexpected fixture stage"); return -1;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
foreach (SCR_InventoryHitZonePointContainerUI point : m_Points) if (point) point.ERR_HitZoneProbeStop();
m_Points.Clear();
if (GetGame() && GetGame().GetMenuManager() && !m_CloseRequested && OwnsMenu())
{
m_CloseRequested = true;
GetGame().GetMenuManager().CloseMenu(m_Menu);
}
m_Menu = null; m_Blood = null; m_Inventory = null;
}
}
#endif
@@ -111,19 +111,20 @@ modded class ERR_GameMode
{ ERR_MedicalCombatCheck(false, "actual hunter UAZ has full special-forces crew and medic"); return; }
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(medic.FindComponent(InventoryStorageManagerComponent));
array<IEntity> items = {}; inventory.GetItems(items);
int epi, morphine, saline, dressings; bool bag;
int epi, standardEpi, morphine, saline, dressings; bool bag;
foreach (IEntity item : items)
{
if (!item || !item.GetPrefabData()) continue;
string prefab = item.GetPrefabData().GetPrefabName();
if (prefab == ERR_EnemyMedicalCatalog.EPI) epi++;
if (prefab == ERR_EnemyMedicalCatalog.EPI) standardEpi++;
if (prefab == ERR_EnemyMedicalCatalog.COMBAT_EPI) epi++;
if (prefab == ERR_EnemyMedicalCatalog.MORPHINE) morphine++;
if (prefab.Contains("SalineBag")) saline++;
if (prefab.Contains("FieldDressing")) dressings++;
if (prefab.Contains("Backpack_Medical") || (m_ERRHunterFactionFixture && prefab.Contains("Backpack_"))) bag = true;
}
if (epi < 6 || morphine < 6) return;
if (!ERR_MedicalCombatCheck(bag && saline >= 4 && dressings >= 4, "medic carries native medical bag, at least four saline/bandages, six morphine and six epinephrine")) return;
if (epi < 6 || standardEpi < 2 || morphine < 6) return;
if (!ERR_MedicalCombatCheck(bag && saline >= 4 && dressings >= 4, "medic carries native medical bag, at least four saline/bandages, six morphine, six combat epinephrine and two standard epinephrine")) return;
if (m_ERRHunterFactionFixture)
{
if (!ERR_MedicalCombatCheck(hunter.Group.GetFaction() == EnemyFaction() && EnemyFaction().GetFactionKey() == "RHS_ION", "response crew belongs specifically to RHS ION")) return;
@@ -15,6 +15,7 @@ class ERR_NativeMedicalProbe
protected SCR_CharacterDamageManagerComponent m_Damage;
protected CharacterControllerComponent m_Controller;
protected SCR_CharacterBloodHitZone m_Blood;
protected SCR_CharacterHitZone m_Wound;
protected SCR_SalineStorageComponent m_SalineStorage;
protected SCR_InventoryStorageManagerComponent m_Inventory;
protected ref array<IEntity> m_Owned = {};
@@ -67,6 +68,7 @@ class ERR_NativeMedicalProbe
{
if (!m_Blood || !m_Controller || !m_Damage) return;
PrintFormat("ERR MEDICAL STATE: stage=%1 blood=%2 bloodState=%3 unconscious=%4 bleeding=%5 salineEffects=%6 attachedBags=%7", stage, m_Blood.GetHealth(), m_Blood.GetDamageState(), m_Controller.IsUnconscious(), m_Damage.IsBleeding(), SalineEffects(), AttachedBags());
PrintFormat("ERR MEDICAL ACTORS: casualtyAlive=%1 healerAlive=%2 bodyHealth=%3", ERR_Actors.Alive(m_Casualty), ERR_Actors.Alive(m_Healer), m_Damage.GetDefaultHitZone().GetHealthScaled());
}
void Stop()
{
@@ -83,6 +85,7 @@ class ERR_NativeMedicalProbe
m_Damage = null;
m_Controller = null;
m_Blood = null;
m_Wound = null;
m_SalineStorage = null;
m_Inventory = null;
}
@@ -103,8 +106,8 @@ class ERR_NativeMedicalProbe
if (now < m_Wait) return 0;
if (m_Step == 0)
{
// Keep fixture actors separate from the real prisoner spawn.
vector anchor = m_Mode.PrisonPosition() + "100 0 100";
// Treatment timing needs isolation from the response teams around prison.
vector anchor = m_Mode.PrisonPosition() + "-2000 0 -2000";
m_Casualty = Spawn(m_Mode.Factions().PrisonerPrefab, anchor);
m_Healer = Spawn(m_Mode.Factions().PrisonerPrefab, anchor + "3 0 0");
m_Dressing = Spawn("{C3F1FA1E2EC2B345}Prefabs/Items/Medicine/FieldDressing_01/FieldDressing_USSR_01.et", anchor + "4 0 0");
@@ -112,6 +115,7 @@ class ERR_NativeMedicalProbe
m_SpareSaline = Spawn("{527D7C5D2E476BDC}Prefabs/Items/Medicine/SalineBag_01/SalineBag_USSR_01.et", anchor + "6 0 0");
if (!Check(m_Casualty && m_Healer && m_Dressing && m_Saline && m_SpareSaline, "two isolated characters and finite stock-type medicine spawned")) return -1;
if (!Check(!ERR_Actors.Player(m_Casualty) && !ERR_Actors.Player(m_Healer), "neither medical actor is a real player")) return -1;
DamageManagerComponent.Cast(m_Healer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
m_Deadline = now + 240000;
m_Wait = now + 1000;
m_Step = 1;
@@ -149,8 +153,29 @@ class ERR_NativeMedicalProbe
if (candidate && candidate.GetMaxBleedingRate() > 0) { wound = candidate; break; }
}
if (!Check(wound != null, "native left-arm hit zone can bleed")) return -1;
wound.SetHealthScaled(0.5);
m_Damage.AddBleedingEffectOnHitZone(wound);
m_Wound = wound;
// Enter through real bullet damage: directly adding a bleed cannot catch
// casualty protection accidentally suppressing native wound consequences.
vector hit[3];
SCR_DamageContext scratch = new SCR_DamageContext(EDamageType.KINETIC, 1, hit, m_Casualty, wound, Instigator.CreateInstigator(m_Healer), null, -1, -1);
scratch.damageEffect = new SCR_BaseBulletDamageEffect();
m_Damage.HandleDamage(scratch);
if (!Check(!m_Damage.IsBleeding(), "sub-threshold bullet damage does not force bleeding")) return -1;
SCR_DamageContext injury = new SCR_DamageContext(EDamageType.KINETIC, wound.GetMaxHealth() * 0.75, hit, m_Casualty, wound, Instigator.CreateInstigator(m_Healer), null, -1, -1);
// Account for the chosen prefab's native hit-zone damage multipliers. The
// remaining health must be below its bleed threshold without lethal trauma.
float low, high = 1000;
for (int sample = 0; sample < 20; sample++)
{
injury.damageValue = (low + high) * 0.5;
if (wound.ERR_ComputeWoundDamage(injury) < wound.GetMaxHealth() * 0.75) low = injury.damageValue;
else high = injury.damageValue;
}
injury.damageValue = high;
injury.damageEffect = new SCR_BaseBulletDamageEffect();
m_Damage.HandleDamage(injury);
PrintFormat("ERR MEDICAL WOUND: health=%1 critical=%2 alive=%3 bleeding=%4", wound.GetHealthScaled(), wound.GetCriticalHealthThreshold(), ERR_Actors.Alive(m_Casualty), m_Damage.IsBleeding());
if (!Check(ERR_Actors.Alive(m_Casualty) && wound.GetHealth() < wound.GetCriticalHealthThreshold() && m_Damage.IsBleeding(), "bullet wound starts native bleeding even when remaining health is below the wound threshold")) return -1;
float threshold = m_Blood.GetDamageStateThreshold(ECharacterBloodState.UNCONSCIOUS) * m_Blood.GetMaxHealth();
float destroyed = m_Blood.GetDamageStateThreshold(ECharacterBloodState.DESTROYED) * m_Blood.GetMaxHealth();
if (!Check(threshold - destroyed > 200, "native blood thresholds permit a survivable recovery fixture")) return -1;
@@ -250,6 +275,13 @@ class ERR_NativeMedicalProbe
{
if (!Check(!m_Epinephrine && m_Damage.GetResilienceHitZone().GetHealth() > m_BeforeEpi,
"epinephrine is consumed and resilience recovers over time")) return -1;
// Extreme trauma is separate from the moderate-wound recovery case above.
// It also checks re-injuring the same previously bandaged body part.
vector hit[3];
SCR_DamageContext injury = new SCR_DamageContext(EDamageType.KINETIC, 1000, hit, m_Casualty, m_Wound, Instigator.CreateInstigator(m_Healer), null, -1, -1);
injury.damageEffect = new SCR_BaseBulletDamageEffect();
m_Damage.HandleDamage(injury);
if (!Check(ERR_Actors.Alive(m_Casualty) && m_Wound.GetHealthScaled() < 0.1 && m_Damage.IsBleeding(), "otherwise fatal bullet reopens a bandaged wound while preserving the same living body")) return -1;
m_Damage.GetBloodHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Wait = now + 1000; m_Step = 9; return 0;
}
@@ -0,0 +1,361 @@
#ifdef WORKBENCH
// Explicit UI mechanism fixture. It creates no intel, tasks, actors or inventory
// and never changes annotation restrictions. Shared-root evidence is NOT a
// physical held-texture, manual-input, restricted-platform or multiplayer test.
modded class ERR_ObjectiveMapPresentation
{
Widget ERR_ProbeRoot() { return m_Root; }
ERR_ObjectiveMapElement ERR_ProbeElement() { return m_Element; }
Widget ERR_ProbeCanvas() { return m_Canvas; }
Widget ERR_ProbeContainer() { return m_Container; }
bool ERR_ProbeStopped() { return !m_Started && !m_Root && !m_Element; }
}
modded class ERR_DiscoveryMarker
{
ERR_ObjectiveMapPresentation ERR_ProbePresentation() { return m_ERRPresentation; }
}
modded class SCR_MapMarkerManagerComponent
{
int ERR_ProbeAnnotationCount() { return m_aStaticMarkers.Count() + m_aDisabledMarkers.Count(); }
}
class ERR_NativeObjectiveMapProbe
{
protected ERR_GameMode m_Mode;
protected SCR_MapEntity m_Map;
protected SCR_MapMarkerManagerComponent m_Annotations;
protected ref ERR_DiscoveryMarker m_Marker;
protected ref ERR_ObjectiveMapPresentation m_Presentation;
protected SCR_MapMenuUI m_Menu;
protected Widget m_SharedRoot, m_ReusedRoot, m_ExpectedRoot, m_PreviousWidget;
protected ref MapConfiguration m_Config;
protected MapConfiguration m_Ready;
protected vector m_Point;
protected int m_Stage, m_AnnotationCount;
protected int m_Deadline;
protected float m_Wait;
protected bool m_Stopped, m_Done, m_CloseRequested, m_CloseObserved;
static bool Enabled()
{
string enabled, outcome;
return System.GetCLIParam("errBaseSmoke", enabled) && enabled == "1" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "objective-map";
}
void ERR_NativeObjectiveMapProbe(ERR_GameMode mode)
{
m_Mode = mode;
m_Deadline = System.GetTickCount() + 90000;
SCR_MapEntity.GetOnMapOpenComplete().Insert(Ready);
SCR_MapEntity.GetOnMapClose().Insert(Closed);
}
protected void Ready(MapConfiguration config) { m_Ready = config; }
protected void Closed(MapConfiguration config)
{
if (m_CloseRequested && config == m_Config) m_CloseObserved = true;
}
protected bool Check(bool condition, string detail)
{
if (condition) { Print("ERR SMOKE PASS: objective overlay " + detail); return true; }
Print("ERR SMOKE FAIL: objective overlay " + detail, LogLevel.ERROR);
Stop(); return false;
}
protected bool OwnsMap()
{
if (!m_Map || SCR_MapEntity.GetMapInstance() != m_Map || !m_Map.IsOpen()) return false;
return m_Config && m_Map.GetMapConfig() == m_Config && m_Config.RootWidgetRef == m_ExpectedRoot && m_Map.GetMapMenuRoot() == m_ExpectedRoot;
}
protected bool OpenStandard()
{
if (!Check(m_Map && !m_Map.IsOpen() && !m_Menu, "standard map starts from closed fixture-owned state")) return false;
m_Ready = null;
m_Menu = SCR_MapMenuUI.Cast(GetGame().GetMenuManager().OpenMenu(ChimeraMenuPreset.MapMenu));
if (!Check(m_Menu && m_Map.IsOpen(), "ordinary native MapMenu opens without simulated input")) return false;
m_ExpectedRoot = m_Menu.GetRootWidget();
m_Config = m_Map.GetMapConfig();
return Check(OwnsMap() && m_Config.MapEntityMode == EMapEntityMode.FULLSCREEN, "ordinary full-screen configuration and exact menu root pinned");
}
protected bool CloseStandard()
{
if (!Check(m_Menu && OwnsMap() && !m_CloseRequested, "only exact owned standard menu may request close once")) return false;
m_CloseRequested = true;
m_CloseObserved = false;
return Check(GetGame().GetMenuManager().CloseMenu(m_Menu), "native standard menu close request accepted");
}
protected int StandardClosed()
{
if (!Check(m_CloseRequested && m_Map && SCR_MapEntity.GetMapInstance() == m_Map, "pending close retains original native map identity")) return -1;
if (m_Map.IsOpen())
{
if (!Check(OwnsMap(), "pending close cannot adopt a foreign open map")) return -1;
return 0;
}
if (!m_CloseObserved) return 0;
if (!Check(!m_Presentation.ERR_ProbeRoot(), "native map close callback removes objective widget before reopening")) return -1;
m_Menu = null;
m_CloseRequested = false;
m_CloseObserved = false;
return 1;
}
protected Widget CreateSharedRoot()
{
Widget root = GetGame().GetWorkspace().CreateWidgets("{255E1265F702B4AE}UI/layouts/ERRMap/SharedMapRenderTarget.layout");
if (!root) return null;
RTTextureWidget target = RTTextureWidget.Cast(root.FindAnyWidget("RTTexture0"));
if (!target) { root.RemoveFromHierarchy(); return null; }
// Expose the real root for geometry/screenshot checks. No model render target
// is bound, so these observations cannot qualify the physical held texture.
target.SetEnabled(false);
root.SetVisible(true);
return root;
}
protected bool OpenShared(Widget root)
{
if (!Check(root && !m_Map.IsOpen(), "separate real shared-map render layout exists before opening")) return false;
m_Ready = null; m_ExpectedRoot = root;
m_Config = m_Map.SetupMapConfig(EMapEntityMode.PLAIN, "{A786DD4868598F15}Configs/Map/MapPlain.conf", root);
if (!Check(m_Config != null, "native shared plain-map configuration prepared")) return false;
m_Map.OpenMap(m_Config);
return Check(OwnsMap(), "shared map uses exact authored root and configuration");
}
protected bool ReadyToInspect()
{
return OwnsMap() && m_Ready == m_Config && !m_Map.IsZooming();
}
protected void Center(float zoom)
{
int position[2]; m_Marker.GetWorldPos(position);
m_Map.ZoomPanSmooth(zoom, position[0] + 100.0 / zoom, position[1], 0.1);
}
protected bool Inspect(string label, bool rough)
{
if (!Check(ReadyToInspect(), "native configuration finished initialization and camera remains fixture-owned")) return false;
Widget root = m_Presentation.ERR_ProbeRoot();
ERR_ObjectiveMapElement element = m_Presentation.ERR_ProbeElement();
Widget container = m_ExpectedRoot.FindAnyWidget("ERR_ObjectiveOverlay");
Widget nativeIcons = m_ExpectedRoot.FindAnyWidget("UIIconsContainer");
if (!container && nativeIcons) container = nativeIcons.GetParent();
if (!Check(root && element && container && container != m_Map.GetMapWidget() && root.GetParent() == container && m_Presentation.ERR_ProbeContainer() == container && m_Presentation.ERR_ProbeCanvas() == m_Map.GetMapWidget(), "actual authored layout handler belongs to the exact supported objective container and native canvas")) return false;
TextWidget text = TextWidget.Cast(root.FindAnyWidget("ObjectiveText"));
ImageWidget image = ImageWidget.Cast(root.FindAnyWidget("Image"));
if (!Check(text && image && text.GetText() == label && root.IsVisible(), "actual visible widget contains the current objective label and icon widget")) return false;
int width, height; image.GetImageSize(0, width, height);
if (!Check(width > 0 && height > 0, "actual icon image resource has nonzero native dimensions")) return false;
int point[2]; m_Marker.GetWorldPos(point);
int knowledge = 2; if (rough) knowledge = 1;
if (!Check(point[0] == ERR_DiscoveryMapRules.Coordinate(m_Point[0], knowledge) && point[1] == ERR_DiscoveryMapRules.Coordinate(m_Point[2], knowledge), "presentation retains only requested rough or identified map coordinates")) return false;
vector model = element.GetPos();
if (!Check(model[0] == point[0] && model[2] == point[1], "native handler receives only frozen disclosed coordinates")) return false;
int x, y; m_Map.WorldToScreen(point[0], point[1], x, y, true);
float cx, cy, ox, oy;
m_Map.GetMapWidget().GetScreenPos(cx, cy);
container.GetScreenPos(ox, oy);
vector placed = FrameSlot.GetPos(root);
if (!Check(Math.AbsFloat(placed[0] - GetGame().GetWorkspace().DPIUnscale(cx + x - ox)) < 1 && Math.AbsFloat(placed[1] - GetGame().GetWorkspace().DPIUnscale(cy + y - oy)) < 1, "native frame position matches current pan zoom DPI and canvas-to-container offset")) return false;
float ix, iy, iw, ih;
image.GetScreenPos(ix, iy); image.GetScreenSize(iw, ih);
PrintFormat("ERR OBJECTIVE PIXELS: expected=%1,%2 actual=%3,%4 stage=%5", cx + x, cy + y, ix + iw * 0.5, iy + ih * 0.5, m_Stage);
return Check(Math.AbsFloat(ix + iw * 0.5 - cx - x) <= 3 && Math.AbsFloat(iy + ih * 0.5 - cy - y) <= 3, "actual rendered icon box is centered on disclosed native pixel position");
}
int Tick(float now)
{
if (m_Done) return 1;
if (m_Stopped) return -1;
if (!Enabled() || !m_Mode || ERR_GameMode.Get() != m_Mode || !m_Mode.Active() || System.GetTickCount() >= m_Deadline) { Check(false, "fixed ninety-second deadline and original active mission"); return -1; }
if (now < m_Wait) return 0;
if (m_Stage >= 16 && m_Stage <= 23 && !Check(ReadyToInspect(), "capture hold retains exact active map before any following mutation")) return -1;
if (m_Stage == 0)
{
m_Map = SCR_MapEntity.GetMapInstance();
m_Annotations = SCR_MapMarkerManagerComponent.GetInstance();
PlayerController controller = GetGame().GetPlayerController();
if (!Check(m_Map && !m_Map.IsOpen() && m_Annotations && controller && controller.GetControlledEntity(), "closed native map and actual local participant available")) return -1;
m_Point = controller.GetControlledEntity().GetOrigin() + "1037.25 0 1063.75";
m_AnnotationCount = m_Annotations.ERR_ProbeAnnotationCount();
m_Marker = new ERR_DiscoveryMarker();
m_Marker.SetDiscoveryPosition(m_Point, 1); m_Marker.SetLocationIcon(ERR_LocationKind.RADIO, "", 1);
m_Marker.SetCustomText("Unknown location (500 m area)");
m_Annotations.ERR_InsertDiscoveryMarker(m_Marker);
m_Presentation = m_Marker.ERR_ProbePresentation();
if (!Check(m_Presentation != null, "real discovery insertion uses separate objective presentation")) return -1;
if (!OpenStandard()) return -1;
m_Stage = 1; m_Wait = now + 1000; return 0;
}
if (m_Stage == 1 || m_Stage == 7 || m_Stage == 9 || m_Stage == 11)
{
if (!ReadyToInspect()) return 0;
Center(0.3); m_Stage++; m_Wait = now + 1000; return 0;
}
if (m_Stage == 2)
{
if (!Inspect("Unknown location (500 m area)", true)) return -1;
System.MakeScreenshot("$logs:err-objective-map-standard-rough");
Print("ERR OBJECTIVE CAPTURE READY: standard-rough");
m_Stage = 16; m_Wait = now + 1500; return 0;
}
if (m_Stage == 16)
{
m_PreviousWidget = m_Presentation.ERR_ProbeRoot();
m_Marker.SetDiscoveryPosition(m_Point, 2); m_Marker.SetLocationIcon(ERR_LocationKind.RADIO, "", 2); m_Marker.SetCustomText("Radio station");
Center(0.6); m_Stage = 3; m_Wait = now + 1000; return 0;
}
if (m_Stage == 3)
{
if (!Inspect("Radio station", false)) return -1;
if (!Check(m_PreviousWidget == m_Presentation.ERR_ProbeRoot(), "rough to exact updates one existing widget")) return -1;
System.MakeScreenshot("$logs:err-objective-map-standard-exact");
Print("ERR OBJECTIVE CAPTURE READY: standard-exact");
m_Stage = 17; m_Wait = now + 1500; return 0;
}
if (m_Stage == 17)
{
float farX = 500; if (m_Point[0] < m_Map.GetMapSizeX() * 0.5) farX = m_Map.GetMapSizeX() - 500;
m_Map.ZoomPanSmooth(0.6, farX, 500, 0.1);
m_Stage = 4; m_Wait = now + 1000; return 0;
}
if (m_Stage == 4)
{
if (!Check(ReadyToInspect() && m_PreviousWidget == m_Presentation.ERR_ProbeRoot() && !m_PreviousWidget.IsVisible(), "native pan culls original objective without deleting or relocating its data")) return -1;
Center(0.3); m_Stage = 5; m_Wait = now + 1000; return 0;
}
if (m_Stage == 5)
{
if (!Inspect("Radio station", false)) return -1;
m_Marker.SetCustomText("Reported mine area (500 m); may still be active");
m_Stage = 22; m_Wait = now + 750; return 0;
}
if (m_Stage == 22)
{
if (!Inspect("Reported mine area (500 m); may still be active", false)) return -1;
System.MakeScreenshot("$logs:err-objective-map-standard-long-warning");
Print("ERR OBJECTIVE CAPTURE READY: standard-long-warning");
m_Stage = 23; m_Wait = now + 1500; return 0;
}
if (m_Stage == 23)
{
m_Marker.SetCustomText("Radio station");
if (!CloseStandard()) return -1;
m_Stage = 6; m_Wait = now + 500; return 0;
}
if (m_Stage == 6)
{
int closed = StandardClosed();
if (closed <= 0) return closed;
if (!OpenStandard()) return -1;
m_Stage = 7; m_Wait = now + 1000; return 0;
}
if (m_Stage == 8)
{
if (!Inspect("Radio station", false)) return -1;
System.MakeScreenshot("$logs:err-objective-map-standard-reopened");
Print("ERR OBJECTIVE CAPTURE READY: standard-reopened");
m_Stage = 18; m_Wait = now + 1500; return 0;
}
if (m_Stage == 18)
{
if (!CloseStandard()) return -1;
m_Stage = 15; m_Wait = now + 500; return 0;
}
if (m_Stage == 15)
{
int closed = StandardClosed();
if (closed <= 0) return closed;
m_SharedRoot = CreateSharedRoot();
if (!OpenShared(m_SharedRoot)) return -1;
m_Stage = 9; m_Wait = now + 1000; return 0;
}
if (m_Stage == 10)
{
if (!Inspect("Radio station", false)) return -1;
System.MakeScreenshot("$logs:err-objective-map-shared-root");
Print("ERR OBJECTIVE CAPTURE READY: shared-root");
m_Stage = 19; m_Wait = now + 1500; return 0;
}
if (m_Stage == 19)
{
m_Marker.SetCustomText("Reported mine area (500 m); may still be active");
m_Stage = 14; m_Wait = now + 750; return 0;
}
if (m_Stage == 14)
{
if (!Inspect("Reported mine area (500 m); may still be active", false)) return -1;
System.MakeScreenshot("$logs:err-objective-map-shared-long-warning");
Print("ERR OBJECTIVE CAPTURE READY: shared-long-warning");
m_Stage = 20; m_Wait = now + 1500; return 0;
}
if (m_Stage == 20)
{
MapConfiguration previousConfig = m_Config;
m_Map.CloseMap();
if (!Check(!m_Presentation.ERR_ProbeRoot(), "closing shared root clears only owned objective widget")) return -1;
m_ReusedRoot = CreateSharedRoot();
if (!OpenShared(m_ReusedRoot)) return -1;
if (!Check(m_Config == previousConfig && m_ReusedRoot != m_SharedRoot, "native plain config is reused with a distinct root")) return -1;
m_Stage = 11; m_Wait = now + 1000; return 0;
}
if (m_Stage == 12)
{
if (!Inspect("Reported mine area (500 m); may still be active", false)) return -1;
System.MakeScreenshot("$logs:err-objective-map-shared-reused-root");
Print("ERR OBJECTIVE CAPTURE READY: shared-reused-root");
m_Stage = 21; m_Wait = now + 1500; return 0;
}
if (!Check(m_Stage == 21, "only final captured stage can complete")) return -1;
m_Marker.OnDelete();
if (!Check(m_Presentation.ERR_ProbeStopped() && OwnsMap(), "objective teardown releases its widget while leaving native map open")) return -1;
m_Stage = 13;
Stop(); m_Done = true;
if (!Check(m_Annotations && m_Annotations.ERR_ProbeAnnotationCount() == m_AnnotationCount, "existing annotation registry count preserved; no privacy settings changed")) return -1;
Print("ERR SMOKE COMPLETE: objective overlay native standard/shared-root geometry, data, pan zoom culling reopen and teardown; not held-texture, manual input, restricted-platform or multiplayer proof");
return 1;
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
if (m_Marker) m_Marker.OnDelete();
if (m_Presentation) m_Presentation.Stop();
SCR_MapEntity.GetOnMapOpenComplete().Remove(Ready);
SCR_MapEntity.GetOnMapClose().Remove(Closed);
if (OwnsMap())
{
if (m_Menu)
{
if (!m_CloseRequested) GetGame().GetMenuManager().CloseMenu(m_Menu);
}
else m_Map.CloseMap();
}
if (m_SharedRoot) m_SharedRoot.RemoveFromHierarchy();
if (m_ReusedRoot) m_ReusedRoot.RemoveFromHierarchy();
m_Menu = null; m_SharedRoot = null; m_ReusedRoot = null; m_ExpectedRoot = null; m_Config = null; m_Ready = null;
}
}
modded class ERR_GameMode
{
protected ref ERR_NativeObjectiveMapProbe m_ERRObjectiveMapProbe;
protected int m_ERRObjectiveMapDeadline;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !ERR_NativeObjectiveMapProbe.Enabled()) return;
m_ERRObjectiveMapDeadline = System.GetTickCount() + 180000;
OnStop().Insert(ERR_StopObjectiveMapProbe);
GetGame().GetCallqueue().CallLater(ERR_TickObjectiveMapProbe, 250, true);
}
protected void ERR_TickObjectiveMapProbe()
{
if (System.GetTickCount() >= m_ERRObjectiveMapDeadline || !PreparationError().IsEmpty())
{
Print("ERR SMOKE FAIL: objective overlay preparation/overall deadline", LogLevel.ERROR);
ERR_StopObjectiveMapProbe(); return;
}
if (!Active() || !GetGame().GetPlayerController() || !GetGame().GetPlayerController().GetControlledEntity()) return;
if (!m_ERRObjectiveMapProbe) m_ERRObjectiveMapProbe = new ERR_NativeObjectiveMapProbe(this);
if (m_ERRObjectiveMapProbe.Tick(GetWorld().GetWorldTime()) != 0) ERR_StopObjectiveMapProbe();
}
protected void ERR_StopObjectiveMapProbe()
{
if (GetGame()) GetGame().GetCallqueue().Remove(ERR_TickObjectiveMapProbe);
if (m_ERRObjectiveMapProbe) m_ERRObjectiveMapProbe.Stop();
OnStop().Remove(ERR_StopObjectiveMapProbe);
}
void ~ERR_GameMode() { ERR_StopObjectiveMapProbe(); }
}
#endif
@@ -1,12 +1,12 @@
#ifdef WORKBENCH
// Force the reported camp-5 terrain refusal only in this opt-in native fixture.
// Force both optional-camp refusals reported on the server in this opt-in fixture.
modded class ERR_CampTerrain
{
static bool s_ERROptionalSiteRefused;
override bool Site(ERR_SiteDefinition site, vector position, float heading, out float baseHeight)
{
string outcome;
if (site.Id == "everon-camp-5" && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "optional-site-skip")
if ((site.Id == "everon-camp-5" || site.Id == "everon-outpost-1") && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "optional-site-skip")
{
s_ERROptionalSiteRefused = true; return false;
}
@@ -35,14 +35,20 @@ modded class ERR_GameMode
if (Phase() != ERR_Phase.PRISON || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity(); if (!player) return;
GetGame().GetCallqueue().Remove(ERR_OptionalSiteTick);
if (!ERR_CampTerrain.s_ERROptionalSiteRefused || Sites().Find("everon-camp-5"))
if (!ERR_CampTerrain.s_ERROptionalSiteRefused || Sites().Find("everon-camp-5") || Sites().Find("everon-outpost-1"))
{ Print("ERR SMOKE FAIL: forced camp refusal must leave no spawned camp runtime", LogLevel.ERROR); return; }
if (!Sites().Find("everon-prison-1") || !Sites().Find("everon-radio-1") || !Sites().Find("everon-extraction-1"))
{ Print("ERR SMOKE FAIL: required mission sites missing after optional skip", LogLevel.ERROR); return; }
for (int index = 1; index <= 4; index++)
if (!Sites().Find("everon-mortar-" + index.ToString()))
{ Print("ERR SMOKE FAIL: required mortar missing after optional skip", LogLevel.ERROR); return; }
Print("ERR SMOKE PASS: forced camp-5 terrain/fallback failure omitted the camp before spawning");
ERR_MortarLogistics logistics = ERR_HQ.Get().Logistics();
ERR_SiteRuntime depot = ERR_MortarLogistics.FindDepot(Sites());
if (depot && (logistics.Cache() != depot.Container || logistics.Group() != ERR_MortarLogistics.CourierSource(depot).Group))
{ Print("ERR SMOKE FAIL: mortar courier did not use the surviving depot and its mobile team", LogLevel.ERROR); return; }
if (!depot && (!logistics.Stopped() || logistics.Cache() || logistics.Group()))
{ Print("ERR SMOKE FAIL: absent optional depots must leave resupply unavailable", LogLevel.ERROR); return; }
Print("ERR SMOKE PASS: forced camp-5 and preferred mortar-depot terrain failures omitted both camps before spawning");
Print("ERR SMOKE PASS: required prison, radio, extraction and all four mortars retained");
Print("ERR SMOKE PASS: mission preparation completed and a real player was admitted despite the skipped camp");
Transition(ERR_Phase.DEFEAT);
@@ -0,0 +1,155 @@
#ifdef WORKBENCH
modded class ERR_RescueFlightComponent
{
void ERR_RescueProbeProtection()
{
DamageManagerComponent damage = DamageManagerComponent.Cast(GetOwner().FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(false);
foreach (IEntity actor : m_OriginalCrew)
{
if (!actor) continue;
damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
if (damage) damage.EnableDamageHandling(false);
}
}
}
// Opt-in disposable local fixture. Does not run on published dedicated servers.
modded class ERR_GameMode
{
protected int m_ERRRescueStep, m_ERRRescueDeadline;
protected float m_ERRRescueWait, m_ERRRescueLog;
protected IEntity m_ERRRescuePlayer, m_ERRRescueSmoke;
protected ERR_LocationComponent m_ERRRescueRadio;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errRescueProbe", enabled) || enabled != "1") return;
m_ERRRescueDeadline = System.GetTickCount() + 1200000;
GetGame().GetCallqueue().CallLater(ERR_RescueProbeTick, 500, true);
}
protected bool ERR_RescueCheck(bool ok, string text)
{
if (ok) { Print("ERR RESCUE PASS: " + text); return true; }
Print("ERR RESCUE FAIL: " + text, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_RescueProbeTick); return false;
}
protected void ERR_RescueMove(vector position)
{
vector transform[4]; m_ERRRescuePlayer.GetWorldTransform(transform); transform[3] = ERR_Actors.Ground(position);
ChimeraCharacter.Cast(m_ERRRescuePlayer).Teleport(transform);
}
protected IEntity ERR_RescueSmokeAt(vector position, bool burning)
{
IEntity smoke = ERR_Actors.Spawn("{D41D22DD1B8E921E}Prefabs/Weapons/Grenades/M18/Smoke_M18_Green.et", position + "0 0.1 0");
if (!smoke) return null;
Own(smoke);
BaseTriggerComponent trigger = BaseTriggerComponent.Cast(smoke.FindComponent(BaseTriggerComponent));
if (!trigger) return null;
trigger.GetInstigator().SetInstigator(m_ERRRescuePlayer);
if (burning) trigger.OnUserTrigger(smoke);
return smoke;
}
protected void ERR_RescueProbeTick()
{
if (System.GetTickCount() > m_ERRRescueDeadline) { ERR_RescueCheck(false, "native fixture deadline"); return; }
float now = GetWorld().GetWorldTime();
if (Phase() == ERR_Phase.VICTORY)
{
ERR_RescueCheck(Extraction().EscapedCount() == 1, "native flight carried the original prisoner across the escape boundary");
Print("ERR RESCUE COMPLETE"); GetGame().GetCallqueue().Remove(ERR_RescueProbeTick); return;
}
if (!PreparationError().IsEmpty() || Phase() == ERR_Phase.DEFEAT) { ERR_RescueCheck(false, "mission failed: " + PreparationError()); return; }
if (!Active()) return;
// Isolate flight/boarding mechanics from random mission combat in this fixture.
// Native collision, support, velocity and compartment checks remain enabled.
foreach (IEntity aircraftEntity : m_OwnedEntities)
{
if (!aircraftEntity || !Vehicle.Cast(aircraftEntity)) continue;
ERR_RescueFlightComponent protectedFlight = ERR_RescueFlightComponent.Cast(aircraftEntity.FindComponent(ERR_RescueFlightComponent));
if (protectedFlight) protectedFlight.ERR_RescueProbeProtection();
}
if (now < m_ERRRescueWait) return;
if (m_ERRRescueStep == 0)
{
PlayerController controller = GetGame().GetPlayerController();
if (!controller || !controller.GetControlledEntity()) return;
m_ERRRescuePlayer = controller.GetControlledEntity();
DamageManagerComponent.Cast(m_ERRRescuePlayer.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!m_ERRRescuePlayer) return;
if (!ERR_RescueCheck(!ExtractionVehicle() && Extraction().PickupConfigured(), "mission begins with a pickup point and no parked extraction helicopter")) return;
if (Phase() == ERR_Phase.PRISON && !UnlockPrison(m_ERRRescuePlayer)) return;
m_ERRRescueStep = 11;
}
if (m_ERRRescueStep == 11)
{
foreach (ERR_LocationComponent location : m_Locations)
if (location && location.Prepared() && location.Kind() == ERR_LocationKind.RADIO) { m_ERRRescueRadio = location; break; }
if (!m_ERRRescueRadio) return;
m_ERRRescueRadio.Discover(0, 100); Settings().Set("radioDefenceSeconds", 30);
ERR_RescueMove(m_ERRRescueRadio.Position() + "2 0 0");
m_ERRRescueStep = 10; m_ERRRescueWait = now + 1000; return;
}
if (m_ERRRescueStep == 10)
{
if (!ERR_RescueCheck(TuneRadio(m_ERRRescuePlayer, m_ERRRescueRadio), "normal radio action starts the preparation countdown")) return;
if (!ERR_RescueCheck(ERR_RescueSmokeAt(Extraction().PickupPosition(), true) != null, "old smoke present before pickup reveal")) return;
m_ERRRescueStep = 1; return;
}
if (m_ERRRescueStep == 1)
{
if (Phase() != ERR_Phase.EXTRACTION) return;
if (!ERR_RescueCheck(PickupStage() == 1 && !ExtractionVehicle(), "countdown reveals pickup without spawning transport")) return;
ERR_RescueMove(Extraction().PickupPosition() + "40 0 0");
if (!ERR_RescueCheck(ERR_RescueSmokeAt(Extraction().PickupPosition() + "40 0 0", true) != null, "burning smoke created outside pickup radius")) return;
m_ERRRescueStep = 2; m_ERRRescueWait = now + 3000; return;
}
if (m_ERRRescueStep == 2)
{
if (!ERR_RescueCheck(PickupStage() == 1, "old on-site smoke and fresh off-target smoke cannot dispatch rescue")) return;
m_ERRRescueSmoke = ERR_RescueSmokeAt(Extraction().PickupPosition(), false);
if (!ERR_RescueCheck(m_ERRRescueSmoke != null, "unlit smoke created at exact pickup")) return;
m_ERRRescueStep = 3; m_ERRRescueWait = now + 3000; return;
}
if (m_ERRRescueStep == 3)
{
if (!ERR_RescueCheck(PickupStage() == 1, "unlit smoke cannot dispatch rescue")) return;
BaseTriggerComponent.Cast(m_ERRRescueSmoke.FindComponent(BaseTriggerComponent)).OnUserTrigger(m_ERRRescueSmoke);
m_ERRRescueStep = 4; m_ERRRescueWait = now + 3000; return;
}
if (m_ERRRescueStep == 4)
{
if (!ERR_RescueCheck(PickupStage() >= 2 && ExtractionVehicle(), "native burning smoke dispatches real transport")) return;
string signalOnly;
if (System.GetCLIParam("errRescueSignalOnly", signalOnly) && signalOnly == "1")
{
Print("ERR RESCUE COMPLETE: signal-only fixture");
GetGame().GetCallqueue().Remove(ERR_RescueProbeTick); return;
}
m_ERRRescueStep = 5;
}
if (now >= m_ERRRescueLog)
{
m_ERRRescueLog = now + 10000;
Vehicle aircraft = ExtractionVehicle();
if (aircraft) PrintFormat("ERR RESCUE TRACE: stage=%1 position=%2 target=%3 status=%4", PickupStage(), aircraft.GetOrigin(), Extraction().PickupPosition(), ERR_RescueFlightComponent.Cast(aircraft.FindComponent(ERR_RescueFlightComponent)).Status());
}
if (m_ERRRescueStep == 5 && PickupStage() == 4)
{
int escorts;
foreach (IEntity flightEntity : m_OwnedEntities)
{
if (!flightEntity || flightEntity == ExtractionVehicle()) continue;
ERR_RescueFlightComponent cover = ERR_RescueFlightComponent.Cast(flightEntity.FindComponent(ERR_RescueFlightComponent));
if (cover && !cover.Lost() && cover.Phase() == ERR_ReconPhase.SEARCH && SCR_FactionManager.SGetFaction(flightEntity) == PlayerFaction()) escorts++;
}
if (!ERR_RescueCheck(escorts == 2, "both friendly armed escorts remain airborne during pickup")) return;
if (!ERR_RescueCheck(ERR_CasualtyLoadingRules.AircraftReady(ExtractionVehicle()), "transport really landed, upright and stationary")) return;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(m_ERRRescuePlayer.FindComponent(CompartmentAccessComponent));
BaseCompartmentSlot seat = access.FindFreeCompartment(ExtractionVehicle(), ECompartmentType.CARGO);
if (!ERR_RescueCheck(seat && access.GetInVehicle(ExtractionVehicle(), seat, true, -1, ECloseDoorAfterActions.INVALID, false), "native cargo boarding accepted")) return;
m_ERRRescueStep = 6;
}
}
}
#endif
@@ -45,8 +45,8 @@ class ERR_NativeResultProbe
roster.Spawned(playerId, controller.GetControlledEntity());
controller.ERR_PublishEscapeResult(roster, extraction);
array<int> noWinners = {};
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.ESCAPED && info.GetTitle(playerId, noWinners) == "You escaped Everon", "identity snapshot publishes victory despite missing end-data ID")) return false;
if (!Check(NativeText("You escaped Everon", "You crossed the extraction boundary aboard the helicopter."), "already-open native content refreshes when personal victory arrives")) return false;
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.ESCAPED && info.GetTitle(playerId, noWinners) == "You escaped", "identity snapshot publishes victory despite missing end-data ID")) return false;
if (!Check(NativeText("You escaped", "You crossed the extraction boundary aboard the helicopter."), "already-open native content refreshes when personal victory arrives")) return false;
if (!Check(roster.Bind(playerId, missedAccount), "local presentation connection ID is recycled to non-escapee")) return false;
roster.Spawned(playerId, controller.GetControlledEntity());
controller.ERR_PublishEscapeResult(roster, extraction);
@@ -54,7 +54,7 @@ class ERR_NativeResultProbe
if (!Check(NativeText("The helicopter escaped", "You did not escape with the helicopter."), "native text refresh removes the previous account's win")) return false;
if (!Check(roster.Bind(playerId, escapedAccount), "escaped presentation account returns after simulated rebind")) return false;
controller.ERR_PublishEscapeResult(roster, extraction);
if (!Check(info.GetTitle(playerId, noWinners) == "You escaped Everon", "presentation follows returning account rather than copied end data")) return false;
if (!Check(info.GetTitle(playerId, noWinners) == "You escaped", "presentation follows returning account rather than copied end data")) return false;
controller.ERR_ResetEscapeResult();
if (!Check(controller.ERR_EscapeResult() == ERR_PersonalEscapeResult.UNKNOWN && info.GetTitle(playerId, copiedIds) == "Round ended", "round reset clears personal result despite stale winner ID")) return false;
if (!Check(NativeText("Round ended", "Your personal escape result is not available."), "round reset clears the open native result text")) return false;
@@ -0,0 +1,30 @@
#ifdef WORKBENCH
modded class ERR_HQ
{
bool ERR_ProbeRoadblockGroup(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group == group) return !unit.ReservedForSupport && !unit.HomeGuard && unit.Utility != null;
return false;
}
}
modded class ERR_Roadblocks
{
bool ERR_ProbePreparedRoadblocks()
{
if (!m_Ready || m_Target != 4 || m_Groups.IsEmpty() || m_Groups.Count() > m_Target || m_Groups.Count() != m_Positions.Count()) return false;
int count = m_Groups.Count();
foreach (SCR_AIGroup group : m_Groups)
{
ERR_ForceRecord record = m_Mode.Forces().FindGroup(group);
if (!record || record.Kind != ERR_ForceKind.CHECKPOINT || record.InitialStrength != 4 || m_Mode.ForceLiving(group) != 4
|| group.GetFaction() != m_Mode.EnemyFaction() || !ERR_HQ.Get().ERR_ProbeRoadblockGroup(group)) return false;
}
ERR_ScenarioDefinition definition = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab("{ACA98B44E2173605}Configs/ERR/Tests/LegacySites.conf"));
// Repeated preparation cannot replenish or create another squad.
if (Prepare(definition) != ERR_PrepareResult.READY || m_Groups.Count() != count) return false;
PrintFormat("ERR ROADBLOCK NATIVE: posts=%1 soldiers=%2 ledger=checkpoint HQ=available repeatedPrepare=unchanged", count, count * 4);
return true;
}
}
#endif
@@ -55,10 +55,10 @@ class ERR_NativeTransportProbe
return Check(definition.Valid(), "valid authoring restored after isolated mutations");
}
// Read actual prepared stock only; this does not qualify repair/refuel use.
protected bool MaintenanceStock()
bool MaintenanceStock(ERR_SiteRuntime site)
{
if (!Check(m_Site && m_Site.Container && m_Mode.Sites().Owns(m_Site.Container), "maintenance cache has its original site-owned container")) return false;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(m_Site.Container.FindComponent(InventoryStorageManagerComponent));
if (!Check(site && site.Container && m_Mode.Sites().Owns(site.Container), "maintenance cache has its original site-owned container")) return false;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(site.Container.FindComponent(InventoryStorageManagerComponent));
if (!Check(inventory != null, "maintenance cache native inventory exists")) return false;
array<IEntity> items = {}; ERR_Inventory.Items(inventory, items);
if (!Check(items.Count() <= 128, "maintenance inventory enumeration remains bounded")) return false;
@@ -122,7 +122,7 @@ class ERR_NativeTransportProbe
if (!DefinitionChecks()) return -1;
m_Site = m_Mode.Sites().Find("everon-supply-2");
if (!Check(m_Site && m_Site.Transport && m_Mode.Sites().TransportCount() == 1 && m_Mode.Sites().Owns(m_Site.Transport), "exactly one authored transport belongs to the finite site service")) return -1;
if (!MaintenanceStock()) return -1;
if (!MaintenanceStock(m_Site)) return -1;
m_Vehicle = m_Site.Transport;
m_Controller = CarControllerComponent.Cast(m_Vehicle.FindComponent(CarControllerComponent));
m_Fuel = SCR_FuelManagerComponent.Cast(m_Vehicle.FindComponent(SCR_FuelManagerComponent));
@@ -3,12 +3,12 @@
// an invalid parking candidate cannot block inspecting nearby terrain/scenery.
modded class ERR_SiteService
{
override bool Begin(ERR_ScenarioDefinition definition, ResourceName fieldMap)
override bool Begin(ERR_ScenarioDefinition definition, ResourceName fieldMap, bool startupOnly = false, ERR_SiteDefinition only = null)
{
string outcome;
if (definition && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "transport-survey")
foreach (ERR_SiteDefinition site : definition.Sites) site.Transport = null;
return super.Begin(definition, fieldMap);
return super.Begin(definition, fieldMap, startupOnly, only);
}
}
class ERR_NativeTransportSurvey
@@ -148,9 +148,24 @@ class ERR_NativeVehicleSecurityProbe
if (!Check(m_Security.Locked(), "wrong native action owner cannot unlock")) return -1;
if (!Check(m_Security.TryBreach(m_Player) && !m_Security.Locked(), "direct canonical server breach unlocks the existing vehicle")) return -1;
if (!Check(!m_Security.TryBreach(m_Player) && !m_Security.Locked(), "duplicate completion does not relock or repeat breach")) return -1;
if (!Check(m_Security.AlarmActive(), "successful server breach starts bounded alarm state")) return -1;
if (!Check(!m_Breach.CanBeShownScript(m_Player), "completed held action disappears after unlock")) return -1;
// This invokes ordinary native eligibility, not forced scripted boarding.
if (!Check(DriverAvailable(), "unlocked driver seat passes ordinary native boarding availability")) return -1;
m_Step = 2; m_Wait = now + 500;
return 0;
}
if (m_Step == 2)
{
if (!Check(m_Security.AlarmActive() && m_Security.AlarmPlaying(), "breach horn owns a playing native audio handle")) return -1;
Print("ERR VEHICLE ALARM: native handle is playing in muted private output; audible waveform and remote replication remain separate");
m_Step = 3; m_Wait = now + 8000;
return 0;
}
if (m_Step == 3)
{
if (!Check(!m_Security.AlarmActive() && !m_Security.AlarmPlaying(), "fixed alarm deadline clears state and owned native audio handle")) return -1;
if (!Check(!m_Security.TryBreach(m_Player) && !m_Security.AlarmActive(), "completed alarm cannot replay through duplicate breach")) return -1;
Stop();
return 1;
}
@@ -0,0 +1,134 @@
// Authored objective UI, separate from user-created map annotations. The data
// owner supplies only its already-discovered, quantized map position.
class ERR_ObjectiveMapElement : SCR_MapUIElement
{
protected vector m_ERRPosition;
protected ImageWidget m_ERRImage;
protected TextWidget m_ERRText;
protected bool m_ERRTextReady, m_ERRImageReady;
protected string m_ERRLastText, m_ERRLastIcon;
override void HandlerAttached(Widget w)
{
super.HandlerAttached(w);
m_ERRImage = ImageWidget.Cast(w.FindAnyWidget("Image"));
m_ERRText = TextWidget.Cast(w.FindAnyWidget("ObjectiveText"));
}
override vector GetPos() { return m_ERRPosition; }
void SetObjective(vector position, string text, string icon)
{
m_ERRPosition = position;
if (m_ERRText && (!m_ERRTextReady || m_ERRLastText != text))
{
m_ERRText.SetText(text); m_ERRLastText = text; m_ERRTextReady = true;
}
if (!m_ERRImage || (m_ERRImageReady && m_ERRLastIcon == icon)) return;
ResourceName imageSet = "{E0248EBF90751BA5}UI/Textures/ERR/SiteMarkers.imageset";
m_ERRImageReady = m_ERRImage.LoadImageFromSet(0, imageSet, icon);
m_ERRImage.SetVisible(m_ERRImageReady);
if (m_ERRImageReady) m_ERRLastIcon = icon;
}
// The layout ignores pointer input; objectives cannot select, drag or edit.
override void SelectIcon(bool invoke = true) {}
}
class ERR_ObjectiveMapPresentation
{
protected const ResourceName LAYOUT = "{B7DB6D50FA954684}UI/layouts/ERRMap/ObjectiveMapElement.layout";
protected bool m_Started;
protected vector m_Position;
protected string m_Text, m_Icon;
protected SCR_MapEntity m_Map;
protected MapConfiguration m_Config;
protected Widget m_Canvas, m_Container, m_Root;
protected ERR_ObjectiveMapElement m_Element;
void SetData(vector position, string text, string icon)
{
m_Position = position; m_Text = text; m_Icon = icon;
if (m_Started) Refresh();
}
void Start()
{
if (!m_Started)
{
m_Started = true;
SCR_MapEntity.GetOnMapOpenComplete().Insert(Opened);
SCR_MapEntity.GetOnMapClose().Insert(Closed);
SCR_MapEntity.GetOnMapPan().Insert(Panned);
SCR_MapEntity.GetOnMapZoom().Insert(Zoomed);
}
Refresh();
}
protected void Opened(MapConfiguration config) { Refresh(); }
protected void Panned(float x, float y, bool adjusted) { Refresh(); }
protected void Zoomed(float zoom) { Refresh(); }
protected void Closed(MapConfiguration config)
{
if (config == m_Config) ClearWidget();
}
protected void ClearWidget()
{
if (m_Root) m_Root.RemoveFromHierarchy();
m_Root = null; m_Element = null; m_Map = null; m_Config = null; m_Canvas = null; m_Container = null;
}
static Widget Container(Widget mapRoot)
{
if (!mapRoot) return null;
Widget authored = mapRoot.FindAnyWidget("ERR_ObjectiveOverlay");
if (authored) return authored;
Widget icons = mapRoot.FindAnyWidget("UIIconsContainer");
// Vanilla deletes every child of its icons container on faction refresh.
// Use its supported parent instead, so our widget is an independent sibling.
if (icons) return icons.GetParent();
return null;
}
protected bool OwnsSurface(SCR_MapEntity mapEntity, MapConfiguration config, Widget canvas, Widget container)
{
if (!m_Root || !m_Element) return false;
return m_Map == mapEntity && m_Config == config && m_Canvas == canvas && m_Container == container && m_Root.GetParent() == container;
}
void Refresh()
{
if (!m_Started || !GetGame()) return;
SCR_MapEntity mapEntity = SCR_MapEntity.GetMapInstance();
if (!GetGame().GetPlayerController() || !mapEntity || !mapEntity.IsOpen()) { ClearWidget(); return; }
MapConfiguration config = mapEntity.GetMapConfig();
Widget canvas = mapEntity.GetMapWidget();
Widget container = Container(mapEntity.GetMapMenuRoot());
if (!config || !canvas || !container || container == canvas || !GetGame().GetWorkspace()) { ClearWidget(); return; }
// The same native config object can be reused for another root/canvas.
if (!OwnsSurface(mapEntity, config, canvas, container))
{
ClearWidget();
m_Map = mapEntity; m_Config = config; m_Canvas = canvas; m_Container = container;
m_Root = GetGame().GetWorkspace().CreateWidgets(LAYOUT, container);
if (!m_Root) { ClearWidget(); return; }
m_Element = ERR_ObjectiveMapElement.Cast(m_Root.FindHandler(ERR_ObjectiveMapElement));
if (!m_Element) { ClearWidget(); return; }
}
m_Element.SetObjective(m_Position, m_Text, m_Icon);
int x, y;
mapEntity.WorldToScreen(m_Position[0], m_Position[2], x, y, true);
// Use current pixel coordinates: the native cached world viewport is updated
// after pan/zoom callbacks and may still describe the preceding frame.
float width, height;
canvas.GetScreenSize(width, height);
bool visible = width > 0 && height > 0;
if (x < 0 || y < 0 || x > width || y > height) visible = false;
m_Root.SetVisible(visible);
if (!visible) return;
float canvasX, canvasY, containerX, containerY;
canvas.GetScreenPos(canvasX, canvasY);
container.GetScreenPos(containerX, containerY);
FrameSlot.SetPos(m_Root, GetGame().GetWorkspace().DPIUnscale(canvasX + x - containerX), GetGame().GetWorkspace().DPIUnscale(canvasY + y - containerY));
}
void Stop()
{
m_Started = false;
SCR_MapEntity.GetOnMapOpenComplete().Remove(Opened);
SCR_MapEntity.GetOnMapClose().Remove(Closed);
SCR_MapEntity.GetOnMapPan().Remove(Panned);
SCR_MapEntity.GetOnMapZoom().Remove(Zoomed);
ClearWidget();
}
void ~ERR_ObjectiveMapPresentation() { Stop(); }
}
@@ -5,13 +5,13 @@ class ERR_ObjectiveText
{
if (phase == ERR_Phase.PRISON) return "Escape the prison";
if (phase == ERR_Phase.RADIO_SEARCH) return "Locate the radio station";
if (phase == ERR_Phase.RADIO_DEFENCE) return "Defend the radio transmission";
if (phase == ERR_Phase.EXTRACTION) return "Escape Everon by helicopter";
return "Escape Everon";
if (phase == ERR_Phase.RADIO_DEFENCE) return "Hold while the escape route is prepared";
if (phase == ERR_Phase.EXTRACTION) return "Signal the rescue helicopters";
return "Escape the island";
}
static string Extraction(float radius)
{
return string.Format("The map marks the initial helicopter pickup site. A prisoner must pilot the helicopter. Gather everyone you intend to rescue before flying beyond %1 kilometres from the prison: crossing that boundary ends the round for everyone. Only living prisoners aboard escape, including unconscious passengers; anyone left behind does not escape. To load an unconscious prisoner, place them on the ground beside the grounded, stationary helicopter and use 'Load casualty into extraction helicopter' on them.", radius / 1000.0);
return "The map marks the exact extraction pickup point. Throw a burning smoke grenade within 25 metres to call the rescue transport and armed escorts. Any smoke colour works. Clear the landing point, then board with every living prisoner, including wounded teammates. The AI crew will fly you to safety. Use the casualty-loading action beside the grounded, stationary transport to load unconscious survivors.";
}
static string RadioFeedbackMessage(ERR_RadioFeedback feedback)
{
@@ -39,7 +39,7 @@ class ERR_ObjectiveText
return "Preparing mission sites and equipment. You will spawn when the mission is ready.";
}
if (phase == ERR_Phase.PRISON)
return "Search the supply box inside the prison for weapons and a field map. Pick the gate lock, then get clear of the guards.";
return "Search the supply backpack inside the prison for weapons and a field map. Pick the gate lock, then get clear of the guards.";
if (phase == ERR_Phase.RADIO_SEARCH)
{
array<ERR_LocationComponent> locations = {};
@@ -54,8 +54,10 @@ class ERR_ObjectiveText
}
// Scan all sites before acknowledging rough intel: an identified radio wins.
if (roughRadio)
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.";
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 clue site if needed. A close approach with a clear view can also identify the station.";
string startHint;
if (mode.StartMode() == 1) startHint = "Search the salvage backpack at the crash site for weapons and a field map. ";
return startHint + "Choose either marked clue site. 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 marked site 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)
{
@@ -70,7 +72,13 @@ class ERR_ObjectiveText
int displayed = Math.Ceil(seconds / 5.0) * 5;
return string.Format("Transmission in progress: up to %1 seconds remaining. Keep a conscious survivor within 60 metres of the radio. Leaving resets progress.", displayed);
}
if (phase == ERR_Phase.EXTRACTION) return Extraction(mode.EscapeRadius());
if (phase == ERR_Phase.EXTRACTION)
{
if (mode.PickupStage() == 2 || mode.PickupStage() == 3) return "Smoke accepted. Armed rescue helicopters are approaching. Keep the marked landing point clear and gather everyone nearby.";
if (mode.PickupStage() == 4) return "Board the rescue transport. It waits for all living prisoners, including unconscious or disconnected survivors. Escorts cover the landing zone. After everyone is aboard for eight seconds, the pilot takes off.";
if (mode.PickupStage() == 5) return "Stay aboard. The rescue pilot and escorts are taking you beyond the escape boundary to safety.";
return Extraction(mode.EscapeRadius());
}
return "";
}
}
@@ -48,11 +48,11 @@ class ERR_ObserverGuidance
else if (reason == ERR_ObserverReason.UNCONSCIOUS)
{
message = "You are unconscious. Your body remains where you fell and teammates can still treat you. Your view returns automatically when you wake up.";
if (!survivorAvailable) return message + " No conscious teammate is currently available; the camera stays near your body.";
if (!survivorAvailable) return message + " No conscious teammate is currently available; the camera stays near your body. Use camera look controls to orbit and zoom controls to change distance.";
}
if (!survivorAvailable)
return message + " No survivor is currently available to follow. The view will follow a survivor when one becomes available.";
return message + " Press Left / Right arrow to change teammate (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and player view. Use camera look controls to orbit and zoom controls to change distance in third-person.";
return message + " No teammate or body is currently available to follow. Use camera look controls to orbit the last known position and zoom controls to change distance.";
return message + " Press Left / Right arrow to change teammate or body (or /esc spectate next / /esc spectate previous). Press Enter to switch between third-person and the living teammate's camera. Player view waits in third-person if no live camera stream is available. Use camera look controls to orbit and zoom controls to change distance in third-person.";
}
static bool Refresh(bool shown, int previousReason, int reason, bool previousAvailable, bool survivorAvailable)
{
@@ -60,17 +60,72 @@ class ERR_ObserverGuidance
}
}
class ERR_ObserverCameraClass : SCR_ManualCameraClass {}
// Retain the native config registration when extending this serialized core.
[BaseContainerProps(configRoot: true)]
modded class SCR_ChatPanelManager
{
bool ERR_HasOpenChatPanel()
{
foreach (SCR_ChatPanel panel : m_aChatPanels)
if (panel && panel.IsOpen()) return true;
return false;
}
}
class ERR_ObserverChatInput
{
static bool Capture()
{
SCR_ChatPanelManager manager = SCR_ChatPanelManager.GetInstance();
bool chatOpen = manager && manager.ERR_HasOpenChatPanel();
bool editing;
Widget focused = GetGame().GetWorkspace().GetFocusedWidget();
while (focused)
{
// A HUD stopping drawing can unregister a panel before its focused edit
// widget closes. Keep that still-open native panel's send action available.
SCR_ChatPanel panel = SCR_ChatPanel.Cast(focused.FindHandler(SCR_ChatPanel));
if (panel && panel.IsOpen()) chatOpen = true;
if (EditBoxWidget.Cast(focused)) editing = true;
focused = focused.GetParent();
}
InputManager input = GetGame().GetInputManager();
// IsOpen is immediate; vanilla refreshes ChatContext later in its HUD update.
// Renew only for an actual open chat, never for an unrelated password box.
if (chatOpen) input.ActivateContext("ChatContext", 1);
return chatOpen || editing || input.IsContextActive("ChatContext");
}
}
// Gate before SCR_ManualCamera activates its context: disabling only the follow
// component is too late, since the camera context can already consume chat keys.
class ERR_ObserverCamera : SCR_ManualCamera
{
override protected bool IsDisabledByMenu()
{
if (super.IsDisabledByMenu()) return true;
return ERR_ObserverChatInput.Capture();
}
}
// A local follow camera. It never possesses its target or offers free flight.
[BaseContainerProps(), SCR_BaseManualCameraComponentTitle()]
class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
{
protected IEntity m_Target;
protected IEntity m_Vehicle;
protected float m_Yaw;
protected float m_Yaw = 180;
protected float m_Pitch = 20;
protected float m_Distance = 5;
protected bool m_LeftDown, m_RightDown, m_EnterDown;
protected int m_InputResumeFrames;
protected bool m_PlayerView;
protected ref ERR_ObserverHeadVisibility m_ViewHead = new ERR_ObserverHeadVisibility();
protected float m_OrbitFov, m_OrbitNear;
protected ref ERR_ObserverScopeDisplay m_ScopeDisplay = new ERR_ObserverScopeDisplay();
protected ref ERR_ObserverPIPDisplay m_PIPDisplay = new ERR_ObserverPIPDisplay();
protected bool m_InputContextActive;
bool InputContextActive() { return m_InputContextActive; }
bool IsPlayerView() { return m_PlayerView; }
@@ -78,52 +133,80 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
// while closing a menu cannot unexpectedly change the followed teammate.
void Input(bool enabled, bool left, bool right, bool enter)
{
// An inactive context reports zero even while a physical key is held. Let
// the reactivated context settle, then require release before accepting keys.
if (!enabled) m_InputResumeFrames = 2;
else if (m_InputResumeFrames > 0)
{
enabled = false;
if (m_InputResumeFrames > 1 || (!left && !right && !enter)) m_InputResumeFrames--;
}
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
int cycle = ERR_ObserverRules.Cycle(enabled, left, right, m_LeftDown, m_RightDown);
if (controller && cycle != 0) controller.ERR_CycleObserver(cycle);
if (ERR_ObserverRules.Toggle(enabled, enter, m_EnterDown)) m_PlayerView = !m_PlayerView;
m_LeftDown = left; m_RightDown = right; m_EnterDown = enter;
}
static bool PlayerView(IEntity target, out vector transform[4])
override bool EOnCameraInit()
{
ChimeraCharacter character = ChimeraCharacter.Cast(target);
if (!character || !character.GetAnimation()) return false;
int head = character.GetAnimation().GetBoneIndex("Head");
if (head < 0) return false;
vector bone[4], world[4];
if (!character.GetAnimation().GetBoneMatrix(head, bone)) return false;
character.GetWorldTransform(world);
vector position = bone[3].Multiply4(world);
vector direction = world[2];
CharacterControllerComponent controls = character.GetCharacterController();
if (controls && controls.GetHeadAimingComponent()) direction = controls.GetHeadAimingComponent().GetAimingDirectionWorld();
if (direction.LengthSq() < 0.01) direction = world[2];
direction.Normalize();
Math3D.DirectionAndUpMatrix(direction, "0 1 0", transform);
// Just ahead of the face, following posture and vehicle seating. This reads
// replicated pose; it does not possess the teammate or change their camera.
transform[3] = position + direction * 0.15;
m_OrbitFov = GetCameraEntity().GetVerticalFOV();
m_OrbitNear = GetCameraEntity().GetNearPlane();
return true;
}
override bool EOnCameraInit() { return true; }
override void EOnCameraExit() { m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); }
override void EOnCameraFrame(SCR_ManualCameraParam param)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller) return;
if (!controller || GetGame().GetCameraManager().CurrentCamera() != GetCameraEntity()) { m_PIPDisplay.Close(); m_ScopeDisplay.Close(); m_ViewHead.Restore(); return; }
m_InputContextActive = GetInputManager().IsContextActive("ERR_ObserverContext");
Input(param.isManualInputEnabled, GetInputManager().GetActionValue("ERR_ObserverPrevious") > 0.5, GetInputManager().GetActionValue("ERR_ObserverNext") > 0.5, GetInputManager().GetActionValue("ERR_ObserverView") > 0.5);
IEntity target = controller.ERR_ObservedEntity();
if (m_PlayerView && target && PlayerView(target, param.transform))
float fov, nearPlane;
if (m_PlayerView && controller.ERR_ReadPlayerView(target, param.timeSlice, param.transform, fov, nearPlane))
{
param.fov = fov; GetCameraEntity().SetNearPlane(nearPlane);
RplId scopeWeapon; vector scopeData;
controller.ERR_ReadScope(scopeWeapon, scopeData);
SCR_2DOpticsComponent optic = ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData);
IEntity weapon;
if (optic)
{
RplComponent weaponRpl = RplComponent.Cast(Replication.FindItem(scopeWeapon));
if (weaponRpl) weapon = weaponRpl.GetEntity();
}
vector eye[4], geometry;
float eyeFov, eyeNear, pipFov, vignette;
// Read both timelines every camera frame, including the 2D fallback, so
// enabling PiP cannot resume an eye camera from a different time.
bool haveEye = controller.ERR_ReadPlayerEye(target, param.timeSlice, eye, eyeFov, eyeNear, pipFov, geometry, vignette);
bool pip = haveEye && param.isManualInputEnabled && optic && weapon;
if (pip) pip = m_PIPDisplay.Render(optic, param.transform, eye, fov, pipFov, scopeData, geometry, vignette);
if (pip)
{
m_ScopeDisplay.Close();
Math3D.MatrixCopy(eye, param.transform);
param.fov = eyeFov; GetCameraEntity().SetNearPlane(eyeNear);
}
else
{
m_PIPDisplay.Close();
if (!param.isManualInputEnabled || !optic || !weapon || !m_ScopeDisplay.RenderScope(optic, weapon, fov, scopeData)) m_ScopeDisplay.Close();
}
m_ViewHead.Update(target, param.transform[3]);
param.isDirty = true;
return;
}
// No estimated head camera: corpses, streaming gaps and expired samples use
// the stable follow view until an authenticated live camera stream arrives.
m_PIPDisplay.Close(); m_ScopeDisplay.Close();
m_ViewHead.Restore();
param.fov = m_OrbitFov; GetCameraEntity().SetNearPlane(m_OrbitNear);
vector aim = controller.ERR_ObserverAnchor() + "0 1.5 0";
vector position = aim + "0 4 -6";
IEntity exclude = target;
IEntity vehicle;
if (target)
{
IEntity vehicle = CompartmentAccessComponent.GetVehicleIn(target);
vehicle = CompartmentAccessComponent.GetVehicleIn(target);
vector transform[4];
if (vehicle)
{
@@ -137,6 +220,7 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
{
target.GetWorldTransform(transform);
aim = transform[3] + "0 1.5 0";
if (!ERR_Actors.Alive(target)) aim = transform[3] + "0 0.4 0";
}
if (target != m_Target || vehicle != m_Vehicle)
{
@@ -145,19 +229,26 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
if (vehicle) m_Distance = 12;
m_Target = target; m_Vehicle = vehicle;
}
if (param.isManualInputEnabled)
{
m_Yaw += GetInputManager().GetActionValue("ManualCameraRotateYaw") * 90 * param.timeSlice;
m_Pitch = Math.Clamp(m_Pitch - GetInputManager().GetActionValue("ManualCameraRotatePitch") * 90 * param.timeSlice, 5, 75);
float scale = Math.Clamp(1 + GetInputManager().GetActionValue("ManualCameraZoom") * 12 * param.timeSlice, 0.5, 1.5);
float minimum = 2, maximum = 12;
if (vehicle) { minimum = 6; maximum = 25; }
m_Distance = Math.Clamp(m_Distance * scale, minimum, maximum);
}
float yaw = m_Yaw * Math.DEG2RAD, pitch = m_Pitch * Math.DEG2RAD;
vector offset = Vector(Math.Sin(yaw) * Math.Cos(pitch), Math.Sin(pitch), Math.Cos(yaw) * Math.Cos(pitch));
position = aim + offset * m_Distance;
}
else
{
// A lone casualty, an all-down squad or an entity still streaming can have
// only an anchor. Keep orbit controls and forget the previous vehicle limits.
m_Target = null; m_Vehicle = null;
m_Distance = Math.Clamp(m_Distance, 2, 12);
}
if ((!m_PlayerView || !target || !ERR_Actors.Alive(target)) && param.isManualInputEnabled)
{
m_Yaw += GetInputManager().GetActionValue("ManualCameraRotateYaw") * 90 * param.timeSlice;
m_Pitch = Math.Clamp(m_Pitch - GetInputManager().GetActionValue("ManualCameraRotatePitch") * 90 * param.timeSlice, 5, 75);
float scale = Math.Clamp(1 + GetInputManager().GetActionValue("ManualCameraZoom") * 12 * param.timeSlice, 0.5, 1.5);
float minimum = 2, maximum = 12;
if (vehicle) { minimum = 6; maximum = 25; }
m_Distance = Math.Clamp(m_Distance * scale, minimum, maximum);
}
float yaw = m_Yaw * Math.DEG2RAD, pitch = m_Pitch * Math.DEG2RAD;
vector offset = Vector(Math.Sin(yaw) * Math.Cos(pitch), Math.Sin(pitch), Math.Cos(yaw) * Math.Cos(pitch));
vector position = aim + offset * m_Distance;
TraceParam trace = new TraceParam();
trace.Start = aim;
trace.End = position;
@@ -187,6 +278,7 @@ modded class SCR_PlayerController
protected bool m_ERRObserverReady;
protected ERR_ObserverReason m_ERRShownObserverReason;
protected bool m_ERRShownObserverAvailable;
protected string m_ERRShownRecoveryStatus;
protected float m_ERRLastCycle = -1000;
protected IEntity m_ERRRecoveryBody;
protected float m_ERRRecoveryDeadline = -1;
@@ -239,6 +331,16 @@ modded class SCR_PlayerController
m_ERRObserverReason = ERR_ObserverReason.UNCONSCIOUS;
m_ERRObserverAnchor = body.GetOrigin();
}
if (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !ERR_Actors.Alive(body)) m_ERRObserverReason = ERR_ObserverReason.SPENT_LIFE;
// Death can leave the controller attached to its corpse, so entity-lost is
// not a reliable trigger (especially with the last/only player).
ERR_PlayerLife life;
if (mode && mode.Players()) life = mode.Players().Get(GetPlayerId());
if (mode && mode.Active() && life && life.UsedLife && life.Actor && !ERR_Actors.Alive(life.Actor) && !ERR_Actors.Alive(body) && m_ERRObserverReason == ERR_ObserverReason.NONE)
{
m_ERRObserverReason = ERR_ObserverReason.SPENT_LIFE;
m_ERRObserverAnchor = life.Actor.GetOrigin();
}
if (m_ERRObserverReason == ERR_ObserverReason.NONE) return;
if (!mode || !mode.Active() || (ERR_Actors.Alive(body) && !casualty) || (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !casualty))
{
@@ -252,8 +354,14 @@ modded class SCR_PlayerController
Faction friendly = mode.PlayerFaction();
for (int i = survivors.Count() - 1; i >= 0; i--)
if (survivors[i] == body || (casualty && !ERR_Actors.CanFight(survivors[i])) || !friendly || !mode.Players() || !mode.Players().CurrentActor(survivors[i]) || SCR_FactionManager.SGetFaction(survivors[i]) != friendly) survivors.RemoveOrdered(i);
// Dead teammates remain selectable alongside survivors, including our own body.
ERR_ObserverTargets.Bodies(mode, survivors);
int index = survivors.Find(m_ERRServerObserved);
if (index < 0) index = 0;
if (index < 0)
{
if (life && life.Actor) index = survivors.Find(life.Actor);
if (index < 0) index = 0;
}
else index += direction;
if (index < 0) index = survivors.Count() - 1;
if (index >= survivors.Count()) index = 0;
@@ -308,6 +416,17 @@ modded class SCR_PlayerController
protected void ERR_OnObserverMonitorStarted() {}
protected void ERR_UpdateObserverClient()
{
if (!GetGame()) return;
if (this != GetGame().GetPlayerController())
{
// A replaced controller may retain its timer until native destruction.
// Release only its presentation; never restore its old body camera.
GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient);
m_ERRObserverMonitor = false;
m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1;
ERR_CloseObserverCamera();
return;
}
ERR_GameMode mode = ERR_GameMode.Get();
ERR_UpdateRecoveryCamera(mode);
bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
@@ -316,6 +435,8 @@ modded class SCR_PlayerController
ERR_CloseObserverCamera();
return;
}
SCR_EditorManagerEntity editor = SCR_EditorManagerEntity.GetInstance();
if (editor && editor.IsOpened()) return;
if (!m_ERRObserverCamera)
{
Resource resource = Resource.Load("{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et");
@@ -323,24 +444,31 @@ modded class SCR_PlayerController
EntitySpawnParams params = new EntitySpawnParams();
params.TransformMode = ETransformMode.WORLD;
params.Transform[3] = m_ERRObserverAnchor + "0 5 -6";
m_ERRObserverCamera = SCR_ManualCamera.Cast(GetGame().SpawnEntityPrefab(resource, GetGame().GetWorld(), params));
m_ERRObserverCamera = SCR_ManualCamera.Cast(GetGame().SpawnEntityPrefabLocal(resource, GetGame().GetWorld(), params));
if (!m_ERRObserverCamera) return;
}
CameraManager manager = GetGame().GetCameraManager();
if (!manager || manager.CurrentCamera() != m_ERRObserverCamera) return;
if (!manager) return;
// Native death/respawn camera changes can arrive after our camera is created.
if (manager.CurrentCamera() != m_ERRObserverCamera) manager.SetCamera(m_ERRObserverCamera);
if (manager.CurrentCamera() != m_ERRObserverCamera) return;
SCR_RespawnSystemComponent respawn = SCR_RespawnSystemComponent.GetInstance();
if (respawn) respawn.DestroyLoadingPlaceholder();
// The server-selected replicated ID distinguishes no target from a target
// whose local entity is still streaming. Never imply no survivors from delay.
bool survivorAvailable = m_ERRObservedId != RplId.Invalid();
if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable))
string recovery = mode.RecoveryStatus();
if (ERR_ObserverGuidance.Refresh(m_ERRObserverReady, m_ERRShownObserverReason, m_ERRObserverReason, m_ERRShownObserverAvailable, survivorAvailable) || m_ERRShownRecoveryStatus != recovery)
{
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (!hints) return; // retry on the existing monitor; do not lose the first hint
if (!hints.ShowCustom(ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable), "Spectating", 12)) return;
string message = ERR_ObserverGuidance.Message(m_ERRObserverReason, survivorAvailable);
if (!recovery.IsEmpty()) message += "\n" + recovery;
if (!hints.ShowCustom(message, "Spectating", 12)) return;
m_ERRObserverReady = true;
m_ERRShownObserverReason = m_ERRObserverReason;
m_ERRShownObserverAvailable = survivorAvailable;
m_ERRShownRecoveryStatus = recovery;
}
}
// Track the actual local body even when an editor/other camera owns the view.
@@ -371,6 +499,7 @@ modded class SCR_PlayerController
protected void ERR_CloseObserverCamera()
{
m_ERRObserverReady = false;
m_ERRShownRecoveryStatus = "";
if (!m_ERRObserverCamera) return;
if (GetGame().GetPlayerController() == this) m_ERRObserverCamera.TrySwitchToControlledEntityCamera();
m_ERRObserverCamera.Terminate();
@@ -0,0 +1,332 @@
// Camera poses are cosmetic, owner-sampled and relayed only to authorized
// spectators of that body. No client-supplied position affects gameplay.
class ERR_ObserverViewRules
{
// One unreliable packet contains both cameras, so loss/reordering cannot
// pair a new eye pose with an old aiming pose. Four vectors for ordinary/2D,
// nine for PiP: scope pose/lens, eye pose/lens, native lens geometry.
static bool Packet(array<vector> packet)
{
if (!packet || (packet.Count() != 4 && packet.Count() != 9)) return false;
if (!Sample(packet[0], packet[1], packet[2], packet[3][0], packet[3][1])) return false;
if (packet.Count() == 4) return packet[3][2] == 0;
if (!(packet[3][2] >= 0.01 && packet[3][2] <= 150)) return false;
if (!Sample(packet[4], packet[5], packet[6], packet[7][0], packet[7][1])) return false;
return packet[7][2] >= 0 && packet[7][2] <= 100 && Math.AbsFloat(packet[8][0]) <= 90 && Math.AbsFloat(packet[8][1]) <= 90 && packet[8][2] >= 0.001 && packet[8][2] <= 100;
}
// The body and camera arrive through independently timed replication streams.
// Keep the sampled world axes and world-axis eye offset together. Applying
// the proxy body's rotation to delayed body-local aim adds its yaw error to
// both aim and eye position, even when the owner's camera is steady.
static void Encode(vector body[4], vector view[4], out vector pose[4])
{
for (int axis = 0; axis < 3; axis++) pose[axis] = view[axis];
pose[3] = view[3] - body[3];
}
static void Decode(vector body[4], vector pose[4], out vector view[4])
{
for (int axis = 0; axis < 3; axis++) view[axis] = pose[axis];
view[3] = body[3] + pose[3];
}
static bool Sample(vector position, vector forward, vector up, float fov, float nearPlane)
{
// Negated bounded comparisons also reject NaN/infinity before matrix math.
if (!(position.LengthSq() <= 900) || !(fov >= 1 && fov <= 150) || !(nearPlane >= 0.005 && nearPlane <= 2)) return false;
if (!(forward.LengthSq() >= 0.9 && forward.LengthSq() <= 1.1) || !(up.LengthSq() >= 0.9 && up.LengthSq() <= 1.1)) return false;
return Math.AbsFloat(vector.Dot(forward, up)) <= 0.05;
}
static bool Fresh(float now, float received) { return received >= 0 && now >= received && now - received <= 350; }
}
// Keep actual sampled poses on a short timeline instead of restarting a blend
// on every packet arrival. Only presentation is delayed; possession is unchanged.
class ERR_ObserverViewSample
{
int Stamp;
vector Position, Forward, Up;
float Fov, Near;
float PipFov, Vignette;
vector Geometry;
}
class ERR_ObserverViewBuffer
{
protected RplId m_Target = RplId.Invalid();
protected int m_Stamp = -1;
protected RplId m_ScopeWeapon = RplId.Invalid();
protected vector m_ScopeData;
protected float m_Received = -1;
protected float m_Playback, m_ReadAt;
protected float m_PipFov, m_Vignette;
protected vector m_Geometry;
protected ref array<ref ERR_ObserverViewSample> m_Samples = {};
void Scope(out RplId weapon, out vector data) { weapon = m_ScopeWeapon; data = m_ScopeData; }
bool Accepts(RplId target, int stamp) { return target != m_Target || stamp > m_Stamp; }
float Playback() { return m_Playback; }
void Align(float playback, float now) { m_Playback = playback; m_ReadAt = now; }
void PIP(out float fov, out vector geometry, out float vignette)
{ fov = m_PipFov; geometry = m_Geometry; vignette = m_Vignette; }
void Clear()
{
m_Target = RplId.Invalid(); m_Stamp = -1; m_Received = -1;
m_ScopeWeapon = RplId.Invalid(); m_ScopeData = vector.Zero;
m_Samples.Clear();
m_PipFov = 0; m_Vignette = 0; m_Geometry = vector.Zero;
}
void Push(RplId target, int stamp, float now, vector position, vector forward, vector up, float fov, float nearPlane, RplId scopeWeapon, vector scopeData, float pipFov = 0, vector geometry = "0 0 0", float vignette = 0)
{
if (!ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
if (!ERR_ObserverViewRules.Sample(position, forward, up, fov, nearPlane)) return;
if (target == m_Target && stamp <= m_Stamp) return;
if (target != m_Target || scopeWeapon != m_ScopeWeapon || scopeData[0] != m_ScopeData[0] || !ERR_ObserverViewRules.Fresh(now, m_Received))
{
m_Samples.Clear();
m_Playback = stamp - 100; m_ReadAt = now;
}
ERR_ObserverViewSample sample = new ERR_ObserverViewSample();
sample.Stamp = stamp; sample.Position = position; sample.Forward = forward; sample.Up = up;
sample.Fov = fov; sample.Near = nearPlane;
sample.PipFov = pipFov; sample.Geometry = geometry; sample.Vignette = vignette;
m_Samples.Insert(sample);
// Bounded even when first-person view is not being rendered.
while (m_Samples.Count() > 12) m_Samples.Remove(0);
m_ScopeWeapon = scopeWeapon; m_ScopeData = scopeData;
m_Target = target; m_Stamp = stamp; m_Received = now;
}
bool Read(RplId target, float now, float seconds, out vector local[4], out float fov, out float nearPlane)
{
if (target != m_Target || !ERR_ObserverViewRules.Fresh(now, m_Received) || m_Samples.IsEmpty()) return false;
// Use elapsed local time, not the number of reads or a packet's arrival time.
// No clock synchronization between clients is required: stamps are relative.
if (now - m_ReadAt > 250) m_Playback = m_Stamp - 100;
else m_Playback += Math.Max(0, now - m_ReadAt);
m_ReadAt = now;
// A long render stall resumes near live rather than replaying old movement.
if (m_Stamp - m_Playback > 250) m_Playback = m_Stamp - 100;
// Never extrapolate through walls or invent aim during a packet gap.
m_Playback = Math.Min(m_Playback, m_Stamp);
while (m_Samples.Count() > 2 && m_Samples[1].Stamp <= m_Playback) m_Samples.Remove(0);
ERR_ObserverViewSample start = m_Samples[0];
ERR_ObserverViewSample end = start;
if (m_Samples.Count() > 1) end = m_Samples[1];
float blend = 0;
if (end.Stamp > start.Stamp) blend = Math.Clamp((m_Playback - start.Stamp) / (end.Stamp - start.Stamp), 0, 1);
vector forward = vector.Lerp(start.Forward, end.Forward, blend);
vector up = vector.Lerp(start.Up, end.Up, blend);
if (forward.LengthSq() < 0.1 || up.LengthSq() < 0.1) { forward = end.Forward; up = end.Up; }
forward.Normalize(); up.Normalize();
if (Math.AbsFloat(vector.Dot(forward, up)) > 0.95) { forward = end.Forward; up = end.Up; }
Math3D.DirectionAndUpMatrix(forward, up, local);
local[3] = vector.Lerp(start.Position, end.Position, blend);
fov = Math.Lerp(start.Fov, end.Fov, blend); nearPlane = end.Near;
m_PipFov = Math.Lerp(start.PipFov, end.PipFov, blend);
m_Geometry = vector.Lerp(start.Geometry, end.Geometry, blend);
m_Vignette = Math.Lerp(start.Vignette, end.Vignette, blend);
return true;
}
}
modded class SCR_PlayerController
{
protected ref ERR_ObserverViewBuffer m_ERRViewBuffer = new ERR_ObserverViewBuffer();
protected ref ERR_ObserverViewBuffer m_ERREyeBuffer = new ERR_ObserverViewBuffer();
protected bool m_ERRHasPIP;
protected ref array<SCR_PlayerController> m_ERRViewWatchers = {};
protected float m_ERRViewServerRequestAt = -1000;
protected float m_ERRViewRequestAt = -1000, m_ERRViewLeaseUntil = -1;
protected float m_ERRViewPublishUntil = -1, m_ERRViewLeaseSent = -1000;
protected float m_ERRViewSendWait, m_ERRViewLastPacket = -1000;
protected int m_ERRViewSequence, m_ERRViewInputSequence;
protected IEntity m_ERRViewSourceBody;
bool ERR_ViewRecipient(IEntity target, float now)
{
ERR_GameMode mode = ERR_GameMode.Get();
return mode && mode.Active() && now <= m_ERRViewLeaseUntil && target && target == m_ERRServerObserved && ERR_Actors.CanFight(target) && mode.Players().CurrentActor(target) && SCR_FactionManager.SGetFaction(target) == mode.PlayerFaction() && GetGame().GetPlayerManager().GetPlayerController(GetPlayerId()) == this && ERR_ObserverRules.View(m_ERRObserverReason, true, ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
}
void ERR_ReadScope(out RplId weapon, out vector data) { m_ERRViewBuffer.Scope(weapon, data); }
bool ERR_ReadPlayerEye(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane, out float pipFov, out vector geometry, out float vignette)
{
if (!m_ERRHasPIP || this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
vector pose[4], world[4];
float now = GetGame().GetWorld().GetWorldTime();
// Use the exact aiming playhead even after a streamed optic temporarily
// fails resolution and this eye buffer is not read for several frames.
m_ERREyeBuffer.Align(m_ERRViewBuffer.Playback(), now);
if (!m_ERREyeBuffer.Read(m_ERRObservedId, now, seconds, pose, fov, nearPlane)) return false;
target.GetWorldTransform(world); ERR_ObserverViewRules.Decode(world, pose, transform);
m_ERRViewBuffer.PIP(pipFov, geometry, vignette);
return true;
}
bool ERR_ReadPlayerView(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane)
{
if (this != GetGame().GetPlayerController() || !target || !ERR_Actors.CanFight(target)) return false;
float now = GetGame().GetWorld().GetWorldTime();
if (now - m_ERRViewRequestAt >= 500)
{ m_ERRViewRequestAt = now; Rpc(ERR_RequestPlayerView); }
vector pose[4], world[4];
if (!m_ERRViewBuffer.Read(m_ERRObservedId, now, seconds, pose, fov, nearPlane)) return false;
RplId scopeWeapon; vector scopeData;
m_ERRViewBuffer.Scope(scopeWeapon, scopeData);
if (scopeWeapon != RplId.Invalid() && !ERR_ObserverScope.Resolve(target, scopeWeapon, scopeData)) return false;
target.GetWorldTransform(world);
ERR_ObserverViewRules.Decode(world, pose, transform);
return true;
}
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
protected void ERR_RequestPlayerView()
{
if (!Replication.IsServer()) return;
float now = GetGame().GetWorld().GetWorldTime();
if (now - m_ERRViewServerRequestAt < 250) return;
m_ERRViewServerRequestAt = now;
// A lease grants no observer rights; all existing server selection/roster
// checks still apply, both here and when each sample is forwarded.
m_ERRViewLeaseUntil = now + 1500;
if (!ERR_ViewRecipient(m_ERRServerObserved, now)) return;
ERR_PlayerLife life = ERR_GameMode.Get().Players().CurrentActor(m_ERRServerObserved);
SCR_PlayerController source = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId));
if (source) source.ERR_AddViewWatcher(this, now);
}
void ERR_AddViewWatcher(SCR_PlayerController watcher, float now)
{
if (!Replication.IsServer() || !watcher) return;
for (int i = m_ERRViewWatchers.Count() - 1; i >= 0; i--)
if (!m_ERRViewWatchers[i] || !m_ERRViewWatchers[i].ERR_ViewRecipient(GetControlledEntity(), now)) m_ERRViewWatchers.Remove(i);
if (!m_ERRViewWatchers.Contains(watcher) && m_ERRViewWatchers.Count() < 64) m_ERRViewWatchers.Insert(watcher);
if (now - m_ERRViewLeaseSent < 250) return;
m_ERRViewLeaseSent = now;
Rpc(ERR_EnableViewPublisher);
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_EnableViewPublisher()
{
if (this == GetGame().GetPlayerController()) m_ERRViewPublishUntil = GetGame().GetWorld().GetWorldTime() + 2000;
}
void ERR_PublishPlayerView(float seconds)
{
if (this != GetGame().GetPlayerController() || System.IsConsoleApp()) return;
float now = GetGame().GetWorld().GetWorldTime();
if (now > m_ERRViewPublishUntil) return;
m_ERRViewSendWait += seconds;
if (m_ERRViewSendWait < 0.05) return;
m_ERRViewSendWait = 0;
ERR_GameMode mode = ERR_GameMode.Get();
IEntity actor = GetControlledEntity();
PlayerCamera camera = GetPlayerCamera();
CameraManager manager = GetGame().GetCameraManager();
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor) || !camera || !manager || manager.CurrentCamera() != camera) return;
vector world[4], view[4], pose[4], eye[4];
actor.GetWorldTransform(world);
camera.GetWorldCameraTransform(view);
float fov = camera.GetVerticalFOV(), nearPlane = camera.GetNearPlane();
float eyeFov = fov;
ERR_ObserverViewRules.Encode(world, view, eye);
RplId scopeWeapon; vector scopeData;
ERR_ObserverScope.Sample(actor, view, fov, scopeWeapon, scopeData);
ERR_ObserverViewRules.Encode(world, view, pose);
if (!ERR_ObserverViewRules.Sample(pose[3], pose[2], pose[1], fov, nearPlane)) return;
array<vector> packet = {pose[3], pose[2], pose[1], Vector(fov, nearPlane, 0)};
SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(ERR_ObserverScope.Resolve(actor, scopeWeapon, scopeData));
if (pip && pip.IsPIPEnabled() && pip.GetPIPCamera())
{
packet[3] = Vector(fov, nearPlane, pip.GetPIPCamera().GetVerticalFOV());
packet.Insert(eye[3]); packet.Insert(eye[2]); packet.Insert(eye[1]);
packet.Insert(Vector(eyeFov, nearPlane, pip.ERR_ObserverPIPVignette(actor, camera))); packet.Insert(pip.ERR_ObserverPIPGeometry());
if (!ERR_ObserverViewRules.Packet(packet))
{
// An unusual addon lens must not suppress an otherwise valid 2D view.
packet.Resize(4); packet[3] = Vector(fov, nearPlane, 0);
}
}
if (!ERR_ObserverViewRules.Packet(packet)) return;
Rpc(ERR_SubmitPlayerView, ++m_ERRViewSequence, packet, scopeWeapon, scopeData);
}
[RplRpc(RplChannel.Unreliable, RplRcver.Server)]
protected void ERR_SubmitPlayerView(int sequence, array<vector> packet, RplId scopeWeapon, vector scopeData)
{
if (!Replication.IsServer() || !ERR_ObserverScope.Valid(scopeWeapon, scopeData) || !ERR_ObserverViewRules.Packet(packet)) return;
if (packet.Count() == 9 && scopeWeapon == RplId.Invalid()) return;
ERR_GameMode mode = ERR_GameMode.Get();
IEntity actor = GetControlledEntity();
if (!mode || !mode.Active() || !ERR_Actors.CanFight(actor)) return;
ERR_PlayerLife life = mode.Players().CurrentActor(actor);
if (!life || GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId) != this) return;
float now = GetGame().GetWorld().GetWorldTime();
if (actor != m_ERRViewSourceBody) { m_ERRViewSourceBody = actor; m_ERRViewInputSequence = 0; }
if (sequence <= m_ERRViewInputSequence || now - m_ERRViewLastPacket < 35) return;
m_ERRViewInputSequence = sequence; m_ERRViewLastPacket = now;
RplComponent component = RplComponent.Cast(actor.FindComponent(RplComponent));
if (!component) return;
for (int i = m_ERRViewWatchers.Count() - 1; i >= 0; i--)
{
SCR_PlayerController watcher = m_ERRViewWatchers[i];
if (!watcher || !watcher.ERR_ViewRecipient(actor, now)) { m_ERRViewWatchers.Remove(i); continue; }
watcher.ERR_DeliverPlayerView(component.Id(), now, packet, scopeWeapon, scopeData);
}
}
void ERR_DeliverPlayerView(RplId target, int stamp, array<vector> packet, RplId scopeWeapon, vector scopeData)
{
Rpc(ERR_ReceivePlayerViewPacket, target, stamp, packet, scopeWeapon, scopeData);
}
[RplRpc(RplChannel.Unreliable, RplRcver.Owner)]
protected void ERR_ReceivePlayerViewPacket(RplId target, int stamp, array<vector> packet, RplId scopeWeapon, vector scopeData)
{
ERR_ReceivePlayerView(target, stamp, packet, scopeWeapon, scopeData);
}
protected void ERR_ReceivePlayerView(RplId target, int stamp, array<vector> packet, RplId scopeWeapon, vector scopeData)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (this != GetGame().GetPlayerController() || target == RplId.Invalid() || target != m_ERRObservedId || !mode || !ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()))) return;
if (!ERR_ObserverViewRules.Packet(packet) || !ERR_ObserverScope.Valid(scopeWeapon, scopeData)) return;
if (packet.Count() == 9 && scopeWeapon == RplId.Invalid()) return;
// Reject before updating either stream or PiP metadata.
if (!m_ERRViewBuffer.Accepts(target, stamp)) return;
float now = GetGame().GetWorld().GetWorldTime();
bool pip = packet.Count() == 9;
if (pip != m_ERRHasPIP) { m_ERRViewBuffer.Clear(); m_ERREyeBuffer.Clear(); }
vector geometry; float vignette;
if (pip) { geometry = packet[8]; vignette = packet[7][2]; }
m_ERRViewBuffer.Push(target, stamp, now, packet[0], packet[1], packet[2], packet[3][0], packet[3][1], scopeWeapon, scopeData, packet[3][2], geometry, vignette);
m_ERRHasPIP = pip;
if (pip)
{
m_ERREyeBuffer.Push(target, stamp, now, packet[4], packet[5], packet[6], packet[7][0], packet[7][1], scopeWeapon, scopeData);
}
else m_ERREyeBuffer.Clear();
}
}
modded class ArmaReforgerScripted
{
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
SCR_PlayerController controller = SCR_PlayerController.Cast(GetPlayerController());
if (controller) controller.ERR_PublishPlayerView(timeslice);
}
}
// Use the game's own head/helmet fade rather than moving the camera ahead of
// the face. Changes are local and reversed when the observer stops using them.
class ERR_ObserverHeadVisibility
{
protected SCR_ChimeraCharacter m_Character;
protected SCR_CharacterCameraHandlerComponent m_Handler;
protected int m_Previous, m_Applied;
void Update(IEntity target, vector cameraPosition)
{
if (target != m_Character)
{
Restore();
m_Character = SCR_ChimeraCharacter.Cast(target);
if (!m_Character || !m_Character.GetCharacterController()) return;
m_Handler = SCR_CharacterCameraHandlerComponent.Cast(m_Character.GetCharacterController().GetCameraHandlerComponent());
m_Previous = m_Character.m_fFaceAlphaTest;
}
if (!m_Handler || !m_Character.GetAnimation()) return;
m_Handler.UpdateHeadVisibility(cameraPosition);
m_Applied = m_Character.m_fFaceAlphaTest;
}
void Restore()
{
if (m_Handler && m_Character && m_Character.m_fFaceAlphaTest == m_Applied) m_Handler.OnAlphatestChange(m_Previous);
m_Character = null; m_Handler = null;
}
void ~ERR_ObserverHeadVisibility() { Restore(); }
}
@@ -0,0 +1,254 @@
// Presentation only. PiP spectators use the lens renderer; other scopes use a
// private full-screen display. Never activate remote ADS input or its HUD.
class ERR_ObserverScope
{
static bool Valid(RplId weapon, vector data)
{
if (weapon == RplId.Invalid()) return data == vector.Zero;
return data[0] >= 1 && data[0] <= 32 && data[0] == Math.Round(data[0]) && data[1] >= -90 && data[1] <= 90 && (data[2] == 0 || data[2] == 1);
}
static void Sample(IEntity actor, out vector view[4], out float fov, out RplId weaponId, out vector data)
{
weaponId = RplId.Invalid(); data = vector.Zero;
ChimeraCharacter character = ChimeraCharacter.Cast(actor);
if (!character) return;
CharacterControllerComponent control = character.GetCharacterController();
if (!control || !control.IsWeaponADS()) return;
BaseWeaponManagerComponent weapons = control.GetWeaponManagerComponent();
if (!weapons || !weapons.GetCurrentWeapon()) return;
SCR_2DOpticsComponent optic = SCR_2DOpticsComponent.Cast(weapons.GetCurrentSights());
if (!optic || !optic.IsSightADSActive()) return;
RplComponent rpl = RplComponent.Cast(weapons.GetCurrentWeapon().GetOwner().FindComponent(RplComponent));
if (!rpl || rpl.Id() == RplId.Invalid()) return;
float scopeFov = optic.GetFOV();
if (!(scopeFov >= 1 && scopeFov <= 150)) return;
vector sample = Vector(weapons.GetCurrentSightsIndex() + 1, optic.GetCurrentReticleOffsetY(), 0);
if (optic.ERR_ObserverIlluminated()) sample[2] = 1;
if (!Valid(rpl.Id(), sample)) return;
// A PiP sight has its own sight-line camera. Use that direction rather than
// magnifying the peripheral eye camera, which need not point down the scope.
SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(optic);
if (pip && pip.IsPIPEnabled())
{
if (!pip.GetPIPCamera()) return;
pip.GetPIPCamera().GetWorldCameraTransform(view);
}
weaponId = rpl.Id(); data = sample; fov = scopeFov;
}
static SCR_2DOpticsComponent Resolve(IEntity target, RplId weaponId, vector data)
{
if (weaponId == RplId.Invalid() || !Valid(weaponId, data)) return null;
ChimeraCharacter character = ChimeraCharacter.Cast(target);
if (!character || !character.GetCharacterController()) return null;
BaseWeaponManagerComponent weapons = character.GetCharacterController().GetWeaponManagerComponent();
if (!weapons || !weapons.GetCurrentWeapon() || weapons.GetCurrentSightsIndex() + 1 != data[0]) return null;
RplComponent rpl = RplComponent.Cast(weapons.GetCurrentWeapon().GetOwner().FindComponent(RplComponent));
if (!rpl || rpl.Id() != weaponId) return null;
return SCR_2DOpticsComponent.Cast(weapons.GetCurrentSights());
}
}
modded class SCR_2DOpticsComponent
{
bool ERR_ObserverIlluminated() { return m_bIsIlluminationOn; }
}
// Reuse the native reticle textures, angular sizing and vignette layout, without
// registering with the player's HUD, ADS invokers, weapon input or visibility.
class ERR_ObserverScopeDisplay : SCR_2DOpticsDisplay
{
protected ref Widget m_ERRScopeRoot; // Native HUD normally owns this strong reference.
protected IEntity m_ERRHiddenWeapon, m_ERRHiddenOptic;
protected float m_ERRFov;
bool RenderScope(SCR_2DOpticsComponent optic, IEntity weapon, float fov, vector data)
{
if (!optic || !weapon) { Close(); return false; }
if (!m_wRoot)
{
m_ERRScopeRoot = GetGame().GetWorkspace().CreateWidgets("{4CE66FA8219D33D7}UI/layouts/Sights/Optic_Default.layout");
m_wRoot = m_ERRScopeRoot;
if (!m_wRoot) return false;
FindWidgets();
if (!m_wLayoutRear || !m_wLayoutFront || !m_wImgReticle || !m_wImgCover || !m_wBlur || !m_wRearFillLeft || !m_wRearFillTop || !m_wFrontFillLeft || !m_wFrontFillTop) { Close(); return false; }
m_wRoot.SetFlags(WidgetFlags.IGNORE_CURSOR);
m_wRoot.SetZOrder(1); // Standalone workspace root must be above the game viewport.
}
m_ERRFov = fov;
if (m_Optic != optic)
{
RestoreWeapon();
m_Optic = optic;
GetOpticData();
SetupOpticImage();
// Hide only this locally rendered weapon, never the soldier or inventory.
if ((weapon.GetFlags() & EntityFlags.VISIBLE) != 0)
{ m_ERRHiddenWeapon = weapon; weapon.ClearFlags(EntityFlags.VISIBLE, false); }
IEntity opticOwner = optic.GetOwner();
if (opticOwner != weapon && (opticOwner.GetFlags() & EntityFlags.VISIBLE) != 0)
{ m_ERRHiddenOptic = opticOwner; opticOwner.ClearFlags(EntityFlags.VISIBLE, false); }
}
UpdateCurrentFov();
// Native layout uses fill widgets rather than centered anchors. Recompute
// these for resolution/DPI changes, without adding remote animation sway.
WorkspaceWidget workspace = GetGame().GetWorkspace();
float width, height;
workspace.GetScreenSize(width, height);
width = workspace.DPIUnscale(width); height = workspace.DPIUnscale(height);
UpdateScale(1, 1, 1);
m_wFrontFillLeft.SetWidthOverride(LayerDefaultPosition(width, m_fObjectiveSize));
m_wFrontFillTop.SetHeightOverride(LayerDefaultPosition(height, m_fObjectiveSize));
m_wRearFillLeft.SetWidthOverride(LayerDefaultPosition(width, m_fVignetteSize));
m_wRearFillTop.SetHeightOverride(LayerDefaultPosition(height, m_fVignetteSize));
SCR_2DOpticsComponentClass settings = SCR_2DOpticsComponentClass.Cast(optic.GetComponentData(optic.GetOwner()));
if (settings)
{
if (data[2] == 1 && settings.HasIllumination()) ChangeReticleTint(settings.GetReticleIlluminationColor(), settings.GetReticleIlluminationColor());
else ChangeReticleTint(settings.GetReticleColor(), settings.GetReticleOutlineColor());
}
// The relayed pose is already the aiming camera; don't add another local
// weapon-animation sway (remote proxies can be several frames behind).
SetReticleOffset(optic.GetReticleOffsetX(), data[1]);
m_wImgCover.SetVisible(false); m_wBlur.SetIntensity(0); m_wBlur.SetOpacity(0);
m_wRoot.SetOpacity(1); m_wRoot.SetVisible(true);
return true;
}
override void UpdateCurrentFov(bool forceUpdate = false)
{
if (!forceUpdate && float.AlmostEqual(m_fOpticCurrentFov, m_ERRFov)) return;
m_fOpticCurrentFov = m_ERRFov;
UpdateReticleSize();
}
protected void RestoreWeapon()
{
if (m_ERRHiddenWeapon && !m_ERRHiddenWeapon.IsDeleted()) m_ERRHiddenWeapon.SetFlags(EntityFlags.VISIBLE, false);
if (m_ERRHiddenOptic && !m_ERRHiddenOptic.IsDeleted()) m_ERRHiddenOptic.SetFlags(EntityFlags.VISIBLE, false);
m_ERRHiddenWeapon = null; m_ERRHiddenOptic = null;
}
void Close()
{
RestoreWeapon();
m_Optic = null;
if (m_wRoot) m_wRoot.RemoveFromHierarchy();
m_wRoot = null; m_ERRScopeRoot = null;
}
void ~ERR_ObserverScopeDisplay() { Close(); }
}
// Use only the native PiP rendering lifecycle, never OnSightADSActivated or
// HandleSightActivation (those bind weapon input and change the player's HUD).
modded class SCR_2DPIPSightsComponent
{
protected bool m_ERRObserverPIPOwned;
vector ERR_ObserverPIPGeometry() { return Vector(m_fParallaxX, m_fParallaxY, m_fEdgeScale); }
float ERR_ObserverPIPVignette(IEntity actor, CameraBase camera)
{
SCR_2DPIPSightsComponentClass settings = SCR_2DPIPSightsComponentClass.Cast(GetComponentData(GetOwner()));
ChimeraCharacter character = ChimeraCharacter.Cast(actor);
if (!settings || !camera || !character || !character.GetCharacterController()) return 0;
BaseWeaponManagerComponent weapons = character.GetCharacterController().GetWeaponManagerComponent();
vector eye[4], sight[4]; float ignoredFov;
if (!weapons || !weapons.GetCurrentSightsCameraTransform(sight, ignoredFov)) return settings.GetBasicParallax();
camera.GetLocalTransform(eye);
float alignment = vector.Dot(eye[2], sight[2]);
if (Math.AbsFloat(alignment) < 0.001) return settings.GetMaxParallax();
// The same eye-to-reticle-plane distance used by native UpdateScopeMaterial.
float distance = Math.AbsFloat(vector.Dot(sight[3] - eye[3], sight[2]) / alignment) - settings.GetCenterDistance();
float power = settings.GetDistanceMoveNear();
if (distance > 0) power = settings.GetDistanceMoveFar();
float falloff = 1 - 1 / Math.Log2(power * Math.AbsFloat(distance) + 2);
return Math.Clamp(falloff * 100, settings.GetBasicParallax(), settings.GetMaxParallax());
}
bool ERR_ObserverPIPBegin()
{
if (m_ERRObserverPIPOwned) return m_bPIPIsEnabled && m_PIPCamera && m_pMaterial;
if (SCR_Global.IsScope2DEnabled() || IsPIPActive() || m_bPIPIsEnabled || !m_pMaterial || m_fScopeRadius <= 0) return false;
if (m_iReticleScaleIndex < 0 || m_iReticleColorIndex < 0 || m_iEdgeMinIndex < 0 || m_iEdgeMaxIndex < 0 || m_iVignettePowerIndex < 0) return false;
SCR_2DPIPSightsComponentClass data = SCR_2DPIPSightsComponentClass.Cast(GetComponentData(GetOwner()));
// Never reuse the main camera slot or a camera owned by another sight.
if (!data || data.GetCameraIndex() <= 0 || data.GetCameraIndex() > 31) return false;
m_ERRObserverPIPOwned = true;
SetPIPEnabled(true);
if (!m_bPIPIsEnabled || !m_PIPCamera || !m_wRenderTargetTextureWidget)
{ ERR_ObserverPIPEnd(); return false; }
return true;
}
void ERR_ObserverPIPEnd()
{
if (!m_ERRObserverPIPOwned) return;
m_ERRObserverPIPOwned = false;
if (m_bPIPIsEnabled) SetPIPEnabled(false);
else Destroy(); // also dispose a partially allocated camera/layout
}
bool ERR_ObserverPIPRender(vector scopeView[4], vector eyeView[4], float scopeFov, float pipFov, vector data, vector geometry, float vignette)
{
if (!m_ERRObserverPIPOwned || !m_bPIPIsEnabled || !m_PIPCamera || !m_pMaterial || !GetOwner() || GetOwner().IsDeleted()) return false;
if (!(pipFov >= 0.01 && pipFov <= 150) || !(scopeFov >= 1 && scopeFov <= 150)) return false;
SCR_2DPIPSightsComponentClass settings = SCR_2DPIPSightsComponentClass.Cast(GetComponentData(GetOwner()));
if (!settings) return false;
float reticlePIPScale = settings.GetReticlePIPScale();
if (!(reticlePIPScale > 0)) return false;
// Camera pose and lens geometry come from the owner. Never add proxy weapon
// animation/parallax a second time to an already sampled aiming direction.
m_PIPCamera.SetWorldTransform(scopeView);
m_PIPCamera.SetVerticalFOV(pipFov);
m_PIPCamera.ApplyProps(settings.GetCameraIndex());
UpdateHDR();
float angularSize, portion, baseZoom;
GetReticleData(angularSize, portion, baseZoom);
float reticleFov = scopeFov;
if (baseZoom > 0) reticleFov = CalculateZoomFOV(baseZoom);
else if (baseZoom < 0) reticleFov = -baseZoom;
float reticleScale = 1;
if (angularSize > 0) reticleScale = reticleFov * (pipFov / scopeFov) * portion / angularSize;
float parallaxX = geometry[0], parallaxY = geometry[1];
float objective = m_fObjectiveFov * m_fObjectiveScale;
float vignetteScale;
if (objective > 0) vignetteScale = settings.GetVignetteParallaxScale() / objective;
// Lens shading was sampled on the owner too. Native UpdateScopeMaterial
// would consult the spectator's own corpse/weapon manager instead.
m_pMaterial.SetParamByIndex(m_iVignetteCenterXIndex, -parallaxX * vignetteScale / pipFov);
m_pMaterial.SetParamByIndex(m_iVignetteCenterYIndex, -parallaxY * vignetteScale / pipFov);
m_pMaterial.SetParamByIndex(m_iVignettePowerIndex, vignette);
m_pMaterial.SetParamByIndex(m_iEdgeCenterXIndex, parallaxX * reticlePIPScale / pipFov);
m_pMaterial.SetParamByIndex(m_iEdgeCenterYIndex, parallaxY * reticlePIPScale / pipFov);
float edgeSize = objective * geometry[2] / pipFov;
m_pMaterial.SetParamByIndex(m_iEdgeMinIndex, 0.5 * reticlePIPScale * edgeSize * edgeSize * settings.GetObjectivePIPEdgeMin());
m_pMaterial.SetParamByIndex(m_iEdgeMaxIndex, 0.5 * reticlePIPScale * edgeSize * edgeSize * settings.GetObjectivePIPEdgeMax());
m_pMaterial.SetParamByIndex(m_iReticleScaleIndex, reticleScale / reticlePIPScale);
m_pMaterial.SetParamByIndex(m_iReticleOffsetXIndex, reticleScale * (parallaxX + GetReticleOffsetX()) / pipFov);
m_pMaterial.SetParamByIndex(m_iReticleOffsetYIndex, reticleScale * (parallaxY + data[1]) / pipFov);
Color color = settings.GetReticleColor();
if (data[2] == 1 && settings.HasIllumination()) color = settings.GetReticleIlluminationColor();
float rgba[4] = {color.R(), color.G(), color.B(), color.A()};
m_pMaterial.SetParamByIndex(m_iReticleColorIndex, rgba);
return true;
}
}
class ERR_ObserverPIPDisplay
{
protected SCR_2DPIPSightsComponent m_Optic;
protected SCR_2DPIPSightsComponent m_FailedOptic;
protected float m_RetryAt;
bool Render(SCR_2DOpticsComponent optic, vector scopeView[4], vector eyeView[4], float scopeFov, float pipFov, vector data, vector geometry, float vignette)
{
SCR_2DPIPSightsComponent pip = SCR_2DPIPSightsComponent.Cast(optic);
if (!pip || SCR_Global.IsScope2DEnabled()) { Close(); return false; }
float now = GetGame().GetWorld().GetWorldTime();
if (m_Optic != pip)
{
Close();
if (m_FailedOptic == pip && now < m_RetryAt) return false;
if (!pip.ERR_ObserverPIPBegin()) { m_FailedOptic = pip; m_RetryAt = now + 2000; return false; }
m_Optic = pip; m_FailedOptic = null;
}
if (m_Optic.ERR_ObserverPIPRender(scopeView, eyeView, scopeFov, pipFov, data, geometry, vignette)) return true;
Close(); m_FailedOptic = pip; m_RetryAt = now + 2000;
return false;
}
void Close()
{
if (m_Optic && m_Optic.GetOwner() && !m_Optic.GetOwner().IsDeleted()) m_Optic.ERR_ObserverPIPEnd();
m_Optic = null;
}
void ~ERR_ObserverPIPDisplay() { Close(); }
}
@@ -0,0 +1,19 @@
// Corpses lose their player-controller association. The round roster, not the
// current controller list, is the authority for which bodies belong to players.
class ERR_ObserverTargets
{
static void Bodies(ERR_GameMode mode, array<IEntity> targets)
{
if (!mode || !mode.Players() || !mode.PlayerFaction()) return;
array<ref ERR_PlayerLife> lives = {};
mode.Players().Participants(lives);
foreach (ERR_PlayerLife life : lives)
{
if (!life || !life.UsedLife || life.AccountId.IsNull() || !life.Actor || ERR_Actors.Alive(life.Actor)) continue;
// Native death cleanup can drop affiliation as well as controller ownership.
Faction faction = SCR_FactionManager.SGetFaction(life.Actor);
if ((faction && faction != mode.PlayerFaction()) || targets.Contains(life.Actor)) continue;
targets.Insert(life.Actor);
}
}
}
@@ -204,7 +204,7 @@ modded class ArmaReforgerScripted
protected ResourceName ERR_DiagnosticHeader(string project)
{
if (project == "core") return "{AD62322D5213C440}Missions/ERR_Everon_Base.conf";
if (project == "core") return "{C1E20667DCE324ED}Missions/ERR_Everon.conf";
return string.Empty;
}
@@ -0,0 +1,196 @@
// Read-only server telemetry. Durations are elapsed wall time at 1 ms resolution,
// not CPU time. Nested scopes must never be summed with their parent scopes.
enum ERR_PerfMetric
{
MISSION_TICK, MAINTENANCE, CIVILIANS, FORCES, OBSERVERS, HQ_TICK, LISTENERS,
HQ_PREP, HQ_HUNTERS, HQ_GARRISONS, HQ_CONVOYS, HQ_BODIES, HQ_RADIO_INDEX,
HQ_REPORTS, HQ_PLANNER, HQ_SUPPORT, HQ_JOURNAL,
UPDATE_INTERVAL, ENGINE_SIM_SAMPLE, ENGINE_FRAME_SAMPLE, ENGINE_FPS_SAMPLE,
ACTIVE_AI_SAMPLE, PLAYERS_SAMPLE, HQ_GROUPS_SAMPLE, GARRISON_WORK, PERF_FLUSH, COUNT
}
class ERR_PerfStat
{
int Samples;
int Zero;
int Over50;
int Over100;
float Sum;
float Min;
float Max;
void Add(float value)
{
if (!ERR_ForceRules.Finite(value) || value < 0) return;
if (Samples == 0 || value < Min) Min = value;
if (value > Max) Max = value;
Samples++; Sum += value;
if (value == 0) Zero++;
if (value > 50) Over50++;
if (value > 100) Over100++;
}
void Reset() { Samples = 0; Zero = 0; Over50 = 0; Over100 = 0; Sum = 0; Min = 0; Max = 0; }
}
class ERR_Performance
{
protected const string LOG_PATH = "$logs:rereintroduced-performance.log";
protected ref array<ref ERR_PerfStat> m_Stats = {};
protected ref array<string> m_Names = {
"mission_tick_ms", "maintenance_ms", "civilians_ms", "forces_ms", "observers_ms", "hq_tick_ms", "listeners_ms",
"hq_prep_ms", "hq_hunters_ms", "hq_garrisons_ms", "hq_convoys_ms", "hq_bodies_ms", "hq_radio_index_ms",
"hq_reports_ms", "hq_planner_ms", "hq_support_ms", "hq_journal_ms",
"update_interval_ms", "engine_sim_sample_ms", "engine_frame_sample_ms", "engine_fps_sample",
"active_ai_sample", "players_sample", "hq_groups_sample", "garrison_work_ms", "performance_flush_ms"
};
protected string m_Run;
protected int m_Window;
protected int m_Start;
protected int m_LastUpdate = -1;
protected int m_LastSample = -1;
protected int m_PhaseStart;
protected int m_PhaseEnd;
protected bool m_InTick;
protected bool m_Stopping;
protected bool m_Closed;
protected bool m_HeaderWritten;
protected bool m_LogErrorReported;
protected int m_LogRetryAt;
void ERR_Performance()
{
for (int i = 0; i < ERR_PerfMetric.COUNT; i++) m_Stats.Insert(new ERR_PerfStat());
m_Start = System.GetTickCount();
m_Run = string.Format("%1-%2", System.GetUnixTime(), m_Start);
FileHandle file = OpenLog();
if (file) { WriteMemory(file, "mode_created", null); file.Close(); }
}
protected FileHandle OpenLog()
{
FileHandle file = FileIO.OpenFile(LOG_PATH, FileMode.APPEND);
if (!file)
{
m_LogRetryAt = System.GetTickCount() + 60000;
if (!m_LogErrorReported)
{
PrintFormat("Co10 Escape Reintroduced [performance]: cannot open %1; retry in 60s. Measurements will not be sent to the console.", LOG_PATH, level: LogLevel.ERROR);
m_LogErrorReported = true;
}
return null;
}
if (!m_HeaderWritten)
{
file.WriteLine(string.Format("ERR PERF START schema=1 run=%1 clock=wall_ms resolution_ms=1 window_ms=60000 engine_sample_ms=1000 native_ai_time=unavailable disable=-errNoPerf", m_Run));
m_HeaderWritten = true;
}
return file;
}
// Native allocation accounting is not Linux RSS. Pair it with the lightweight
// host sampler when investigating retention across scenario reloads.
static void WriteMemory(FileHandle file, string stage, ERR_GameMode mode)
{
int roots = -1, phase = -1, players = -1, activeAI = -1;
if (mode) { roots = mode.OwnedRootCount(); phase = mode.Phase(); }
if (GetGame() && GetGame().GetPlayerManager()) players = GetGame().GetPlayerManager().GetPlayerCount();
if (GetGame() && GetGame().GetAIWorld()) activeAI = GetGame().GetAIWorld().GetCurrentNumOfActiveAIs();
file.WriteLine(string.Format("ERR MEMORY unix=%1 stage=%2 engine_allocated_kb=%3 phase=%4 players=%5 active_ai=%6 owned_roots=%7", System.GetUnixTime(), stage, System.MemoryAllocationKB(), phase, players, activeAI, roots));
}
static void WorldCleanup()
{
if (!Replication.IsServer() || System.IsCLIParam("errNoPerf")) return;
FileHandle file = FileIO.OpenFile("$logs:rereintroduced-performance.log", FileMode.APPEND);
if (file) { WriteMemory(file, "world_cleanup_requested", ERR_GameMode.Get()); file.Close(); }
}
int BeginTick() { m_InTick = true; return System.GetTickCount(); }
void EndTick(int started)
{
Mark(ERR_PerfMetric.MISSION_TICK, started);
m_InTick = false;
if (m_Stopping) Flush(true);
}
int Mark(ERR_PerfMetric metric, int started)
{
int ended = System.GetTickCount();
if (!m_Closed) m_Stats[metric].Add(ended - started);
return ended;
}
void Groups(int count) { if (!m_Closed) m_Stats[ERR_PerfMetric.HQ_GROUPS_SAMPLE].Add(count); }
void Stop(int phase)
{
if (m_Closed) return;
m_PhaseEnd = phase;
m_Stopping = true;
// A terminal transition may happen inside ServerTick; include its duration.
if (!m_InTick) Flush(true);
}
void Update(ERR_GameMode mode)
{
if (m_Closed || m_Stopping) return;
int now = System.GetTickCount();
m_PhaseEnd = mode.Phase();
if (m_LastUpdate < 0) m_PhaseStart = m_PhaseEnd;
else m_Stats[ERR_PerfMetric.UPDATE_INTERVAL].Add(now - m_LastUpdate);
m_LastUpdate = now;
if (m_LastSample < 0 || now - m_LastSample >= 1000)
{
m_LastSample = now;
ProfileData data = System.GetProfileData();
if (data)
{
m_Stats[ERR_PerfMetric.ENGINE_SIM_SAMPLE].Add(data.SimulationTimeMs);
m_Stats[ERR_PerfMetric.ENGINE_FRAME_SAMPLE].Add(data.FrameTimeMs);
m_Stats[ERR_PerfMetric.ENGINE_FPS_SAMPLE].Add(data.FPS);
}
AIWorld ai = GetGame().GetAIWorld();
if (ai) m_Stats[ERR_PerfMetric.ACTIVE_AI_SAMPLE].Add(ai.GetCurrentNumOfActiveAIs());
m_Stats[ERR_PerfMetric.PLAYERS_SAMPLE].Add(GetGame().GetPlayerManager().GetPlayerCount());
}
if (now - m_Start >= 60000 && !m_InTick) Flush(false);
}
protected void Flush(bool finalWindow)
{
int ended = System.GetTickCount();
int elapsed = ended - m_Start;
if (elapsed <= 0) { if (finalWindow) m_Closed = true; return; }
if (!finalWindow && ended < m_LogRetryAt) return;
FileHandle file = OpenLog();
if (!file) { if (finalWindow) m_Closed = true; return; }
m_Window++;
string memoryStage = "minute";
if (finalWindow) memoryStage = "mode_stopped";
WriteMemory(file, memoryStage, ERR_GameMode.Get());
// Every row is self-contained, including partial windows on clean shutdown.
for (int i = 0; i < m_Stats.Count(); i++)
{
ERR_PerfStat stat = m_Stats[i];
if (stat.Samples > 0)
{
string row = string.Format("ERR PERF run=%1 window=%2 elapsed_ms=%3 final=%4 phase_start=%5 phase_end=%6 metric=%7", m_Run, m_Window, elapsed, finalWindow, m_PhaseStart, m_PhaseEnd, m_Names[i]);
row += string.Format(" samples=%1 sum=%2 min=%3 max=%4 zero=%5 over50=%6 over100=%7", stat.Samples, stat.Sum, stat.Min, stat.Max, stat.Zero, stat.Over50, stat.Over100);
file.WriteLine(row);
}
stat.Reset();
}
file.Close();
// Record this flush in the next window; never recursively log the logger.
if (!finalWindow) m_Stats[ERR_PerfMetric.PERF_FLUSH].Add(System.GetTickCount() - ended);
m_Start = ended; m_PhaseStart = m_PhaseEnd;
if (finalWindow) m_Closed = true;
}
}
modded class ArmaReforgerScripted
{
override protected void OnBeforeWorldCleanup()
{
ERR_Performance.WorldCleanup();
super.OnBeforeWorldCleanup();
}
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
if (!world || !Replication.IsServer()) return;
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.GetWorld() == world && mode.Performance()) mode.Performance().Update(mode);
}
}
@@ -30,6 +30,14 @@ modded class SCR_PlayerController
}
protected void ERR_UpdateGuide()
{
// A retained controller may outlive its local ownership during replacement.
if (!GetGame()) return;
if (this != GetGame().GetPlayerController())
{
GetGame().GetCallqueue().Remove(ERR_UpdateGuide);
m_ERRGuideStarted = false;
return;
}
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode) return;
if (mode.Phase() != ERR_Phase.PREPARING && (ERR_ObserverState() != ERR_ObserverReason.NONE || !ERR_Actors.Alive(GetControlledEntity()))) return;
@@ -45,6 +45,23 @@ modded class SCR_CharacterDamageManagerComponent
if (!m_ERRRevivablePlayer) { super.Kill(instigator); return; }
ERR_RequestKnockout();
}
#ifdef WORKBENCH
// Explicit terminal-lifecycle fixture only. Ordinary Kill retains casualty protection.
// This helper belongs in the same modded class so super bypasses our Kill override.
bool ERR_ForceFatalLossForTest(notnull Instigator instigator, IEntity expectedActor)
{
string enabled;
if (!Replication.IsServer() || !System.GetCLIParam("errSmoke", enabled) || enabled != "1") return false;
IEntity actor = GetOwner();
ERR_GameMode mode = ERR_GameMode.Get();
if (!expectedActor || actor != expectedActor || !ERR_Actors.Alive(actor) || !mode || !mode.Active() || !mode.Players() || !mode.Players().CurrentActor(actor)) return false;
// Vanilla Kill uses HandleDamage, so the ordinary hitzone cap also applies.
// Disable it for this exact terminal fixture actor through deferred processing.
m_ERRRevivablePlayer = false;
super.Kill(instigator);
return true;
}
#endif
// Include delayed but viable native regeneration when deciding team defeat.
// A stopped/zero-rate effect or an actively bleeding casualty cannot self-rescue.
bool ERR_CanRecoverNaturally()
@@ -89,10 +106,22 @@ modded class SCR_CharacterDamageManagerComponent
modded class SCR_HitZone
{
protected bool m_ERRWoundQuery;
// Native bleeding evaluates the hit again after health has been deducted.
// Its severity must retain armour/reductions, but not the remaining-life cap.
float ERR_ComputeWoundDamage(notnull BaseDamageContext context)
{
bool previous = m_ERRWoundQuery;
m_ERRWoundQuery = true;
float damage = ComputeEffectiveDamage(context, false);
m_ERRWoundQuery = previous;
return damage;
}
override float ComputeEffectiveDamage(notnull BaseDamageContext damageContext, bool isDOT)
{
float damage = super.ComputeEffectiveDamage(damageContext, isDOT);
if (!Replication.IsServer() || damage <= 0 || SCR_CharacterResilienceHitZone.Cast(this)) return damage;
if (m_ERRWoundQuery || !Replication.IsServer() || damage <= 0 || SCR_CharacterResilienceHitZone.Cast(this)) return damage;
SCR_CharacterDamageManagerComponent manager = SCR_CharacterDamageManagerComponent.Cast(GetHitZoneContainer());
if (!manager || !manager.ERR_IsRevivablePlayer()) return damage;
float allowed = ERR_PlayerMedicalRules.AllowedDamage(damage, GetHealth(), GetMaxHealth(), GetDamageStateThreshold(EDamageState.DESTROYED));
@@ -101,6 +130,27 @@ modded class SCR_HitZone
}
}
modded class SCR_CharacterDamageManagerEvaluator
{
// Preserve the native critical threshold for bullets, fragments, collisions,
// melee and critical explosions. Ordinary damage and blood loss stay capped.
override private void CriticalThresholdTryCauseBleeding(SCR_InstantDamageEffect effect, ExtendedDamageManagerComponent dmgManager)
{
SCR_CharacterDamageManagerComponent manager = SCR_CharacterDamageManagerComponent.Cast(dmgManager);
if (!Replication.IsServer() || !manager || !manager.ERR_IsRevivablePlayer())
{
super.CriticalThresholdTryCauseBleeding(effect, dmgManager);
return;
}
SCR_CharacterHitZone zone = SCR_CharacterHitZone.Cast(effect.GetAffectedHitZone());
if (!zone) return;
vector hit[3];
SCR_DamageContext context = new SCR_DamageContext(effect.GetDamageType(), effect.GetTotalDamage(), hit, dmgManager.GetOwner(), zone, effect.GetInstigator(), null, -1, -1);
if (zone.ERR_ComputeWoundDamage(context) < zone.GetCriticalHealthThreshold()) return;
manager.AddBleedingEffectOnHitZone(zone);
}
}
// Native resilience ignores the per-character regeneration setting. Apply the
// same slow rate here, only to passive recovery; injected medicine keeps its DPS.
modded class SCR_CharacterResilienceHitZone
@@ -0,0 +1,87 @@
class ERR_ReconnectRules
{
static int Timeout(int configured)
{
// Keep an explicitly unlimited native reservation, or a longer server window.
if (configured <= 0) return configured;
return Math.Max(900, configured);
}
}
modded class SCR_ReconnectComponent
{
protected override void HandleDataExpiery(notnull SCR_ReconnectData data)
{
// A delayed native possession can succeed after its immediate return value
// reported failure. Expiry must never delete a now-controlled character.
if (ERR_GameMode.Get() && data.m_ReservedEntity && GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(data.m_ReservedEntity) > 0) return;
if (ERR_GameMode.Get()) PrintFormat("Reintroduced [reconnect]: unclaimed reservation expired; bodyPresent=%1", data.m_ReservedEntity != null);
super.HandleDataExpiery(data);
}
override int GetReconnectTimeout()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.Active()) return ERR_ReconnectRules.Timeout(super.GetReconnectTimeout());
return super.GetReconnectTimeout();
}
override bool HandlePlayerDisconnect(int playerId, KickCauseCode cause)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !mode.Active()) return super.HandlePlayerDisconnect(playerId, cause);
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(playerId);
ERR_PlayerLife life = mode.Players().CurrentActor(actor);
if (!life || life.ConnectionId != playerId) return false;
UUID identity = SCR_PlayerIdentityUtils.GetPlayerIdentityId(playerId);
if (identity.IsNull() || identity != life.AccountId) return false;
// Keep the actual body, inventory, injuries and seat. No replacement life or
// equipment copy is created, and the body remains vulnerable in the world.
SCR_ReconnectData data = StoreData(playerId);
if (!IsDataRelevant(data) || data.m_ReservedEntity != life.Actor) return false;
int seconds = GetReconnectTimeout();
if (seconds > 0) data.m_ValidUntil = GetGame().GetWorld().GetTimestamp().PlusSeconds(seconds);
m_mReconnectData.Set(identity, data);
UpdateExpieryCheck();
PrintFormat("Reintroduced [reconnect]: player %1 original body reserved; timeoutSeconds=%2", playerId, seconds);
return true;
}
override bool HandlePlayerReconnect(int playerId)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode) return super.HandlePlayerReconnect(playerId);
// Audit/bind happens before this call. Never recover a different round's body,
// somebody else's controlled character, an expired reservation or a dead life.
if (!mode.Active()) return false;
ERR_PlayerLife life = mode.Players().Get(playerId);
UUID identity = SCR_PlayerIdentityUtils.GetPlayerIdentityId(playerId);
if (!life || !life.Connected || !life.UsedLife || identity.IsNull() || life.AccountId != identity) return false;
SCR_ReconnectData data;
if (!m_mReconnectData.Find(identity, data)) return false;
IEntity actor = data.m_ReservedEntity;
int owner;
if (actor) owner = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(actor);
if (!actor || actor != life.Actor || (owner > 0 && owner != playerId))
{
m_mReconnectData.Remove(identity); UpdateExpieryCheck();
PrintFormat("Reintroduced [reconnect]: player %1 rejected mismatched body reservation", playerId);
return false;
}
IEntity controlled = GetGame().GetPlayerManager().GetPlayerControlledEntity(playerId);
if (controlled && controlled != actor) return false;
if (IsDataExpired(data, GetGame().GetWorld().GetTimestamp()) || !IsDataRelevant(data))
{
m_mReconnectData.Remove(identity); HandleDataExpiery(data); UpdateExpieryCheck();
PrintFormat("Reintroduced [reconnect]: player %1 reservation expired or body lost; no replacement life", playerId);
return false;
}
bool restored = super.HandlePlayerReconnect(playerId);
if (!restored)
{
// Native faction/possession rejection must not orphan a live body by consuming
// the reservation. Preserve its original expiry, without renewing the window.
m_mReconnectData.Set(identity, data); UpdateExpieryCheck();
PrintFormat("Reintroduced [reconnect]: player %1 native handoff failed; original reservation retained", playerId);
return false;
}
PrintFormat("Reintroduced [reconnect]: player %1 returned to original body and equipment", playerId);
return true;
}
}
@@ -98,21 +98,3 @@ class ERR_PlayerRoster
return false;
}
}
modded class SCR_ReconnectComponent
{
override bool HandlePlayerDisconnect(int playerId, KickCauseCode cause)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !mode.Active()) return super.HandlePlayerDisconnect(playerId, cause);
UUID identity = SCR_PlayerIdentityUtils.GetPlayerIdentityId(playerId);
if (identity.IsNull()) return super.HandlePlayerDisconnect(playerId, cause);
// Voluntary and involuntary departures use the same vanilla reservation,
// timeout, character handoff and expiry cleanup. No replacement life is issued.
SCR_ReconnectData data = StoreData(playerId);
if (!IsDataRelevant(data)) return false;
m_mReconnectData.Set(identity, data);
UpdateExpieryCheck();
return true;
}
}
@@ -13,6 +13,14 @@ modded class SCR_RespawnSystemComponent
protected string m_ERRPreparationLastDescription;
protected bool m_ERRKeepSpawnSceneLoading;
protected override void CreateLoadingPlaceholder()
{
super.CreateLoadingPlaceholder();
if (System.IsConsoleApp() || !ERR_GameMode.Cast(m_pGameMode) || !GetGame().GetWorld()) return;
DeployMenuSystem updates = DeployMenuSystem.Cast(GetGame().GetWorld().FindSystem(DeployMenuSystem));
if (updates) updates.ERR_BindEscapeRespawn(this);
}
void ERR_KeepSpawnSceneLoading(bool keep) { m_ERRKeepSpawnSceneLoading = keep; }
void ERR_ShowSpawnSceneLoading()
{
@@ -45,6 +53,14 @@ modded class SCR_RespawnSystemComponent
override void UpdateLoadingPlaceholder(float dt)
{
ERR_GameMode current = ERR_GameMode.Get();
if (current && m_pGameMode == current && ERR_MissionRules.Terminal(current.Phase()))
{
// A cancelled preload lease must never obscure the native result screen.
m_ERRKeepSpawnSceneLoading = false;
DestroyLoadingPlaceholder();
return;
}
super.UpdateLoadingPlaceholder(dt);
// Vanilla OnInit(IEntity owner) stores its owning mode in m_pGameMode.
ERR_GameMode mode = ERR_GameMode.Get();
@@ -97,7 +113,8 @@ modded class SCR_RespawnSystemComponent
override void DestroyLoadingPlaceholder()
{
if (m_ERRKeepSpawnSceneLoading) return;
ERR_GameMode mode = ERR_GameMode.Get();
if (m_ERRKeepSpawnSceneLoading && mode && m_pGameMode == mode && (mode.Phase() == ERR_Phase.PREPARING || mode.Active())) return;
ERR_ClearPreparationStatus();
super.DestroyLoadingPlaceholder();
}
@@ -4,6 +4,10 @@
modded class SCR_ChimeraAIAgent
{
protected ERR_GameMode m_ERRPrisonMode;
#ifdef WORKBENCH
// Observe only this mission hold; native distance LOD may independently deactivate AI.
bool ERR_PrisonHeldForTest() { return m_ERRPrisonMode != null; }
#endif
void ERR_HoldForPrison()
{
@@ -94,7 +94,7 @@ class ERR_PrisonEscapeService
m_Samples.Clear();
if (!Replication.IsServer() || m_Stopped || current != ERR_Phase.PRISON || m_Registered) return;
m_RegistrationReason = "no unique owned authored prison";
if (!m_Mode || !m_Mode.Sites()) return;
if (!m_Mode || m_Mode.StartMode() != 0 || !m_Mode.Sites()) return;
array<ERR_LocationComponent> locations = {};
m_Mode.Locations(locations);
IEntity prison;
@@ -146,7 +146,7 @@ class ERR_PrisonEscapeService
}
void Tick(float now)
{
if (!Replication.IsServer() || m_Stopped || !m_Mode || m_Mode.Phase() != ERR_Phase.PRISON || !m_Mode.Active() || !m_Registered) return;
if (!Replication.IsServer() || m_Stopped || !m_Mode || m_Mode.Phase() != ERR_Phase.PRISON || m_Mode.StartMode() != 0 || !m_Mode.Active() || !m_Registered) return;
ref map<UUID, ref ERR_PrisonEscapeSample> next = new map<UUID, ref ERR_PrisonEscapeSample>();
array<ref ERR_PlayerLife> lives = {};
m_Mode.Players().Participants(lives);
@@ -0,0 +1,242 @@
// One operator-owned document. Legacy names identify sections internally only;
// custom paths used by isolated probes keep their original standalone behavior.
class ERR_ProfileConfig
{
static const string PATH = "$profile:Escapists.json";
static const string VOICE = "$profile:EscapistsWelcomeVoice.json";
static array<string> LegacyPaths()
{
return {ERR_Settings.PATH, ERR_ContentSettings.PATH, ERR_FactionSelection.PATH, ERR_TerrainPreference.PATH, ERR_ItemPreferences.PATH, VOICE};
}
static string Section(string path)
{
if (path == ERR_Settings.PATH) return "mission";
if (path == ERR_ContentSettings.PATH) return "mods";
if (path == ERR_FactionSelection.PATH) return "factions";
if (path == ERR_TerrainPreference.PATH) return "terrain";
if (path == ERR_ItemPreferences.PATH) return "items";
if (path == VOICE) return "voice";
return "";
}
static bool Text(string path, out string text)
{
FileHandle file = FileIO.OpenFile(path, FileMode.READ);
if (!file) return false;
int length = file.GetLength();
if (length < 2 || length > 8388608) { file.Close(); return false; }
file.Read(text, length); file.Close();
return text.Length() == length;
}
// The engine validates JSON syntax. This scan only separates root members,
// retaining unknown fields and dormant addon entries without reinterpreting them.
static bool Split(string text, map<string, string> members)
{
members.Clear(); text.TrimInPlace();
JsonLoadContext valid = new JsonLoadContext();
if (text.Length() < 2 || text.Substring(0, 1) != "{" || !valid.LoadFromString(text)) return false;
int depth, start = 1, colon = -1;
bool quoted, escaped;
string block;
for (int i = 0; i < text.Length(); i++)
{
if (i % 1024 == 0) block = text.Substring(i, Math.Min(1024, text.Length() - i));
string ch = block.Substring(i % 1024, 1);
if (quoted)
{
if (escaped) escaped = false;
else if (ch == "\\") escaped = true;
else if (ch == "\"") quoted = false;
continue;
}
if (ch == "\"") { quoted = true; continue; }
if (ch == "{" || ch == "[") depth++;
if (ch == "}" || ch == "]") depth--;
if (depth == 1 && ch == ":" && colon < 0) colon = i;
if ((depth == 1 && ch == ",") || (depth == 0 && ch == "}"))
{
if (colon < start)
{
string empty = text.Substring(start, i - start); empty.TrimInPlace();
return members.Count() == 0 && empty.IsEmpty() && i == text.Length() - 1;
}
JsonLoadContext keyJson = new JsonLoadContext();
string key;
if (!keyJson.LoadFromString("{\"key\":" + text.Substring(start, colon - start) + "}") || !keyJson.ReadValue("key", key) || members.Contains(key)) return false;
string value = text.Substring(colon + 1, i - colon - 1); value.TrimInPlace();
members.Set(key, value); start = i + 1; colon = -1;
}
}
return depth == 0 && !quoted;
}
static bool Document(string path, out map<string, string> members)
{
string text; members = new map<string, string>();
if (!Text(path, text) || !Split(text, members)) return false;
// Never downgrade or rewrite a configuration created by an unknown version.
return members.Contains("configVersion") && members.Get("configVersion") == "1";
}
static bool Valid(string section, string text)
{
JsonLoadContext json = new JsonLoadContext();
map<string, string> fields = new map<string, string>();
if (!Split(text, fields) || !json.LoadFromString(text)) return false;
map<string, int> numbers; map<string, string> roles; ResourceName scenario; array<string> records;
if (section == "mission") return ERR_Settings.Parse(json, numbers);
if (section == "mods") return ERR_ContentSettings.Parse(json, numbers);
if (section == "factions") return ERR_FactionSelection.Parse(json, roles);
if (section == "terrain") return ERR_TerrainPreference.Parse(json, scenario);
if (section == "items") return ERR_ItemPreferences.Parse(json, records);
string url;
return section == "voice" && json.ReadValue("welcomeUrl", url) && (url.IsEmpty() || ERR_WelcomeVoiceRules.URL(url));
}
static string Defaults(string section)
{
if (section == "mods") return "{}";
if (section == "factions") return "{\"escapists\":\"us\",\"enemy\":\"ussr\",\"independent\":\"fia\"}";
if (section == "terrain") return "{\"scenario\":\"\"}";
if (section == "items") return "{\"version\":1,\"records\":[\"active\"]}";
if (section == "voice") return "{\"welcomeUrl\":\"\"}";
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
foreach (string key : ERR_Settings.Keys())
{
int value = ERR_Settings.AddedDefault(key);
if (key == "reportIntervalSeconds") value = 10;
if (key == "supportSquadLimit") value = 4;
if (key == "responseRadiusMetres") value = 2000;
if (key == "aiIntelChancePercent") value = 60;
if (key == "intelRevealChancePercent") value = 15;
if (key == "radioDefenceSeconds") value = 180;
json.WriteValue(key, value);
}
return json.SaveToString();
}
static string Encode(map<string, string> members)
{
string result = "{\n";
int index;
foreach (string key, string raw : members)
{
// Quote arbitrary root keys through the engine, including escaped characters.
JsonSaveContext name = new JsonSaveContext(); name.EnableTypeDiscriminator(false); name.WriteValue("key", key);
string pair = name.SaveToString(); int colon = pair.IndexOf(":");
string quotedKey = pair.Substring(colon + 1, pair.Length() - colon - 2); quotedKey.TrimInPlace();
if (index > 0) result += ",\n";
result += " " + quotedKey + ": " + raw; index++;
}
return result + "\n}\n";
}
static bool Same(map<string, string> a, map<string, string> b)
{
if (!a || !b || a.Count() != b.Count()) return false;
foreach (string key, string value : a) if (!b.Contains(key) || b.Get(key) != value) return false;
return true;
}
// A section may have loaded from the backup while the primary remains valid
// JSON. Never promote that damaged document or overwrite its recovery copy.
static bool ContentsValid(map<string, string> members)
{
foreach (string path : LegacyPaths())
{
string section = Section(path);
if (members.Contains(section) && !Valid(section, members.Get(section))) return false;
}
return true;
}
static bool Commit(map<string, string> members)
{
if (!Replication.IsServer() || !ContentsValid(members)) return false;
FileHandle file = FileIO.OpenFile(PATH + ".tmp", FileMode.WRITE);
if (!file) return false;
file.Write(Encode(members)); file.Close();
map<string, string> checked, previous;
if (!Document(PATH + ".tmp", checked) || !Same(members, checked)) return false;
bool hadPrevious = Document(PATH, previous) && ContentsValid(previous);
if (hadPrevious)
{
if (!FileIO.CopyFile(PATH, PATH + ".bak") || !Document(PATH + ".bak", checked) || !Same(previous, checked)) return false;
}
if (!FileIO.CopyFile(PATH + ".tmp", PATH) || !Document(PATH, checked) || !Same(members, checked))
{
if (FileIO.FileExists(PATH + ".bak")) FileIO.CopyFile(PATH + ".bak", PATH);
else if (!hadPrevious) FileIO.DeleteFile(PATH);
return false;
}
FileIO.DeleteFile(PATH + ".tmp");
return true;
}
static bool Ensure()
{
if (!Replication.IsServer()) return false;
// Existing unified files are authoritative; never reimport stale legacy data.
if (FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak"))
{
string existing; map<string, string> root = new map<string, string>();
if (Text(PATH, existing) && Split(existing, root) && (!root.Contains("configVersion") || root.Get("configVersion") != "1"))
{
Print("Reintroduced [config]: unsupported Escapists.json configVersion; file preserved, writes disabled", LogLevel.ERROR);
return false;
}
return true;
}
map<string, string> members = new map<string, string>(); members.Set("configVersion", "1");
foreach (string path : LegacyPaths())
{
string section = Section(path), raw;
if (!Text(path, raw) || !Valid(section, raw))
{
if (!Text(path + ".bak", raw) || !Valid(section, raw))
{
if (FileIO.FileExists(path) || FileIO.FileExists(path + ".bak"))
{
Print("Reintroduced [config]: cannot import " + path + "; repair it or its backup. Existing files preserved.", LogLevel.ERROR);
return false;
}
raw = Defaults(section);
}
}
raw.TrimInPlace(); members.Set(section, raw);
}
if (!Commit(members)) return false;
Print("Reintroduced [config]: using $profile:Escapists.json; legacy files retained but no longer used.");
return true;
}
static bool Load(string path, JsonLoadContext json)
{
string section = Section(path); bool backup;
if (section.IsEmpty() && path.EndsWith(".bak")) { section = Section(path.Substring(0, path.Length() - 4)); backup = true; }
if (section.IsEmpty()) return json.LoadFromFile(path);
if (!Ensure()) return false;
string source = PATH; if (backup) source += ".bak";
map<string, string> members;
if (!Document(source, members)) return false;
if (!members.Contains(section)) return json.LoadFromString(Defaults(section));
return json.LoadFromString(members.Get(section));
}
static bool Exists(string path)
{
string section = Section(path);
if (section.IsEmpty() && path.EndsWith(".bak")) section = Section(path.Substring(0, path.Length() - 4));
if (section.IsEmpty()) return FileIO.FileExists(path);
// A failed migration must make all owners read-only, not just the bad section.
if (!Ensure()) return true;
return FileIO.FileExists(PATH) || FileIO.FileExists(PATH + ".bak");
}
static bool Save(string path, JsonSaveContext json)
{
if (!Replication.IsServer() || !Ensure()) return false;
string section = Section(path), raw = json.SaveToString(); raw.TrimInPlace();
if (section.IsEmpty() || !Valid(section, raw)) return false;
map<string, string> members;
if (!Document(PATH, members) && !Document(PATH + ".bak", members)) return false;
// Keep unrecognized fields within the edited section too (for returning mods
// and newer optional settings). Explicitly written values replace old values.
map<string, string> fields = new map<string, string>();
map<string, string> changed = new map<string, string>();
if (members.Contains(section) && !Split(members.Get(section), fields)) return false;
if (!Split(raw, changed)) return false;
foreach (string key, string value : changed) fields.Set(key, value);
raw = Encode(fields); raw.TrimInPlace();
members.Set(section, raw);
return Commit(members);
}
}
@@ -0,0 +1,104 @@
// Repair the combined resource in memory before character templates are instantiated.
// No prefab override, optional script class reference, new dependency, or disk write.
class ERR_ReaperRHSCompat
{
protected ref Resource m_Character;
protected bool m_Applied;
bool Applied() { return m_Applied; }
static bool ModsPresent()
{
array<string> addons = {}; GameProject.GetLoadedAddons(addons);
bool reaper, rhs;
foreach (string addon : addons)
{
addon.ToUpper();
if (addon == "5EB139459EBF5C16") reaper = true;
if (addon == "595F2BF2F44836FB") rhs = true;
}
return reaper && rhs;
}
protected static bool CameraSlot(BaseContainer slot)
{
if (!slot || slot.GetName() != "SpectatorCameraHelper") return false;
ResourceName prefab;
if (!slot.Get("Prefab", prefab)) return false;
return prefab == "{574AA64002B8E308}Prefabs/Characters/REAPER_SpectatorCameraHelper.et";
}
bool Apply()
{
if (m_Applied) return true;
if (!ModsPresent()) return false;
// The GUID is essential: a bare path can resolve an unmodified base resource.
m_Character = Resource.Load("{37578B1666981FCE}Prefabs/Characters/Core/Character_Base.et");
if (!m_Character || !m_Character.IsValid()) return false;
BaseContainer source = m_Character.GetResource().ToBaseContainer();
if (!source) return false;
BaseContainerList components = source.GetObjectArray("components");
if (!components) return false;
BaseContainer reaper, rhs, camera;
int cameras, thermals;
for (int i = 0; i < components.Count(); i++)
{
BaseContainer component = components.Get(i);
if (component.GetClassName() == "RHS_ThermalSlotManagerComponent")
{
rhs = component; thermals++;
}
if (component.GetClassName() != "SlotManagerComponent") continue;
BaseContainerList slots = component.GetObjectArray("Slots");
// Do not absorb an unrelated manager or silently drop additional slots.
if (!slots || slots.Count() != 1 || !CameraSlot(slots.Get(0))) continue;
reaper = component; camera = slots.Get(0); cameras++;
}
// Already compatible resources and unknown future layouts are left untouched.
if (cameras == 0) return false;
if (cameras != 1 || thermals != 1) return Unsupported();
BaseContainerList originalSlots = rhs.GetObjectArray("Slots");
// RHS uses its first slot as the thermal body. Preserve that order.
if (!originalSlots || originalSlots.Count() == 0 || originalSlots.Get(0).GetName() != "Body") return Unsupported();
bool alreadyAttached;
for (int j = 0; j < originalSlots.Count(); j++)
{
BaseContainer existing = originalSlots.Get(j);
if (existing.GetName() != "SpectatorCameraHelper") continue;
if (!CameraSlot(existing)) return Unsupported();
alreadyAttached = true;
}
BaseContainerList writableSlots = rhs.SetObjectArray("Slots");
BaseContainerList writableComponents = source.SetObjectArray("components");
if (!writableSlots || !writableComponents) return Unsupported();
if (!alreadyAttached && !writableSlots.Insert(camera)) return Unsupported();
if (!writableComponents.Remove(reaper))
{
if (!alreadyAttached) writableSlots.Remove(camera);
return Unsupported();
}
// Pin the repaired resource for the game instance, including subsequent worlds.
m_Applied = true;
Print("Reintroduced [Reaper/RHS compatibility]: merged spectator camera slot into thermal manager; duplicate removed (memory only)");
return true;
}
protected bool Unsupported()
{
Print("Reintroduced [Reaper/RHS compatibility]: unfamiliar character slot layout; repair not applied", LogLevel.WARNING);
return false;
}
}
modded class ArmaReforgerScripted
{
protected ref ERR_ReaperRHSCompat m_ERRReaperRHSCompat;
void ArmaReforgerScripted()
{
m_ERRReaperRHSCompat = new ERR_ReaperRHSCompat();
m_ERRReaperRHSCompat.Apply();
}
}
@@ -13,9 +13,9 @@ class ERR_RecoveryRules
{
return conscious == 0 && incapacitated > 0;
}
static bool Stalled(int conscious, int incapacitated, int reservedRescuers, int recovering, bool connected)
static bool Stalled(int conscious, int incapacitated, int reservedRescuers, int recovering, bool connected, bool evacuating = false)
{
return connected && conscious == 0 && incapacitated > 0 && reservedRescuers == 0 && recovering == 0;
return !evacuating && connected && conscious == 0 && incapacitated > 0 && reservedRescuers == 0 && recovering == 0;
}
static bool DefeatDue(float since, float now)
{
@@ -108,7 +108,8 @@ class ERR_RecoveryService
m_Conscious = conscious;
m_Incapacitated = incapacitated;
m_Reserved = reserved;
bool stalled = ERR_RecoveryRules.Stalled(conscious, incapacitated, reservedRescuers, recovering, GetGame().GetPlayerManager().GetPlayerCount() > 0);
bool evacuating = m_Mode.Extraction() && m_Mode.Extraction().CarryingSurvivorsToSafety();
bool stalled = ERR_RecoveryRules.Stalled(conscious, incapacitated, reservedRescuers, recovering, GetGame().GetPlayerManager().GetPlayerCount() > 0, evacuating);
if (!stalled) m_StalledSince = -1;
else if (m_StalledSince < 0 || now < m_StalledSince) m_StalledSince = now;
if (logMedical && incapacitated > 0)
@@ -120,7 +121,8 @@ class ERR_RecoveryService
if (ERR_RecoveryRules.NeedsRescuer(conscious, incapacitated))
{
message = "No conscious prisoner is currently available to help.";
if (recovering > 0) message += " Treatment or natural recovery is possible; the round continues.";
if (evacuating) message += " The rescue crew is carrying all living prisoners to safety; evacuation continues.";
else if (recovering > 0) message += " Treatment or natural recovery is possible; the round continues.";
else if (reservedRescuers > 0) message += " A conscious prisoner's reconnection may provide help.";
else message += " Everyone is unconscious with no treatment or recovery progressing. The mission fails after 30 uninterrupted seconds in this state.";
}
@@ -0,0 +1,41 @@
// Fleet space is qualified before a pickup is offered to players. The primary
// pad retains its scenario/terrain validation; the extra Huey uses the same
// native aircraft footprint and a bounded, deterministic nearby search.
class ERR_RescueLanding
{
static vector Candidate(vector primary, int attempt)
{
int band = attempt / 16;
int bearing = Math.Mod(attempt, 16);
float angle = bearing * Math.PI2 / 16;
return ERR_Actors.Ground(primary + Vector(Math.Cos(angle), 0, Math.Sin(angle)) * (90 + band * 40));
}
static bool SecondaryClear(vector position)
{
if (!Replication.IsServer() || !GetGame().GetWorldEntity()) return false;
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
if (position[0] < low[0] + 40 || position[0] > high[0] - 40 || position[2] < low[2] + 40 || position[2] > high[2] - 40) return false;
return ERR_AirfieldPlacement.ClearGround(position);
}
static bool Survey(vector primary, int count, array<vector> pads)
{
pads.Clear();
if (!Replication.IsServer() || count < 1 || count > 2) return false;
pads.Insert(primary);
if (count == 1) return true;
for (int attempt = 0; attempt < 48; attempt++)
{
vector candidate = Candidate(primary, attempt);
if (!SecondaryClear(candidate)) continue;
pads.Insert(candidate); return true;
}
pads.Clear(); return false;
}
static bool Fits(vector primary)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !mode.PlayerFaction()) return false;
array<vector> pads = {};
return Survey(primary, ERR_RescueAssets.TransportCount(mode.PlayerFaction().GetFactionKey()), pads);
}
}
@@ -4,12 +4,24 @@ class ERR_ScenarioComponent : ScriptComponent
{
[Attribute("", UIWidgets.ResourceNamePicker, "Scenario site definitions", "conf")]
protected ResourceName m_Definition;
[Attribute("0", UIWidgets.CheckBox, "Generate all sites on this terrain; requires terrain-specific AI navigation")]
protected bool m_TerrainIndependent;
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;
protected bool m_Progressive = true;
protected bool m_BackgroundDone;
protected bool m_BackgroundActivated;
protected bool m_BackgroundStopped;
protected bool m_PlacementDone;
protected int m_PreparationStarted;
protected ref ERR_SiteService m_PendingSite;
protected ERR_SiteDefinition m_PendingDefinition;
bool BackgroundPending() { return m_Progressive && !m_BackgroundDone && !m_BackgroundStopped && !m_Failed; }
ERR_SiteService Sites() { return m_Sites; }
string FailureReason()
@@ -20,6 +32,20 @@ class ERR_ScenarioComponent : ScriptComponent
if (m_Sites && !m_Sites.FailureReason().IsEmpty()) return m_Sites.FailureReason();
return "Scenario or additional force preparation failed.";
}
#ifdef WORKBENCH
// The historical catalog predates finite mobile site squads. Configure this
// isolated fixture before ordinary population, never borrow permanent guards.
protected bool ERR_ConfigurePatrolHQFixture(string outcome)
{
if (m_TerrainIndependent || outcome != "patrol-hq") return true;
if (!m_RuntimeDefinition) return false;
ERR_SiteDefinition site = m_RuntimeDefinition.Find("everon-outpost-1");
if (!site || site.Kind != ERR_LocationKind.OUTPOST || !site.Guarded || site.ResponseCount != 0) return false;
site.ResponseCount = 2;
site.ResponseOffset = "-20 0 -20";
return m_RuntimeDefinition.Valid();
}
#endif
ERR_PrepareResult Prepare()
{
ERR_GameMode mode = ERR_GameMode.Get();
@@ -28,9 +54,16 @@ class ERR_ScenarioComponent : ScriptComponent
if (!m_Started)
{
m_Started = true;
m_PreparationStarted = System.GetTickCount();
#ifdef WORKBENCH
// Historical fixtures assert a fully populated world at startup. Ordinary
// dedicated/listen rounds use progressive preparation, including terrain takeover.
string preparationFixture;
if (System.GetCLIParam("errSmokeOutcome", preparationFixture) && !preparationFixture.IsEmpty()) m_Progressive = false;
#endif
#ifdef WORKBENCH
string historicalFixture;
if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "optional-site-skip" && historicalFixture != "infantry-combat" && historicalFixture != "site-garrisons" && historicalFixture != "generated-sites" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
if (!m_TerrainIndependent && System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "normal-escape-route" && historicalFixture != "crash-start" && historicalFixture != "mounted-ammo" && historicalFixture != "alarm-hearing" && historicalFixture != "optional-site-skip" && historicalFixture != "infantry-combat" && historicalFixture != "site-garrisons" && 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));
@@ -38,38 +71,150 @@ class ERR_ScenarioComponent : ScriptComponent
#ifdef WORKBENCH
// Existing deterministic probes intentionally use authored coordinates.
string fixture;
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "optional-site-skip" && fixture != "infantry-combat" && fixture != "site-garrisons" && fixture != "generated-sites" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
if (!m_TerrainIndependent && System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "normal-escape-route" && fixture != "crash-start" && fixture != "mounted-ammo" && fixture != "alarm-hearing" && fixture != "optional-site-skip" && fixture != "infantry-combat" && fixture != "site-garrisons" && 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;
if (!ERR_ConfigurePatrolHQFixture(fixture)) { m_Failed = true; return ERR_PrepareResult.FAILED; }
#endif
m_Generated = new ERR_GeneratedSites();
if (m_Progressive && !m_RuntimeDefinition.ConfigurePopulation(m_Definition, mode.Settings().Get("fieldCampCount"), mode.Settings().Get("fieldHospitalCount")))
{ m_Failed = true; return ERR_PrepareResult.FAILED; }
if (!ERR_StartSetup.Configure(m_RuntimeDefinition, mode.StartMode())) { m_Failed = true; return ERR_PrepareResult.FAILED; }
if (m_TerrainIndependent) m_Generated = new ERR_TerrainSites();
else m_Generated = new ERR_GeneratedSites();
m_Generated.StartupOnly(m_Progressive);
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 (placement == ERR_PrepareResult.FAILED) return placement;
if (placement != ERR_PrepareResult.READY && (!m_Progressive || !m_Generated.StartupReady())) return placement;
if (!m_Sites)
{
m_Sites = new ERR_SiteService(mode);
if (!m_Sites.Begin(m_RuntimeDefinition, FieldMap())) { m_Failed = true; return ERR_PrepareResult.FAILED; }
if (!m_Sites.Begin(m_RuntimeDefinition, FieldMap(), m_Progressive)) { 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())
if (!m_Progressive)
{
m_Failed = true;
m_Sites.Rollback();
return ERR_PrepareResult.FAILED;
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;
m_Sites.Rollback();
return ERR_PrepareResult.FAILED;
}
}
mode.OnPhaseChanged().Insert(m_Sites.PhaseChanged);
mode.OnStop().Insert(m_Sites.Stop);
mode.OnStop().Insert(StopBackground);
mode.OnTick().Insert(m_Sites.Tick);
GetGame().GetCallqueue().CallLater(ActivateSites, 2000, false);
m_Finished = true;
if (m_Progressive)
{
m_Generated.StartupOnly(false);
GetGame().GetCallqueue().CallLater(BackgroundStep, 250, true);
PrintFormat("Reintroduced [generation]: spawn-ready in %1 ms; remaining sites continue in background", System.GetTickCount() - m_PreparationStarted);
}
return ERR_PrepareResult.READY;
}
// Independent of tactical OnTick: continue while players are in prison.
// A fresh empty server keeps only the admission scene, not the entire island.
protected void BackgroundStep()
{
ERR_GameMode mode = ERR_GameMode.Cast(GetOwner());
if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !BackgroundPending()) { StopBackground(); return; }
if (!mode.Active()) { StopBackground(); return; }
// Wait only before starting the background job. Once admitted, let all
// native preparation callbacks/deadlines drain even if everyone disconnects.
if (!m_BackgroundActivated)
{
if (GetGame().GetPlayerManager().GetPlayerCount() == 0) return;
m_BackgroundActivated = true;
}
if (!m_PlacementDone)
{
ERR_PrepareResult placement = m_Generated.Tick();
if (placement == ERR_PrepareResult.FAILED) { FailBackground(m_Generated.Error()); return; }
if (placement != ERR_PrepareResult.READY) return;
m_PlacementDone = true;
return;
}
if (m_PendingSite)
{
ERR_PrepareResult prepared = m_PendingSite.Prepare();
if (prepared == ERR_PrepareResult.FAILED) { FailedSite(); return; }
if (prepared != ERR_PrepareResult.READY) return;
if (!m_Sites.Adopt(m_PendingSite)) { FailBackground("Cannot register prepared site " + m_PendingDefinition.Id); return; }
PrintFormat("Reintroduced [generation]: site ready: %1; total=%2", m_PendingDefinition.Id, m_Sites.Count());
m_PendingSite = null; m_PendingDefinition = null;
return;
}
bool deferred;
array<IEntity> players = {};
ERR_Actors.Players(players);
foreach (ERR_SiteDefinition spec : m_RuntimeDefinition.Sites)
{
if (m_Sites.Find(spec.Id)) continue;
bool nearby;
foreach (IEntity player : players)
if (vector.DistanceXZ(player.GetOrigin(), spec.Position) < 200) nearby = true;
if (nearby) { deferred = true; continue; }
m_PendingDefinition = spec;
m_PendingSite = new ERR_SiteService(mode);
m_PendingSite.InheritContentIndex(m_Sites);
if (!m_PendingSite.Begin(m_RuntimeDefinition, FieldMap(), false, spec)) FailedSite();
return;
}
if (deferred) return;
ERR_PrepareResult population = PrepareAdditionalPopulation();
if (population == ERR_PrepareResult.FAILED) { FailBackground(FailureReason()); return; }
if (population != ERR_PrepareResult.READY) return;
if (!PrepareAdditionalForces()) { FailBackground("Additional force preparation failed"); return; }
m_BackgroundDone = true;
GetGame().GetCallqueue().Remove(BackgroundStep);
PrintFormat("Reintroduced [generation]: background complete in %1 ms; sites=%2", System.GetTickCount() - m_PreparationStarted, m_Sites.Count());
}
protected void FailedSite()
{
string reason = m_PendingSite.FailureReason();
// A failed late transaction owns only its own objects. Preserve a playable
// prison, and omit optional sites only if objectives/intel remain valid.
m_PendingSite.Rollback();
ERR_SiteDefinition spec = m_PendingDefinition;
if (spec && (spec.Kind == ERR_LocationKind.OUTPOST || spec.Kind == ERR_LocationKind.DEPOT || spec.Kind == ERR_LocationKind.HOSPITAL))
{
int index = m_RuntimeDefinition.Sites.Find(spec);
if (index >= 0)
{
ref ERR_SiteDefinition retained = spec;
m_RuntimeDefinition.Sites.Remove(index);
if (m_RuntimeDefinition.Valid())
{
PrintFormat("Reintroduced [generation]: optional site omitted: %1: %2", retained.Id, reason);
m_PendingSite = null; m_PendingDefinition = null;
return;
}
m_RuntimeDefinition.Sites.InsertAt(retained, index);
}
}
FailBackground(reason);
}
protected void FailBackground(string reason)
{
StopBackground();
ERR_GameMode mode = ERR_GameMode.Cast(GetOwner());
if (mode && mode == ERR_GameMode.Get()) mode.WorldPreparationFailed(reason);
}
protected void StopBackground()
{
m_BackgroundStopped = true;
if (GetGame()) GetGame().GetCallqueue().Remove(BackgroundStep);
if (m_PendingSite) m_PendingSite.Rollback();
m_PendingSite = null; m_PendingDefinition = null;
}
protected void ActivateSites()
{
ERR_GameMode mode = ERR_GameMode.Get();
@@ -80,6 +225,7 @@ class ERR_ScenarioComponent : ScriptComponent
protected ResourceName FieldMap() { return ERR_Supplies.Map(); }
protected override void OnDelete(IEntity owner)
{
StopBackground();
if (GetGame()) GetGame().GetCallqueue().Remove(ActivateSites);
if (m_Sites) m_Sites.Stop();
super.OnDelete(owner);
@@ -0,0 +1,51 @@
// Shared action colors and read-only status badges. Text accompanies every color.
class ERR_SelectionStyle
{
static void Action(SCR_ButtonTextComponent button, bool enable)
{
if (!button) return;
Color normal = Color.FromInt(0xFF8F2929), hover = Color.FromInt(0xFFB43B3B);
if (enable) { normal = Color.FromInt(0xFF246B3C); hover = Color.FromInt(0xFF31894F); }
button.SetBackgroundColors(normal, hover, normal, hover, hover);
button.ColorizeBackground(false);
}
// Saved selections retain their color even when the selected action is disabled.
// Do not make actions toggle themselves on click: only server state selects them.
static void Choice(SCR_ButtonTextComponent button, bool selected, bool positive = true)
{
if (!button) return;
Color normal = Color.FromInt(0xFF454B52), hover = Color.FromInt(0xFF606870);
if (selected)
{
normal = Color.FromInt(0xFF246B3C); hover = Color.FromInt(0xFF31894F);
if (!positive) { normal = Color.FromInt(0xFF8F2929); hover = Color.FromInt(0xFFB43B3B); }
}
button.SetDisabledOpacity(1);
button.SetBackgroundColors(normal, hover, normal, hover, hover);
button.ColorizeBackground(false); button.ShowBorder(selected, false);
}
static void Pair(Widget parent, string yes, string no, int state)
{
Choice(SCR_ButtonTextComponent.GetButtonText(yes, parent), state == 1, true);
Choice(SCR_ButtonTextComponent.GetButtonText(no, parent), state == 0, false);
}
// -1 waiting, 0 disabled, 1 enabled, 2 mixed overrides.
static void Status(Widget parent, string name, int state)
{
SCR_ButtonTextComponent badge = SCR_ButtonTextComponent.GetButtonText(name, parent);
if (!badge) return;
string label = "Waiting..."; Color color = Color.FromInt(0xFF454B52);
if (state == 0) { label = "Disabled"; color = Color.FromInt(0xFF8F2929); }
if (state == 1) { label = "Enabled"; color = Color.FromInt(0xFF246B3C); }
if (state == 2) { label = "Mixed"; color = Color.FromInt(0xFF805B17); }
badge.SetText(label); badge.SetDisabledOpacity(1); badge.SetEnabled(false, false);
badge.SetBackgroundColors(color, color, color, color, color); badge.ColorizeBackground(false);
}
static int Aggregate(int total, int enabled, bool defaultEnabled)
{
if (total == 0) { if (defaultEnabled) return 1; return 0; }
if (enabled == 0) return 0;
if (enabled == total) return 1;
return 2;
}
}
@@ -0,0 +1,207 @@
class ERR_ServerItem
{
ResourceName Prefab;
string Label;
bool Eligible;
}
class ERR_ServerAddonItems
{
string Id;
string Project;
string Label;
int EnabledCount;
int TicketWeight;
ref array<ref ERR_ServerItem> Items = {};
}
// Inventory discovery is separate from saved equipment selection.
class ERR_ServerItemCatalog
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_ServerAddonItems> m_Addons = {};
protected ref array<SCR_EntityCatalogEntry> m_Pending = {};
protected ref map<string, ref ERR_ServerItem> m_Seen = new map<string, ref ERR_ServerItem>();
protected int m_Cursor;
protected bool m_Started;
protected bool m_Truncated;
protected int m_Unattributed;
protected int m_SelectionRevision = -1;
protected int m_SelectionCursor = -1;
protected int m_WeightRevision = -1;
static const int PAGE_SIZE = 12;
static string Clean(string value)
{
value.Replace("|", " "); value.Replace("\n", " "); value.Replace("\r", " ");
if (value.Length() > 180) value = value.Substring(0, 180);
return value;
}
ERR_ServerAddonItems Find(string id)
{
foreach (ERR_ServerAddonItems addon : m_Addons) if (addon.Id == id) return addon;
return null;
}
bool Contains(string addonId, string itemId)
{
ERR_ServerAddonItems addon = Find(addonId);
if (!addon) return false;
foreach (ERR_ServerItem item : addon.Items) if (ERR_TerrainCatalog.Key(item.Prefab) == itemId) return true;
return false;
}
protected void Queue(SCR_EntityCatalog catalog)
{
if (!catalog) return;
array<SCR_EntityCatalogEntry> entries = {}; catalog.GetEntityList(entries);
foreach (SCR_EntityCatalogEntry entry : entries)
{
if (m_Pending.Count() >= 32768) { m_Truncated = true; return; }
if (entry) m_Pending.Insert(entry);
}
}
void Start(ERR_GameMode mode)
{
if (m_Started || !mode || !Replication.IsServer()) return;
SCR_EntityCatalogManagerComponent manager = SCR_EntityCatalogManagerComponent.GetInstance();
FactionManager factions = GetGame().GetFactionManager();
if (!manager || !factions) return;
m_Started = true; m_Mode = mode;
array<string> loaded = {}; GameProject.GetLoadedAddons(loaded);
foreach (string guid : loaded)
{
ERR_ServerAddonItems addon = new ERR_ServerAddonItems();
addon.Id = guid; addon.Id.ToLower(); addon.Project = GameProject.GetAddonID(guid);
addon.Label = Clean(GameProject.GetAddonTitle(guid));
if (addon.Label.IsEmpty()) addon.Label = Clean(addon.Project);
m_Addons.Insert(addon);
}
Queue(manager.GetEntityCatalogOfType(EEntityCatalogType.ITEM, false));
array<Faction> all = {}; factions.GetFactionsList(all);
foreach (Faction faction : all)
{
SCR_Faction nativeFaction = SCR_Faction.Cast(faction);
if (nativeFaction) Queue(nativeFaction.GetFactionEntityCatalogOfType(EEntityCatalogType.ITEM, false));
}
// Small batches keep resource/UI-info inspection out of one large admin RPC.
GetGame().GetCallqueue().CallLater(Scan, 50, true);
mode.OnStop().Insert(Stop);
}
void Stop()
{
if (GetGame()) GetGame().GetCallqueue().Remove(Scan);
if (m_Mode) m_Mode.OnStop().Remove(Stop);
m_Mode = null;
m_Pending.Clear(); m_Seen.Clear(); m_Addons.Clear();
}
protected void Scan()
{
if (!m_Mode || m_Mode != ERR_GameMode.Get() || !Replication.IsServer()) { Stop(); return; }
int end = Math.Min(m_Cursor + 12, m_Pending.Count());
while (m_Cursor < end)
{
SCR_EntityCatalogEntry entry = m_Pending[m_Cursor]; m_Cursor++;
ResourceName prefab = entry.GetPrefab();
string key = ERR_TerrainCatalog.Key(prefab);
if (key.IsEmpty()) continue;
SCR_ArsenalItem arsenal = SCR_ArsenalItem.Cast(entry.GetEntityDataOfType(SCR_ArsenalItem));
bool eligible = entry.IsEnabled() && arsenal && ERR_AutomaticEquipmentCatalog.Supported(arsenal.GetItemType(), arsenal.GetItemMode());
ERR_ServerItem item = m_Seen.Get(key);
if (item) { item.Eligible = item.Eligible || eligible; continue; }
item = new ERR_ServerItem(); item.Prefab = prefab; item.Eligible = eligible;
item.Label = Clean(entry.GetEntityName());
if (item.Label.IsEmpty()) item.Label = Clean(FilePath.StripPath(prefab.GetPath()));
m_Seen.Set(key, item);
array<string> sources = SCR_AddonTool.GetResourceAddons(prefab);
bool attributed;
foreach (ERR_ServerAddonItems addon : m_Addons)
if (sources.Contains(addon.Project)) { addon.Items.Insert(item); attributed = true; }
if (!attributed) m_Unattributed++;
}
if (m_Cursor >= m_Pending.Count())
{
GetGame().GetCallqueue().Remove(Scan);
PrintFormat("Reintroduced [server items]: addons=%1 uniqueItems=%2 unattributed=%3 truncated=%4", m_Addons.Count(), m_Seen.Count(), m_Unattributed, m_Truncated);
}
}
static string Share(int tickets, int total)
{
if (total <= 0 || tickets <= 0) return "0%";
float percent = Math.Round(tickets * 10000.0 / total) / 100.0;
return percent.ToString() + "%";
}
void Snapshot(ERR_GameMode mode, array<string> values, string selected, int page)
{
Start(mode);
ERR_ItemPreferences preferences = GetGame().ERR_ItemSelections();
preferences.Snapshot(values);
int totalWeight = ERR_AutomaticEquipmentCatalog.TotalWeight(mode);
int weightRevision = ERR_AutomaticEquipmentCatalog.CatalogRevision();
if (m_SelectionRevision != preferences.Revision() || m_SelectionCursor != m_Cursor || m_WeightRevision != weightRevision)
{
m_SelectionRevision = preferences.Revision(); m_SelectionCursor = m_Cursor; m_WeightRevision = weightRevision;
foreach (ERR_ServerAddonItems summary : m_Addons)
{
summary.EnabledCount = 0; summary.TicketWeight = 0;
foreach (ERR_ServerItem counted : summary.Items)
{
if (preferences.Allows(counted.Prefab) && (!mode.Content() || mode.Content().Allows(counted.Prefab))) summary.EnabledCount++;
summary.TicketWeight += ERR_AutomaticEquipmentCatalog.TicketWeight(counted.Prefab);
}
}
}
foreach (ERR_ServerAddonItems addon : m_Addons)
{
string defaultEnabled = "0"; if (preferences.Enabled(addon.Id, "*")) defaultEnabled = "1";
values.Insert("serverMod=" + addon.Id + "|" + addon.Label + "|" + addon.Items.Count().ToString() + "|" + addon.EnabledCount.ToString() + "|" + defaultEnabled);
}
string state = "Catalog complete";
if (!m_Started || m_Cursor < m_Pending.Count()) state = "Scanning inventory catalogs";
if (m_Truncated) state += "; entry limit reached, results incomplete";
if (m_Unattributed > 0) state += "; " + m_Unattributed.ToString() + " items could not be attributed";
values.Insert("itemsScan=" + state);
int extraChance = mode.Settings().Runtime("automaticEquipmentChancePercent");
values.Insert("weightChance=" + extraChance.ToString() + "% current chance per eligible soldier. The Spawn chances page controls which extra item is chosen; inventory fit can still prevent a grant.");
ERR_ServerAddonItems chosen = Find(selected);
if (!chosen) return;
values.Insert("modWeight=" + chosen.Id + "|" + preferences.WeightSetting(chosen.Id, "*").ToString() + "|" + Share(chosen.TicketWeight, totalWeight));
int lastPage = Math.Max(0, (chosen.Items.Count() - 1) / PAGE_SIZE);
page = Math.Clamp(page, 0, lastPage);
values.Insert("itemsPage=" + selected + "|" + page.ToString() + "|" + lastPage.ToString() + "|" + chosen.Items.Count().ToString());
int end = Math.Min(chosen.Items.Count(), (page + 1) * PAGE_SIZE);
for (int i = page * PAGE_SIZE; i < end; i++)
{
ERR_ServerItem item = chosen.Items[i];
string stateText = "Catalog item; not in automatic equipment pool";
if (item.Eligible) stateText = "Eligible for automatic equipment";
if (mode.Content() && !mode.Content().Allows(item.Prefab)) stateText = "Blocked by current content-pack selection";
string key = ERR_TerrainCatalog.Key(item.Prefab);
string enabled = "0";
if (preferences.Enabled(selected, key)) enabled = "1";
if (!preferences.Allows(item.Prefab)) stateText = "Disabled by item/mod selection";
string effective = "0";
if (preferences.Allows(item.Prefab) && (!mode.Content() || mode.Content().Allows(item.Prefab))) effective = "1";
values.Insert("itemWeight=" + key + "|" + preferences.WeightSetting(selected, key).ToString() + "|" + Share(ERR_AutomaticEquipmentCatalog.TicketWeight(item.Prefab), totalWeight));
values.Insert("serverItem=" + key + "|" + item.Label + "|" + Clean(item.Prefab.GetPath()) + "|" + stateText + "|" + enabled + "|" + effective);
}
}
}
modded class ArmaReforgerScripted
{
protected ref ERR_ServerItemCatalog m_ERRServerItems;
protected ref ERR_ItemPreferences m_ERRItemSelections;
ERR_ItemPreferences ERR_ItemSelections()
{
if (!m_ERRItemSelections) m_ERRItemSelections = new ERR_ItemPreferences();
return m_ERRItemSelections;
}
ERR_ServerItemCatalog ERR_ServerItems()
{
if (!m_ERRServerItems) m_ERRServerItems = new ERR_ServerItemCatalog();
return m_ERRServerItems;
}
override protected void OnBeforeWorldCleanup()
{
if (m_ERRServerItems) m_ERRServerItems.Stop();
m_ERRServerItems = null; m_ERRItemSelections = null;
super.OnBeforeWorldCleanup();
}
}
@@ -0,0 +1,249 @@
// Server inventory selection. The existing content switches remain separate from
// loaded addons: disabling mission content cannot unload a running script mod.
modded class ERR_AdminMenu
{
protected ref array<string> m_ServerAddonKeys = {};
protected ref array<string> m_ServerAddonLabels = {};
protected ref map<string, Widget> m_ServerAddonRows = new map<string, Widget>();
protected ref array<SCR_ButtonTextComponent> m_ItemButtons = {};
protected ref map<string, Widget> m_ItemRows = new map<string, Widget>();
protected ref array<string> m_ItemPresets = {};
protected ref map<string, int> m_ItemSaved = new map<string, int>();
protected ref map<string, int> m_AddonSelectionStates = new map<string, int>();
protected string m_ShownItemAddon;
protected int m_ShownItemPage;
protected int m_LastItemPage;
override void OnMenuOpen()
{
super.OnMenuOpen();
foreach (string name : {"ItemsShow", "ItemsPrevious", "ItemsNext", "ItemsEnableAll", "ItemsDisableAll", "ItemsPresetSave", "ItemsPresetLoad", "ItemsPresetDelete"})
{
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(name, GetRootWidget());
if (!button) { Close(); return; }
if (name == "ItemsDisableAll" || name == "ItemsPresetDelete") ERR_SelectionStyle.Action(button, false);
if (name == "ItemsEnableAll") ERR_SelectionStyle.Action(button, true);
button.m_OnClicked.Insert(OnItemsButton); m_ItemButtons.Insert(button);
}
ERR_SelectionStyle.Status(GetRootWidget(), "ItemsSelectionState", -1);
UpdateActions();
}
override void OnMenuClose()
{
foreach (SCR_ButtonTextComponent button : m_ItemButtons) if (button) button.m_OnClicked.Remove(OnItemsButton);
ClearItemRows(); m_ItemPresets.Clear();
foreach (string addon, Widget row : m_ServerAddonRows) SCR_ButtonTextComponent.GetButtonText("Enable", row).m_OnClicked.Remove(OnLoadedModEdit);
m_AddonSelectionStates.Clear();
m_ItemButtons.Clear(); m_ServerAddonRows.Clear(); m_ServerAddonKeys.Clear(); m_ServerAddonLabels.Clear();
m_ShownItemAddon = ""; m_ShownItemPage = 0; m_LastItemPage = 0;
super.OnMenuClose();
}
protected override void UpdateActions()
{
super.UpdateActions();
if (!m_Ready)
{
ERR_SelectionStyle.Status(GetRootWidget(), "ItemsSelectionState", -1);
foreach (string id, Widget stale : m_ItemRows) ERR_SelectionStyle.Status(stale, "SelectionState", -1);
foreach (string modId, Widget staleMod : m_ServerAddonRows) ERR_SelectionStyle.Status(staleMod, "SelectionState", -1);
}
bool ready = m_Ready && !m_ServerAddonKeys.IsEmpty();
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsMod", GetRootWidget());
if (combo) combo.SetEnabled(ready);
foreach (string addon, Widget loadedRow : m_ServerAddonRows) SCR_ButtonTextComponent.GetButtonText("Enable", loadedRow).SetEnabled(ready);
foreach (string key, Widget row : m_ItemRows)
{
SCR_ButtonTextComponent.GetButtonText("Enable", row).SetEnabled(ready && m_ItemSaved.Contains(key) && m_ItemSaved.Get(key) == 0);
SCR_ButtonTextComponent.GetButtonText("Disable", row).SetEnabled(ready && m_ItemSaved.Contains(key) && m_ItemSaved.Get(key) == 1);
int selected = -1; if (m_ItemSaved.Contains(key)) selected = m_ItemSaved.Get(key);
ERR_SelectionStyle.Pair(row, "Enable", "Disable", selected);
}
int allState = -1; if (m_AddonSelectionStates.Contains(m_ShownItemAddon)) allState = m_AddonSelectionStates.Get(m_ShownItemAddon);
ERR_SelectionStyle.Pair(GetRootWidget(), "ItemsEnableAll", "ItemsDisableAll", allState);
foreach (SCR_ButtonTextComponent button : m_ItemButtons)
{
bool enabled = ready;
if (button.GetRootWidget().GetName() == "ItemsPrevious") enabled = ready && m_ShownItemPage > 0;
if (button.GetRootWidget().GetName() == "ItemsNext") enabled = ready && m_ShownItemPage < m_LastItemPage;
string name = button.GetRootWidget().GetName();
if (name == "ItemsEnableAll" || name == "ItemsDisableAll") enabled = ready && !m_ShownItemAddon.IsEmpty();
if (name == "ItemsPresetLoad" || name == "ItemsPresetDelete") enabled = ready && !m_ItemPresets.IsEmpty();
button.SetEnabled(enabled);
}
}
override void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
array<string> keys = {}, labels = {};
array<string> itemRows = {}, presets = {};
string scan, shown;
int page, last, total;
if (authorized && values)
foreach (string entry : values)
{
int divider = entry.IndexOf("="); if (divider < 0) continue;
string field = entry.Substring(0, divider), value = entry.Substring(divider + 1, entry.Length() - divider - 1);
array<string> parts = {}; value.Split("|", parts, false);
if (field == "itemsScan") scan = value;
if (field == "itemsStorage") SetText("ItemsStorage", value);
if (field == "itemPreset") presets.Insert(value);
if (field == "itemsPage" && parts.Count() == 4)
{ shown = parts[0]; page = parts[1].ToInt(); last = parts[2].ToInt(); total = parts[3].ToInt(); }
if (field == "serverItem" && parts.Count() == 6) itemRows.Insert(value);
if (field != "serverMod" || parts.Count() != 5 || keys.Contains(parts[0])) continue;
keys.Insert(parts[0]); labels.Insert(parts[1]);
int aggregate = ERR_SelectionStyle.Aggregate(parts[2].ToInt(), parts[3].ToInt(), parts[4] == "1");
m_AddonSelectionStates.Set(parts[0], aggregate);
Widget row = m_ServerAddonRows.Get(parts[0]);
if (!row)
{
row = GetGame().GetWorkspace().CreateWidgets("{F092D45656A8E02B}UI/layouts/ERR/ContentRow.layout", GetRootWidget().FindAnyWidget("ModList"));
if (!row) continue;
m_ServerAddonRows.Set(parts[0], row);
row.FindAnyWidget("Disable").SetVisible(false);
SCR_ButtonTextComponent edit = SCR_ButtonTextComponent.GetButtonText("Enable", row);
edit.SetText("Edit items"); edit.m_OnClicked.Insert(OnLoadedModEdit);
}
ERR_SelectionStyle.Status(row, "SelectionState", aggregate);
TextWidget.Cast(row.FindAnyWidget("PackLabel")).SetText(parts[1]);
TextWidget.Cast(row.FindAnyWidget("PackState")).SetText("Loaded on server | " + parts[2] + " catalogued items | Edit future random equipment");
}
bool changed = keys.Count() != m_ServerAddonKeys.Count();
if (!changed) for (int i = 0; i < keys.Count(); i++) if (keys[i] != m_ServerAddonKeys[i] || labels[i] != m_ServerAddonLabels[i]) changed = true;
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsMod", GetRootWidget());
if (authorized && changed && combo)
{
string draft;
int index = combo.GetCurrentIndex();
if (index >= 0 && index < m_ServerAddonKeys.Count()) draft = m_ServerAddonKeys[index];
combo.ClearAll(); foreach (string label : labels) combo.AddItem(label);
combo.SetCurrentItem(Math.Max(0, keys.Find(draft)), false, false, false);
m_ServerAddonKeys.Copy(keys); m_ServerAddonLabels.Copy(labels);
}
SetText("ItemsScan", scan);
if (!shown.IsEmpty())
{
bool moved = shown != m_ShownItemAddon || page != m_ShownItemPage;
m_ShownItemAddon = shown; m_ShownItemPage = page; m_LastItemPage = last;
int selected = keys.Find(shown); string title = shown;
if (selected >= 0) title = labels[selected];
SetText("ItemsPageState", title + " | " + total.ToString() + " items | Page " + (page + 1).ToString() + "/" + (last + 1).ToString());
if (moved)
{
ClearItemRows();
if (combo) combo.SetCurrentItem(Math.Max(0, keys.Find(shown)), false, false, false);
}
int addonState = -1; if (m_AddonSelectionStates.Contains(shown)) addonState = m_AddonSelectionStates.Get(shown);
ERR_SelectionStyle.Status(GetRootWidget(), "ItemsSelectionState", addonState);
foreach (string itemRow : itemRows)
{
array<string> parts = {}; itemRow.Split("|", parts, false);
Widget row = m_ItemRows.Get(parts[0]);
if (!row)
{
row = GetGame().GetWorkspace().CreateWidgets("{93A8120CBE7DA469}UI/layouts/ERR/ItemRow.layout", GetRootWidget().FindAnyWidget("ItemsContent"));
if (!row) continue;
m_ItemRows.Set(parts[0], row);
ERR_SelectionStyle.Action(SCR_ButtonTextComponent.GetButtonText("Enable", row), true);
ERR_SelectionStyle.Action(SCR_ButtonTextComponent.GetButtonText("Disable", row), false);
SCR_ButtonTextComponent.GetButtonText("Enable", row).m_OnClicked.Insert(OnItemRowButton);
SCR_ButtonTextComponent.GetButtonText("Disable", row).m_OnClicked.Insert(OnItemRowButton);
}
TextWidget.Cast(row.FindAnyWidget("PackLabel")).SetText(WidgetManager.Translate(parts[1]));
m_ItemSaved.Set(parts[0], parts[4].ToInt());
ERR_SelectionStyle.Status(row, "SelectionState", parts[5].ToInt());
string selection = "Disabled"; if (parts[4] == "1") selection = "Enabled";
TextWidget.Cast(row.FindAnyWidget("PackState")).SetText(selection + " here | " + parts[3] + "\n" + parts[2]);
}
string empty; if (itemRows.IsEmpty()) empty = "No registered inventory items found for this mod.";
SetText("ItemsText", empty);
ScrollLayoutWidget scroll = ScrollLayoutWidget.Cast(GetRootWidget().FindAnyWidget("ItemsScroll"));
if (moved && scroll) scroll.SetSliderPos(0, 0);
}
bool presetsChanged = presets.Count() != m_ItemPresets.Count();
if (!presetsChanged) for (int n = 0; n < presets.Count(); n++) if (presets[n] != m_ItemPresets[n]) presetsChanged = true;
SCR_ComboBoxComponent presetCombo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsPresets", GetRootWidget());
if (presetsChanged && presetCombo)
{
string draftPreset; int oldIndex = presetCombo.GetCurrentIndex();
if (oldIndex >= 0 && oldIndex < m_ItemPresets.Count()) draftPreset = m_ItemPresets[oldIndex];
presetCombo.ClearAll(); foreach (string preset : presets) presetCombo.AddItem(preset);
presetCombo.SetCurrentItem(Math.Max(0, presets.Find(draftPreset)), false, false, false);
m_ItemPresets.Copy(presets);
}
super.ReceiveSnapshot(authorized, values, status);
}
protected void OnItemsButton(SCR_ButtonBaseComponent button)
{
if (!button || !m_Ready || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0) return;
string name = button.GetRootWidget().GetName();
if (name == "ItemsEnableAll" || name == "ItemsDisableAll")
{
if (m_ShownItemAddon.IsEmpty()) return;
string value = "0"; if (name == "ItemsEnableAll") value = "1";
SubmitItems("items set " + m_ShownItemAddon + " * " + value); return;
}
if (name == "ItemsPresetSave")
{
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("ItemsPresetName", GetRootWidget());
string presetName = edit.GetValue().Trim(); presetName.ToLower();
if (!ERR_ItemPreferences.Name(presetName) || presetName == "active") { ReceiveReply("Preset name: 1-24 letters, digits, _ or -. 'active' is reserved."); return; }
SubmitItems("items preset save " + presetName); return;
}
if (name == "ItemsPresetLoad" || name == "ItemsPresetDelete")
{
SCR_ComboBoxComponent presetCombo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsPresets", GetRootWidget());
int selected = presetCombo.GetCurrentIndex(); if (selected < 0 || selected >= m_ItemPresets.Count()) return;
string action = "load"; if (name == "ItemsPresetDelete") action = "delete";
if (action == "load") SCR_EditBoxComponent.GetEditBoxComponent("ItemsPresetName", GetRootWidget()).SetValue(m_ItemPresets[selected]);
SubmitItems("items preset " + action + " " + m_ItemPresets[selected]); return;
}
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsMod", GetRootWidget());
if (!combo) return;
int index = combo.GetCurrentIndex(); if (index < 0 || index >= m_ServerAddonKeys.Count()) return;
string addon = m_ServerAddonKeys[index]; int page;
if (addon == m_ShownItemAddon)
{
page = m_ShownItemPage;
if (button.GetRootWidget().GetName() == "ItemsPrevious") page = Math.Max(0, page - 1);
if (button.GetRootWidget().GetName() == "ItemsNext") page = Math.Min(m_LastItemPage, page + 1);
}
SubmitItems("items list " + addon + " " + page.ToString());
}
protected void OnLoadedModEdit(SCR_ButtonBaseComponent button)
{
if (!button || !m_Ready || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0) return;
foreach (string addon, Widget row : m_ServerAddonRows)
{
if (button.GetRootWidget() != row.FindAnyWidget("Enable")) continue;
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("ItemsMod", GetRootWidget());
if (combo) combo.SetCurrentItem(m_ServerAddonKeys.Find(addon), false, false, false);
ShowPage(17); SubmitItems("items list " + addon + " 0"); return;
}
}
protected void SubmitItems(string command)
{
m_ClickDelay = 0.3;
SetText("Feedback", "Saving selection on the server...");
m_Controller.ERR_AdminSubmit(command, true);
}
protected void ClearItemRows()
{
foreach (string key, Widget row : m_ItemRows)
{
SCR_ButtonTextComponent.GetButtonText("Enable", row).m_OnClicked.Remove(OnItemRowButton);
SCR_ButtonTextComponent.GetButtonText("Disable", row).m_OnClicked.Remove(OnItemRowButton);
row.RemoveFromHierarchy();
}
m_ItemRows.Clear(); m_ItemSaved.Clear();
}
protected void OnItemRowButton(SCR_ButtonBaseComponent button)
{
if (!button || !m_Ready || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0 || m_ShownItemAddon.IsEmpty()) return;
foreach (string key, Widget row : m_ItemRows)
{
string value;
if (button.GetRootWidget() == row.FindAnyWidget("Enable")) value = "1";
if (button.GetRootWidget() == row.FindAnyWidget("Disable")) value = "0";
if (!value.IsEmpty()) { SubmitItems("items set " + m_ShownItemAddon + " " + key + " " + value); return; }
}
}
}
@@ -0,0 +1,163 @@
// Separate view of the same server-owned item selection and preset settings.
modded class ERR_AdminMenu
{
protected ref array<SCR_ButtonTextComponent> m_WeightButtons = {};
protected ref map<string, Widget> m_WeightRows = new map<string, Widget>();
protected ref map<string, int> m_WeightSaved = new map<string, int>();
protected ref array<string> m_WeightMods = {};
protected string m_WeightAddon;
protected int m_WeightPage = -1;
override void OnMenuOpen()
{
super.OnMenuOpen();
foreach (string name : {"WeightsShow", "WeightsPrevious", "WeightsNext", "ModWeightApply", "ModWeightNormal", "ModWeightHigh"})
{
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(name, GetRootWidget());
if (!button) { Close(); return; }
button.m_OnClicked.Insert(OnWeightsButton); m_WeightButtons.Insert(button);
}
UpdateActions();
}
protected void ClearWeightRows()
{
foreach (string id, Widget row : m_WeightRows)
{
SCR_ButtonTextComponent.GetButtonText("WeightApply", row).m_OnClicked.Remove(OnWeightRow);
SCR_ButtonTextComponent.GetButtonText("WeightNormal", row).m_OnClicked.Remove(OnWeightRow);
row.RemoveFromHierarchy();
}
m_WeightRows.Clear(); m_WeightSaved.Clear();
}
override void OnMenuClose()
{
foreach (SCR_ButtonTextComponent button : m_WeightButtons) if (button) button.m_OnClicked.Remove(OnWeightsButton);
m_WeightButtons.Clear(); ClearWeightRows(); m_WeightMods.Clear(); m_WeightAddon = ""; m_WeightPage = -1;
super.OnMenuClose();
}
protected override void UpdateActions()
{
super.UpdateActions();
bool ready = m_Ready && !m_ServerAddonKeys.IsEmpty();
bool known = ready && m_WeightSaved.Contains("*");
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("ModWeightNormal", GetRootWidget()), known && m_WeightSaved.Get("*") == 1);
ERR_SelectionStyle.Choice(SCR_ButtonTextComponent.GetButtonText("ModWeightHigh", GetRootWidget()), known && m_WeightSaved.Get("*") == 100);
foreach (SCR_ButtonTextComponent button : m_WeightButtons)
{
string name = button.GetRootWidget().GetName();
bool enabled = ready;
if (name == "WeightsPrevious") enabled = ready && m_ShownItemPage > 0;
if (name == "WeightsNext") enabled = ready && m_ShownItemPage < m_LastItemPage;
if (name.StartsWith("ModWeight")) enabled = ready && !m_WeightAddon.IsEmpty();
button.SetEnabled(enabled);
}
foreach (string id, Widget row : m_WeightRows)
{
SCR_ButtonTextComponent.GetButtonText("WeightApply", row).SetEnabled(ready);
SCR_ButtonTextComponent.GetButtonText("WeightNormal", row).SetEnabled(ready);
}
}
override void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
super.ReceiveSnapshot(authorized, values, status);
if (!authorized || !values) return;
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("WeightsMod", GetRootWidget());
if (combo && m_WeightMods.Count() != m_ServerAddonKeys.Count())
{
combo.ClearAll();
foreach (string label : m_ServerAddonLabels) combo.AddItem(label);
m_WeightMods.Copy(m_ServerAddonKeys);
combo.SetCurrentItem(Math.Max(0, m_ServerAddonKeys.Find(m_ShownItemAddon)), false, false, false);
}
bool moved = m_WeightAddon != m_ShownItemAddon || m_WeightPage != m_ShownItemPage;
if (moved)
{
ClearWeightRows(); m_WeightAddon = m_ShownItemAddon; m_WeightPage = m_ShownItemPage;
if (combo) combo.SetCurrentItem(Math.Max(0, m_ServerAddonKeys.Find(m_WeightAddon)), false, false, false);
ScrollLayoutWidget scroll = ScrollLayoutWidget.Cast(GetRootWidget().FindAnyWidget("WeightsScroll"));
if (scroll) scroll.SetSliderPos(0, 0);
}
ref map<string, string> shares = new map<string, string>();
foreach (string entry : values)
{
int divider = entry.IndexOf("="); if (divider < 0) continue;
string field = entry.Substring(0, divider), value = entry.Substring(divider + 1, entry.Length() - divider - 1);
array<string> parts = {}; value.Split("|", parts, false);
if (field == "weightChance") SetText("WeightsChance", value);
if (field == "modWeight" && parts.Count() == 3 && parts[0] == m_WeightAddon)
{
SavedWeight("*", parts[1].ToInt(), "ModWeight", GetRootWidget());
SetText("ModWeightSaved", "Saved mod weight: " + parts[1] + " | Share of extra-item choices: " + parts[2]);
}
if (field == "itemWeight" && parts.Count() == 3) shares.Set(parts[0], parts[1] + "|" + parts[2]);
}
foreach (string itemEntry : values)
{
if (!itemEntry.StartsWith("serverItem=")) continue;
array<string> item = {}; itemEntry.Substring(11, itemEntry.Length() - 11).Split("|", item, false);
if (item.Count() != 6 || !shares.Contains(item[0])) continue;
Widget row = m_WeightRows.Get(item[0]);
if (!row)
{
row = GetGame().GetWorkspace().CreateWidgets("{8D927ABB47916CE5}UI/layouts/ERR/ItemWeightRow.layout", GetRootWidget().FindAnyWidget("WeightsContent"));
if (!row) continue;
m_WeightRows.Set(item[0], row);
SCR_ButtonTextComponent.GetButtonText("WeightApply", row).m_OnClicked.Insert(OnWeightRow);
SCR_ButtonTextComponent.GetButtonText("WeightNormal", row).m_OnClicked.Insert(OnWeightRow);
}
array<string> weight = {}; shares.Get(item[0]).Split("|", weight, false);
SavedWeight(item[0], weight[0].ToInt(), "WeightValue", row);
TextWidget.Cast(row.FindAnyWidget("WeightLabel")).SetText(WidgetManager.Translate(item[1]));
TextWidget.Cast(row.FindAnyWidget("WeightState")).SetText("Saved item weight: " + weight[0] + " | Share of extra-item choices: " + weight[1] + " | " + item[3]);
}
int index = m_ServerAddonKeys.Find(m_WeightAddon);
string title = "Choose a mod above.";
if (index >= 0) title = m_ServerAddonLabels[index] + " | " + (m_ShownItemPage + 1).ToString() + "/" + (m_LastItemPage + 1).ToString();
SetText("WeightsPageState", title);
UpdateActions();
}
protected void SavedWeight(string id, int value, string field, Widget root)
{
// Polls must not overwrite an unsaved edit while its server value is unchanged.
if (!m_WeightSaved.Contains(id) || m_WeightSaved.Get(id) != value)
SCR_EditBoxComponent.GetEditBoxComponent(field, root).SetValue(value.ToString());
m_WeightSaved.Set(id, value);
}
protected bool WeightReady()
{
return m_Ready && IsFocused() && m_Controller && m_Age <= 6 && m_ClickDelay <= 0;
}
protected void SaveWeight(string item, string text)
{
int weight;
if (!WeightReady() || m_WeightAddon.IsEmpty()) return;
if (!ERR_CommandArguments.Integer(text.Trim(), weight) || weight < 0 || weight > 100) { ReceiveReply("Enter a weight from 0 to 100. 1 is normal; 100 is very high."); return; }
SubmitItems("items weight " + m_WeightAddon + " " + item + " " + weight.ToString());
}
protected void OnWeightsButton(SCR_ButtonBaseComponent button)
{
if (!button || !IsFocused()) return;
string name = button.GetRootWidget().GetName();
if (!WeightReady()) return;
if (name == "ModWeightApply") { SaveWeight("*", SCR_EditBoxComponent.GetEditBoxComponent("ModWeight", GetRootWidget()).GetValue()); return; }
if (name == "ModWeightNormal") { SCR_EditBoxComponent.GetEditBoxComponent("ModWeight", GetRootWidget()).SetValue("1"); SaveWeight("*", "1"); return; }
if (name == "ModWeightHigh") { SCR_EditBoxComponent.GetEditBoxComponent("ModWeight", GetRootWidget()).SetValue("100"); SaveWeight("*", "100"); return; }
if (name == "WeightsPrevious" || name == "WeightsNext")
{
if (m_WeightAddon.IsEmpty()) return;
int page = m_ShownItemPage - 1; if (name == "WeightsNext") page = m_ShownItemPage + 1;
SubmitItems("items list " + m_WeightAddon + " " + Math.Clamp(page, 0, m_LastItemPage).ToString()); return;
}
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("WeightsMod", GetRootWidget());
int index = combo.GetCurrentIndex();
if (index >= 0 && index < m_ServerAddonKeys.Count()) SubmitItems("items list " + m_ServerAddonKeys[index] + " 0");
}
protected void OnWeightRow(SCR_ButtonBaseComponent button)
{
if (!button || !WeightReady()) return;
foreach (string id, Widget row : m_WeightRows)
{
if (button.GetRootWidget() == row.FindAnyWidget("WeightNormal")) { SCR_EditBoxComponent.GetEditBoxComponent("WeightValue", row).SetValue("1"); SaveWeight(id, "1"); return; }
if (button.GetRootWidget() == row.FindAnyWidget("WeightApply")) { SaveWeight(id, SCR_EditBoxComponent.GetEditBoxComponent("WeightValue", row).GetValue()); return; }
}
}
}
@@ -9,16 +9,33 @@ class ERR_Settings
static array<string> Keys()
{
return {"keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
return {"aiDoctrine", "aiShadow", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
}
static bool Valid(string key, int value)
{
if (key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1;
if (key == "motorbikeCount") return value >= 0 && value <= 6;
if (key == "planeAltitudeMetres") return value >= 150 && value <= 500;
if (key == "planeReplacementSeconds") return value >= 30 && value <= 1800;
if (key == "planeSectorSeconds") return value >= 30 && value <= 600;
if (key == "planeReconEnabled") return value == 0 || value == 1;
if (key == "fieldCampCount") return value >= 1 && value <= 16;
if (key == "fieldHospitalCount") return value >= 1 && value <= 8;
if (key == "startWeaponCount") return value >= 0 && value <= 12;
if (key == "startMagazineCount") return value >= 0 && value <= 48;
if (key == "objectGuardEvery") return value >= 1 && value <= 4;
if (key == "objectGuardHQMode") return value >= 0 && value <= 2;
if (key == "objectGuardCount") return value >= 0 && value <= 16;
if (key == "hunterSpawnDistanceMetres") return value >= 800 && value <= 3000;
if (key == "roadblockCount") return value >= 0 && value <= 12;
if (key == "civilianTrafficCount") return value >= 0 && value <= 6;
if (key == "startMode") return value == 0 || value == 1;
if (key == "keepWeaponOnUnconscious") return value == 0 || value == 1;
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 == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent") return value >= 0 && value <= 100;
if (key == "automaticEquipmentChancePercent" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent") return value >= 0 && value <= 100;
if (key == "reportIntervalSeconds") return value >= 5 && value <= 60;
if (key == "supportSquadLimit") return value >= 0 && value <= 8;
if (key == "responseRadiusMetres") return value >= 500 && value <= 5000;
@@ -27,6 +44,7 @@ class ERR_Settings
if (key == "startTimeMinutes") return value >= 0 && value <= 1439;
if (key == "timeMultiplier") return value >= 1 && value <= 24;
if (key == "weatherProfile") return value >= 0 && value <= 3;
if (key == "viewDistanceMetres") return value == 0 || (value >= 500 && value <= 10000);
if (key == "startDateCode")
{
if (value == 0) return true;
@@ -51,7 +69,7 @@ class ERR_Settings
// Saved modes are distinct from validated concrete gameplay values.
static bool Randomizable(string key)
{
return key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "heliTimerMinutes" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres" || key == "aiIntelChancePercent" || key == "intelRevealChancePercent" || key == "radioDefenceSeconds" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile";
return key == "automaticEquipmentChancePercent" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || 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)
{
@@ -73,7 +91,7 @@ class ERR_Settings
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 == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || key == "aiIntelChancePercent" || key == "intelRevealChancePercent") maximum = 100;
else if (key == "automaticEquipmentChancePercent" || key == "antiAirChancePercent" || key == "antiPersonnelMineChancePercent" || 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; }
@@ -97,21 +115,57 @@ class ERR_Settings
static int AddedDefault(string key)
{
if (key == "aiDoctrine" || key == "aiShadow") return 0;
if (key == "motorbikeCount") return 2;
if (key == "planeAltitudeMetres") return 250;
if (key == "planeReplacementSeconds") return 60;
if (key == "planeSectorSeconds") return 120;
if (key == "planeReconEnabled") return 1;
if (key == "fieldCampCount") return 12;
if (key == "fieldHospitalCount") return 6;
if (key == "startWeaponCount") return 5;
if (key == "startMagazineCount") return 8;
if (key == "objectGuardEvery") return 3;
if (key == "objectGuardHQMode") return 1;
if (key == "objectGuardCount") return 8;
if (key == "hunterSpawnDistanceMetres") return 1000;
if (key == "roadblockCount") return 4;
if (key == "civilianTrafficCount") return 3;
if (key == "startMode") return 0;
if (key == "keepWeaponOnUnconscious") return 1;
if (key == "heliTimerMinutes") return 15;
if (key == "convoyCount") return 3;
if (key == "automaticConvoyEnabled") return 1;
if (key == "automaticEquipmentChancePercent") return 25;
if (key == "antiAirChancePercent") return 7;
if (key == "antiPersonnelMineChancePercent") return 60;
if (key == "startTimeMinutes") return 840;
if (key == "timeMultiplier") return 1;
if (key == "startDateCode" || key == "weatherProfile") return 0;
if (key == "startDateCode" || key == "weatherProfile" || key == "viewDistanceMetres") return 0;
return -1;
}
void ERR_Settings(string path = PATH)
{
m_Path = path;
m_Values.Set("aiDoctrine", AddedDefault("aiDoctrine"));
m_Values.Set("aiShadow", AddedDefault("aiShadow"));
m_Values.Set("motorbikeCount", AddedDefault("motorbikeCount"));
m_Values.Set("planeAltitudeMetres", AddedDefault("planeAltitudeMetres"));
m_Values.Set("planeReplacementSeconds", AddedDefault("planeReplacementSeconds"));
m_Values.Set("planeSectorSeconds", AddedDefault("planeSectorSeconds"));
m_Values.Set("planeReconEnabled", AddedDefault("planeReconEnabled"));
m_Values.Set("fieldCampCount", AddedDefault("fieldCampCount"));
m_Values.Set("fieldHospitalCount", AddedDefault("fieldHospitalCount"));
m_Values.Set("startWeaponCount", AddedDefault("startWeaponCount"));
m_Values.Set("startMagazineCount", AddedDefault("startMagazineCount"));
m_Values.Set("objectGuardCount", AddedDefault("objectGuardCount"));
m_Values.Set("objectGuardEvery", AddedDefault("objectGuardEvery"));
m_Values.Set("objectGuardHQMode", AddedDefault("objectGuardHQMode"));
m_Values.Set("hunterSpawnDistanceMetres", AddedDefault("hunterSpawnDistanceMetres"));
m_Values.Set("roadblockCount", AddedDefault("roadblockCount"));
m_Values.Set("civilianTrafficCount", AddedDefault("civilianTrafficCount"));
m_Values.Set("startMode", AddedDefault("startMode"));
m_Values.Set("keepWeaponOnUnconscious", AddedDefault("keepWeaponOnUnconscious"));
m_Values.Set("reportIntervalSeconds", 10);
m_Values.Set("supportSquadLimit", 4);
@@ -123,6 +177,8 @@ class ERR_Settings
m_Values.Set("timeMultiplier", AddedDefault("timeMultiplier"));
m_Values.Set("startDateCode", AddedDefault("startDateCode"));
m_Values.Set("weatherProfile", AddedDefault("weatherProfile"));
m_Values.Set("viewDistanceMetres", AddedDefault("viewDistanceMetres"));
m_Values.Set("automaticEquipmentChancePercent", AddedDefault("automaticEquipmentChancePercent"));
m_Values.Set("antiAirChancePercent", AddedDefault("antiAirChancePercent"));
m_Values.Set("antiPersonnelMineChancePercent", AddedDefault("antiPersonnelMineChancePercent"));
m_Values.Set("automaticConvoyEnabled", AddedDefault("automaticConvoyEnabled"));
@@ -131,7 +187,7 @@ class ERR_Settings
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"))
else if (ERR_ProfileConfig.Exists(m_Path) || ERR_ProfileConfig.Exists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Reintroduced: invalid settings; defaults active, existing files preserved", LogLevel.ERROR);
@@ -143,7 +199,10 @@ class ERR_Settings
protected bool Read(string path, out map<string, int> values)
{
JsonLoadContext json = new JsonLoadContext();
if (!json.LoadFromFile(path)) return false;
return ERR_ProfileConfig.Load(path, json) && Parse(json, values);
}
static bool Parse(JsonLoadContext json, out map<string, int> values)
{
values = new map<string, int>();
foreach (string key : Keys())
{
@@ -184,6 +243,13 @@ class ERR_Settings
if (option == key) saved = value;
json.WriteValue(option, saved);
}
if (m_Path == PATH)
{
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
if (Get(key) != value) m_Resolved.Remove(key);
m_Values.Set(key, value);
return true;
}
map<string, int> checked;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked)) return false;
foreach (string option : Keys())
@@ -197,7 +263,7 @@ class ERR_Settings
if (hadPrevious && !FileIO.CopyFile(m_Path, m_Path + ".bak")) return false;
if (!FileIO.CopyFile(m_Path + ".tmp", m_Path) || !Read(m_Path, checked))
{
if (FileIO.FileExists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
if (ERR_ProfileConfig.Exists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
else if (!hadPrevious) FileIO.DeleteFile(m_Path);
return false;
}
@@ -207,7 +273,7 @@ class ERR_Settings
if (option == key) expected = value;
if (checked.Get(option) != expected)
{
if (FileIO.FileExists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
if (ERR_ProfileConfig.Exists(m_Path + ".bak")) FileIO.CopyFile(m_Path + ".bak", m_Path);
else if (!hadPrevious) FileIO.DeleteFile(m_Path);
return false;
}
@@ -22,6 +22,8 @@ class ERR_SiteProp
[BaseContainerProps()]
class ERR_SiteTransport
{
[Attribute("0", UIWidgets.CheckBox, "Omit this transport on preparation failure instead of failing the mission") ]
bool Optional;
[Attribute("", UIWidgets.ResourceNamePicker, "One finite wheeled transport; never replaced", "et")]
ResourceName Prefab;
[Attribute("0 0 0", UIWidgets.Coords, "Parking offset from the site; ground height is sampled")]
@@ -96,6 +98,7 @@ class ERR_SiteDefinition
[BaseContainerProps()]
class ERR_ScenarioDefinition
{
bool TerrainGenerated;
[Attribute()]
ref array<ref ERR_SiteDefinition> Sites;
[Attribute("2", UIWidgets.EditBox, "Independent accessible caches containing enough radio documents") ]
@@ -110,6 +113,63 @@ class ERR_ScenarioDefinition
return null;
}
// Keep objective/source identities; only optional population changes per round.
// Each addition is a fresh authored instance, so stock/props are not aliased.
bool ConfigurePopulation(ResourceName resource, int camps, int hospitals)
{
if (!ERR_Settings.Valid("fieldCampCount", camps) || !ERR_Settings.Valid("fieldHospitalCount", hospitals)) return false;
if (!ResizeOptional(resource, ERR_LocationKind.OUTPOST, camps, "everon-camp-2", "Field camp", "err-extra-camp-")) return false;
if (!ResizeOptional(resource, ERR_LocationKind.HOSPITAL, hospitals, "everon-medical-2", "Field hospital", "err-extra-hospital-")) return false;
// More locations must not consume the global spawn budget before scouts,
// mortars and mobile hunting forces can initialize. Keep the original field
// force budget (32 defenders + 16 mobile soldiers) above eight sites.
int total = camps + hospitals;
if (total > 8)
{
int index;
foreach (ERR_SiteDefinition site : Sites)
{
if (site.Kind != ERR_LocationKind.OUTPOST && site.Kind != ERR_LocationKind.HOSPITAL) continue;
site.GuardCount = SharedRoster(32, total, index);
site.ResponseCount = SharedRoster(16, total, index);
index++;
}
}
return Valid();
}
static int SharedRoster(int budget, int sites, int index)
{
if (budget < 0 || sites <= 0 || index < 0 || index >= sites) return 0;
int count = budget / sites;
if (index < budget % sites) count++;
return count;
}
protected bool ResizeOptional(ResourceName resource, ERR_LocationKind kind, int wanted, string templateId, string label, string prefix)
{
int count;
foreach (ERR_SiteDefinition existing : Sites) if (existing.Kind == kind) count++;
for (int index = Sites.Count() - 1; index >= 0 && count > wanted; index--)
{
ERR_SiteDefinition candidate = Sites[index];
if (candidate.Kind != kind || candidate.RevealOnEscape) continue;
Sites.Remove(index); count--;
}
if (count > wanted) return false;
while (count < wanted)
{
ref ERR_ScenarioDefinition fresh = ERR_ScenarioDefinition.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(resource));
if (!fresh) return false;
ERR_SiteDefinition extra = fresh.Find(templateId);
if (!extra || extra.Kind != kind || extra.RevealOnEscape || extra.Transport) return false;
extra.Id = prefix + (count + 1).ToString();
if (Find(extra.Id)) return false;
extra.Label = label + " " + (count + 1).ToString();
extra.RandomClear = true; extra.PlacementResolved = false;
Sites.Insert(extra); count++;
}
return true;
}
bool Valid()
{
if (ScoutCount < 0 || ScoutCount > 32) return false;
@@ -191,6 +251,6 @@ class ERR_ScenarioDefinition
sources++;
}
}
return prisons == 1 && radios == 1 && extractions == 1 && groups <= 24 && items <= 512 && props <= 128 && transports <= 4 && sources >= MinimumRadioSources && maps >= 1;
return prisons == 1 && radios == 1 && extractions == 1 && groups <= 64 && items <= 1024 && props <= 128 && transports <= 4 && sources >= MinimumRadioSources && maps >= 1;
}
}
@@ -12,6 +12,8 @@ class ERR_SiteRuntime
ref ERR_SiteDefinition Definition;
ERR_LocationComponent Location;
IEntity Container;
ref array<IEntity> DepotMagazines = {}; // Exact authored startup identities; never refreshed from live inventory.
ref array<IEntity> DepotMines = {}; // At most two authored inert PMN4 per camp, never replenished.
SCR_AIGroup Group;
ref ERR_SiteRuntime Responders;
string ExpectedGuardFaction;
@@ -20,6 +22,10 @@ class ERR_SiteRuntime
int LastInitialPhysical = -1;
int LastInitialConscious = -1;
Vehicle Transport;
IEntity TransportEntity;
bool TransportAttempted;
bool TransportOmitted;
float TransportDeadline = -1;
AIWaypointCycle PatrolCycle;
ref array<AIWaypoint> PatrolWaypoints = {};
ref array<IEntity> Props = {};
@@ -41,6 +47,7 @@ class ERR_SiteService
protected bool m_Begun;
protected bool m_Stopped;
protected bool m_Activated;
protected bool m_SingleSite;
protected bool m_Obstructed;
protected string m_FailureReason;
protected float m_NextDiscovery;
@@ -53,6 +60,52 @@ class ERR_SiteService
if (index < 0 || index >= m_Sites.Count()) return null;
return m_Sites[index];
}
// Optional ground loading consumes only retained original camp items.
int DepotMagazineCount(ERR_SiteRuntime site)
{
if (!Replication.IsServer() || m_Stopped || m_State != ERR_PrepareResult.READY || !site || !m_Sites.Contains(site)) return 0;
if (!site.Definition || site.Definition.Kind != ERR_LocationKind.OUTPOST || site.Definition.RevealOnEscape) return 0;
if (!site.Container || !Owns(site.Container) || !site.Responders || !site.Responders.Group) return 0;
if (site.DepotMagazines.Count() > 96) return 0;
return site.DepotMagazines.Count();
}
int DepotMineCount(ERR_SiteRuntime site)
{
if (!Replication.IsServer() || m_Stopped || m_State != ERR_PrepareResult.READY || !site || !m_Sites.Contains(site)) return 0;
if (!site.Definition || site.Definition.Kind != ERR_LocationKind.OUTPOST || site.Definition.RevealOnEscape) return 0;
if (!site.Container || !Owns(site.Container) || !site.Responders || !site.Responders.Group) return 0;
if (site.DepotMines.Count() > 2) return 0;
return site.DepotMines.Count();
}
int DepotItemCount(ERR_SiteRuntime site)
{
if (!site || site.DepotMagazines.Count() > 96 || site.DepotMines.Count() > 2) return 0;
int count = DepotMagazineCount(site) + DepotMineCount(site);
if (count > 96) return 0;
return count;
}
IEntity DepotItem(ERR_SiteRuntime site, int index)
{
if (index < 0 || index >= DepotItemCount(site)) return null;
int magazines = DepotMagazineCount(site);
if (index < magazines) return site.DepotMagazines[index];
return site.DepotMines[index - magazines];
}
void DepotStock(ERR_SiteRuntime site, out array<IEntity> items)
{
items = {};
int count = DepotItemCount(site);
for (int i = 0; i < count; i++) items.Insert(DepotItem(site, i));
}
IEntity DepotMagazine(ERR_SiteRuntime site, int index)
{
if (index < 0 || index >= DepotMagazineCount(site)) return null;
return site.DepotMagazines[index];
}
bool DepotOriginal(ERR_SiteRuntime site, IEntity item)
{
return item && DepotItemCount(site) > 0 && (site.DepotMagazines.Contains(item) || site.DepotMines.Contains(item));
}
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.
@@ -206,27 +259,79 @@ class ERR_SiteService
PrintFormat("Reintroduced [sites]: transport parking=%1 radius=%2 terrainRange=%3 obstructed=%4 clear=%5", ERR_Actors.Ground(position), radius, highest - lowest, m_Obstructed, clear);
return clear;
}
protected bool CreateTransport(ERR_SiteRuntime site, vector sitePosition)
// Native recursive deletion must never consume an occupant or character that
// acquired this candidate. Unknown/oversized hierarchies are relinquished.
protected bool TransportEmpty(IEntity exact)
{
array<IEntity> pending = {exact};
int visited;
while (!pending.IsEmpty())
{
if (++visited > 128) return false;
IEntity entity = pending[0]; pending.Remove(0);
if (!entity) continue;
if (ERR_Actors.Player(entity) || ChimeraCharacter.Cast(entity) || SCR_AIGroup.Cast(entity)) return false;
BaseCompartmentManagerComponent manager = BaseCompartmentManagerComponent.Cast(entity.FindComponent(BaseCompartmentManagerComponent));
if (Vehicle.Cast(entity) && !manager) return false;
if (manager)
{
array<BaseCompartmentSlot> seats = {}; manager.GetCompartments(seats);
if (seats.Count() > 64) return false;
foreach (BaseCompartmentSlot seat : seats) if (!seat || seat.GetOccupant()) return false;
}
IEntity child = entity.GetChildren();
while (child)
{
if (pending.Count() >= 128) return false;
pending.Insert(child); child = child.GetSibling();
}
}
return true;
}
// Only the transport identity recorded by CreateTransport may be omitted.
// Required transports preserve the existing whole-preparation failure policy.
protected bool TransportFailure(ERR_SiteRuntime site, string reason)
{
if (!site.Definition.Transport.Optional) return Fail(reason);
if (site.TransportOmitted) return true;
site.TransportOmitted = true;
IEntity exact = site.TransportEntity;
site.TransportEntity = null;
site.Transport = null;
if (exact && m_Entities.Contains(exact))
{
m_Entities.RemoveItem(exact);
if (TransportEmpty(exact)) ERR_Actors.DeleteSpawned(exact);
else PrintFormat("Reintroduced [sites]: optional transport cleanup relinquished: %1; occupied or uncertain candidate retained", site.Definition.Id);
}
PrintFormat("Reintroduced [sites]: optional transport omitted: %1; %2", site.Definition.Id, reason);
return true;
}
protected bool CreateTransport(ERR_SiteRuntime site, vector sitePosition, bool allowOptional = false)
{
ERR_SiteTransport spec = site.Definition.Transport;
if (!spec) return true;
if (!spec || (spec.Optional && !allowOptional)) return true;
if (site.TransportAttempted) return spec.Optional || site.TransportOmitted || site.Transport != null;
site.TransportAttempted = true;
vector position = ERR_Actors.Ground(sitePosition + spec.Offset);
if (!TransportClearance(position, spec.ClearanceRadius, spec.MaximumTerrainRange)) return Fail("obstructed or unsafe transport parking: " + site.Definition.Id);
if (!TransportClearance(position, spec.ClearanceRadius, spec.MaximumTerrainRange)) return TransportFailure(site, "obstructed or unsafe transport parking: " + site.Definition.Id);
IEntity entity = ERR_Actors.Spawn(spec.Prefab, position, Vector(spec.Heading, 0, 0));
if (!entity) return Fail("transport prefab: " + site.Definition.Id);
if (!entity) return TransportFailure(site, "transport prefab: " + site.Definition.Id);
m_Entities.Insert(entity);
site.TransportEntity = entity;
if (spec.Optional) site.TransportDeadline = GetGame().GetWorld().GetWorldTime() + 15000;
site.Transport = Vehicle.Cast(entity);
if (!site.Transport) return Fail("transport must be a vehicle: " + site.Definition.Id);
if (!site.Transport) return TransportFailure(site, "transport must be a vehicle: " + site.Definition.Id);
CarControllerComponent controller = CarControllerComponent.Cast(site.Transport.FindComponent(CarControllerComponent));
if (!controller) return Fail("transport must be wheeled: " + site.Definition.Id);
if (!controller) return TransportFailure(site, "transport must be wheeled: " + site.Definition.Id);
controller.SetPersistentHandBrake(true);
ERR_VehicleSecurityComponent security = ERR_VehicleSecurityComponent.Find(site.Transport);
if (security && !security.Configure()) return Fail("transport security configuration: " + site.Definition.Id);
if (security && !security.Configure()) return TransportFailure(site, "transport security configuration: " + site.Definition.Id);
return true;
}
protected bool TransportReady(ERR_SiteRuntime site)
{
if (!site.Definition.Transport) return true;
if (!site.Definition.Transport || site.TransportOmitted) return true;
Vehicle vehicle = site.Transport;
if (!ERR_Actors.Alive(vehicle)) return false;
vector low, high;
@@ -268,37 +373,45 @@ class ERR_SiteService
return false;
}
bool Begin(ERR_ScenarioDefinition definition, ResourceName fieldMap)
bool Begin(ERR_ScenarioDefinition definition, ResourceName fieldMap, bool startupOnly = false, ERR_SiteDefinition only = null)
{
if (!Replication.IsServer() || m_Begun || m_Stopped) return false;
m_Begun = true;
m_Begun = true; m_SingleSite = only != null;
if (!definition || !definition.Valid()) return Fail("invalid scenario definition");
int startWeapons = m_Mode.Settings().Get("startWeaponCount");
int startMagazines = m_Mode.Settings().Get("startMagazineCount");
m_Deadline = GetGame().GetWorld().GetWorldTime() + 15000;
m_GuardDeadline = GetGame().GetWorld().GetWorldTime() + ERR_InitialGuardRules.TimeoutMilliseconds();
foreach (ERR_SiteDefinition spec : definition.Sites)
{
if (only && spec != only) continue;
if (startupOnly && !ERR_GeneratedSites.StartupSite(spec)) continue;
vector position = ERR_Actors.Ground(spec.Position);
if (position[1] < 1) return Fail("site below sea level: " + spec.Id);
vector rootPosition = position;
if (spec.PlacementResolved && spec.Kind == ERR_LocationKind.PRISON) rootPosition = spec.Position;
rootPosition[1] = rootPosition[1] + spec.RootHeight;
if (spec.Kind == ERR_LocationKind.EXTRACTION)
{
rootPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(spec.AircraftOffset, spec.Heading));
if (spec.RequireAirfield && !ERR_AirfieldPlacement.Clear(rootPosition)) return Fail("extraction airfield pad is missing or obstructed; no off-airfield fallback");
if (definition.TerrainGenerated && !spec.RequireAirfield && !ERR_AirfieldPlacement.ClearGround(rootPosition)) return Fail("generated extraction landing zone is obstructed");
}
// Spawn the replicated marker at the final pickup coordinate from the start.
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.SetPrepared(false);
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, spec.Heading);
if (spec.Kind == ERR_LocationKind.EXTRACTION)
{
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);
site.Location.Configure(spec.Id, spec.Kind, "Extraction pickup - throw smoke here");
if (!m_Mode.Extraction().ConfigurePickup(rootPosition)) return Fail("extraction pickup registration or safe landing space for the selected faction's transport fleet");
}
if (!spec.Container.IsEmpty())
{
@@ -321,7 +434,13 @@ class ERR_SiteService
{
ResourceName resource = stock.Prefab;
if (resource == ERR_Supplies.Map()) resource = fieldMap;
for (int i = 0; i < stock.Count; i++)
int quantity = stock.Count;
if (spec.Kind == ERR_LocationKind.PRISON)
{
if (stock.Prefab == ERR_Supplies.Pistol()) quantity = startWeapons;
else if (stock.Prefab == ERR_Supplies.Magazine()) quantity = startMagazines;
}
for (int i = 0; i < quantity; i++)
{
// Created exactly once; insertion retries always use this same entity.
IEntity item = Spawn(resource, site.Container.GetOrigin());
@@ -333,6 +452,13 @@ class ERR_SiteService
document.SetTarget(stock.TargetId);
}
ERR_StockPlacement placement = new ERR_StockPlacement();
if (spec.Kind == ERR_LocationKind.OUTPOST && !spec.RevealOnEscape && stock.TargetId.IsEmpty()
&& site.DepotMagazines.Count() + site.DepotMines.Count() < 96)
{
if (item.FindComponent(BaseMagazineComponent)) site.DepotMagazines.Insert(item);
else if (stock.Prefab == "{B05A816C0BF50802}Prefabs/Weapons/Explosives/Mine_PMN4/Mine_PMN4.et"
&& site.DepotMines.Count() < 2) site.DepotMines.Insert(item);
}
placement.Item = item;
placement.Inventory = inventory;
m_Placements.Insert(placement);
@@ -492,13 +618,14 @@ class ERR_SiteService
{
if (!Replication.IsServer() || !m_Begun || m_Stopped) return ERR_PrepareResult.FAILED;
if (m_State != ERR_PrepareResult.PENDING) return m_State;
if (!m_ExtractionPlacement) { Fail("extraction placement owner missing"); return m_State; }
ERR_PrepareResult aircraftPlacement = m_ExtractionPlacement.Poll(GetGame().GetWorld().GetWorldTime());
if (!m_Mode.Extraction().PickupConfigured() && !m_SingleSite) { Fail("extraction pickup point missing"); return m_State; }
ERR_PrepareResult aircraftPlacement = ERR_PrepareResult.READY;
if (m_ExtractionPlacement) aircraftPlacement = m_ExtractionPlacement.Poll(GetGame().GetWorld().GetWorldTime());
if (aircraftPlacement == ERR_PrepareResult.FAILED) { Fail(m_ExtractionPlacement.FailureReason()); return m_State; }
int pending, accepted, pendingGuards;
foreach (ERR_SiteRuntime site : m_Sites)
{
if (!TransportReady(site))
if (site.Definition.Transport && !site.Definition.Transport.Optional && !TransportReady(site))
{
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) Fail("transport needs fuel, driver/passenger seats and a fitting parking footprint: " + site.Definition.Id);
return m_State;
@@ -531,13 +658,46 @@ class ERR_SiteService
return m_State;
}
if (pendingGuards > 0 || aircraftPlacement == ERR_PrepareResult.PENDING) return m_State;
// All required scenery, stock and physical guards now exist. Survey optional
// parking once against that real population, then bound only vehicle settling.
int pendingTransports;
foreach (ERR_SiteRuntime optionalSite : m_Sites)
{
if (!optionalSite.Definition.Transport || !optionalSite.Definition.Transport.Optional) continue;
if (!CreateTransport(optionalSite, optionalSite.Definition.Position, true)) return m_State;
if (TransportReady(optionalSite)) continue;
if (GetGame().GetWorld().GetWorldTime() > optionalSite.TransportDeadline)
{
if (!TransportFailure(optionalSite, "transport needs fuel, driver/passenger seats and a fitting parking footprint: " + optionalSite.Definition.Id)) return m_State;
}
else pendingTransports++;
}
if (pendingTransports > 0) return m_State;
foreach (IEntity entity : m_Entities) m_Mode.Own(entity);
m_State = ERR_PrepareResult.READY;
foreach (ERR_SiteRuntime readySite : m_Sites) if (readySite.Location) readySite.Location.SetPrepared(true);
PrintFormat("Reintroduced [sites]: %1 sites and %2 physical stock items ready", m_Sites.Count(), m_Placements.Count());
m_Placements.Clear();
return m_State;
}
// Commit a separately prepared site without reopening or rolling back the
// playable prison. Publish the aggregate before HQ resolves its home roster.
void InheritContentIndex(ERR_SiteService previous)
{
if (!m_Begun && previous) m_ContentGuardIndex = previous.m_ContentGuardIndex;
}
bool Adopt(ERR_SiteService ready)
{
if (m_Stopped || m_State != ERR_PrepareResult.READY || !ready || ready == this || ready.m_Stopped || ready.m_State != ERR_PrepareResult.READY || !ready.m_SingleSite) return false;
foreach (ERR_SiteRuntime site : ready.m_Sites) m_Sites.Insert(site);
foreach (IEntity entity : ready.m_Entities) m_Entities.Insert(entity);
m_ContentGuardIndex = ready.m_ContentGuardIndex;
if (m_Activated) ready.Activate();
if (m_Mode.Phase() >= ERR_Phase.RADIO_SEARCH) ready.PhaseChanged(ERR_Phase.PRISON, ERR_Phase.RADIO_SEARCH);
ready.m_Sites.Clear(); ready.m_Entities.Clear();
return true;
}
void Activate()
{
if (m_State != ERR_PrepareResult.READY || m_Stopped || m_Activated) return;
@@ -604,6 +764,12 @@ class ERR_SiteService
}
void Rollback()
{
// An unrelated required failure can arrive while an optional candidate is
// settling. Apply the same occupant-safe omission before recursive rollback.
if (m_State == ERR_PrepareResult.PENDING)
foreach (ERR_SiteRuntime optionalSite : m_Sites)
if (optionalSite.Definition.Transport && optionalSite.Definition.Transport.Optional && optionalSite.TransportEntity)
TransportFailure(optionalSite, "preparation rolled back before optional transport readiness");
Stop();
// Children/items are listed after parents; delete them first.
for (int i = m_Entities.Count() - 1; i >= 0; i--) ERR_Actors.DeleteSpawned(m_Entities[i]);
@@ -1,3 +1,29 @@
// A newly created character exists before its owner finishes scene replication.
// This lease belongs to its admission ticket, never to a connection ID or timer.
// Preserve pre-existing damage policy; cancellation/destruction is idempotent.
class ERR_SpawnLoadingProtection
{
protected DamageManagerComponent m_Damage;
protected bool m_RestoreDamage;
bool Begin(IEntity actor)
{
if (!Replication.IsServer() || !actor || m_Damage) return false;
DamageManagerComponent damage = DamageManagerComponent.Cast(actor.FindComponent(DamageManagerComponent));
if (!damage || damage.IsDestroyed()) return false;
m_Damage = damage;
m_RestoreDamage = damage.IsDamageHandlingEnabled();
damage.EnableDamageHandling(false);
return true;
}
void Stop()
{
if (Replication.IsServer() && m_Damage && m_RestoreDamage) m_Damage.EnableDamageHandling(true);
m_Damage = null;
m_RestoreDamage = false;
}
void ~ERR_SpawnLoadingProtection() { Stop(); }
}
class ERR_SpawnTicket
{
int ConnectionId;
@@ -7,6 +33,19 @@ class ERR_SpawnTicket
SCR_SpawnRequestComponent Request;
ref SCR_SpawnData Data;
IEntity Actor;
ref ERR_SpawnLoadingProtection Protection;
bool ProtectLoading()
{
if (Protection) return false;
Protection = new ERR_SpawnLoadingProtection();
return Protection.Begin(Actor);
}
void ReleaseProtection()
{
if (Protection) Protection.Stop();
Protection = null;
}
void ~ERR_SpawnTicket() { ReleaseProtection(); }
}
class ERR_SpawnFinalizationRules
@@ -55,6 +94,9 @@ modded class SCR_FreeSpawnHandlerComponent
if (ERR_GameMode.Get() && requestComponent)
{
if (!requestComponent.ERR_SpawnSceneMayFinish()) return false;
// An expired/invalid scene must reach rejection even if the remote native
// preload completion is delayed. It can never authorize actor assignment.
if (!requestComponent.ERR_SpawnSceneReady()) return true;
if (requestComponent.ERR_TakeScenePreload()) super.OnFinalizeBegin_S(requestComponent, data, entity);
if (!super.CanFinalize_S(requestComponent, data, entity)) return false;
return requestComponent.ERR_VerifyLoadedScene();
@@ -82,8 +124,9 @@ modded class SCR_FreeSpawnHandlerComponent
SCR_ESpawnResult result = super.HandleRequest_S(requestComponent, data, spawnedEntity);
if (result != SCR_ESpawnResult.OK) return result;
ticket.Actor = spawnedEntity;
if (!logic.TrackSpawnTicket(ticket))
if (!ticket.ProtectLoading() || !logic.TrackSpawnTicket(ticket))
{
ticket.ReleaseProtection();
ERR_RejectOwnedActor(requestComponent, spawnedEntity, data);
return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
}
@@ -100,10 +143,12 @@ modded class SCR_FreeSpawnHandlerComponent
if (!requestComponent.ERR_SpawnSceneVerified() || !logic.MayFinalizeSpawn(ticket))
{
requestComponent.ERR_EndSpawnScene();
ticket.ReleaseProtection();
ERR_RejectOwnedActor(requestComponent, entity, data);
return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
}
SCR_ESpawnResult result = super.FinalizeRequest_S(requestComponent, data, entity);
ticket.ReleaseProtection();
if (result == SCR_ESpawnResult.OK) requestComponent.ERR_RetainSpawnScene(entity);
requestComponent.ERR_EndSpawnScene();
if (result != SCR_ESpawnResult.OK) ERR_RejectOwnedActor(requestComponent, entity, data);
@@ -217,6 +217,7 @@ class ERR_SpawnLogic : SCR_SpawnLogic
m_SpawnTickets.Remove(i);
bool notify = sendResponse && ticket.Controller && ticket.Controller == GetGame().GetPlayerManager().GetPlayerController(playerId);
if (ticket.Request) ticket.Request.ERR_CancelAdmission(ticket.Data, notify);
ticket.ReleaseProtection();
ERR_Actors.DeleteSpawned(ticket.Actor);
}
}
@@ -279,11 +280,16 @@ class ERR_SpawnLogic : SCR_SpawnLogic
UUID identity;
if (!m_Identities.Find(playerId, identity) || !m_Attempts.Contains(playerId)) return;
if (m_Retries.Contains(playerId) && m_Retries.Get(playerId) == identity) return;
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerManager().GetPlayerController(playerId));
if (!controller) return;
m_Retries.Set(playerId, identity);
GetGame().GetCallqueue().CallLater(RetrySpawn, 1000, false, playerId, identity);
GetGame().GetCallqueue().CallLater(RetrySpawn, 1000, false, playerId, identity, controller);
}
protected void RetrySpawn(int playerId, UUID identity)
protected void RetrySpawn(int playerId, UUID identity, SCR_PlayerController expectedController)
{
// The same account may reconnect with the same connection ID. Its old queued
// callback must not consume the replacement controller's retry or attempt.
if (!expectedController || GetGame().GetPlayerManager().GetPlayerController(playerId) != expectedController) return;
if (!m_Identities.Contains(playerId) || m_Identities.Get(playerId) != identity || m_Retries.Get(playerId) != identity) return;
m_Retries.Remove(playerId);
DoSpawn_S(playerId);
@@ -0,0 +1,191 @@
// Separate case-preserving owner RPC: tokens never enter lowercased chat commands.
modded class SCR_PlayerController
{
void ERR_AdminSpeech(int action, string endpoint, string secret)
{
ERR_StartChatClient();
if (m_ERRChatClient && this == GetGame().GetPlayerController()) Rpc(ERR_AdminSpeechRequest, m_ERRChatClient.Scope(), action, endpoint, secret);
}
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
protected void ERR_AdminSpeechRequest(int token, int action, string endpoint, string secret)
{
ERR_AdminSession session = ERR_ServerSession(token);
if (session) session.Speech(action, endpoint, secret);
}
void ERR_AdminDeliverSpeech(int token, string url, string text) { Rpc(ERR_AdminSpeechResponse, token, url, text); }
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_AdminSpeechResponse(int token, string url, string text)
{
if (!m_ERRChatClient || !m_ERRChatClient.Accept(token)) return;
GetGame().ERR_PlaySpeechPreview(url, text);
}
}
modded class ERR_AdminSession
{
void Speech(int action, string endpoint, string secret)
{
if (!Authorize()) return;
float now = GetGame().GetWorld().GetWorldTime();
if (endpoint.Length() > 512 || secret.Length() > 256 || now - m_LastRequest < 250) return;
m_LastRequest = now;
ERR_SpeechService service = m_Mode.ERR_Speech();
if (!service) { SpeechReply("Speech service is unavailable in this mission."); return; }
if (action == 0) service.TestAndEnable(this, endpoint, secret);
else if (action == 1) service.Disable(this);
}
void SpeechReply(string text)
{
if (!Authorize()) return;
m_Controller.ERR_AdminDeliverReply(m_Token, text, true);
Snapshot(true);
}
void SpeechPreview(string url, string text)
{
if (!Authorize()) return;
m_Controller.ERR_AdminDeliverSpeech(m_Token, url, text);
SpeechReply("TTS verified, saved and enabled. Playing the test briefing for you only.");
}
}
modded class ERR_AdminMenu
{
protected ref array<SCR_ButtonTextComponent> m_SpeechButtons = {};
protected bool m_SpeechInitialized;
protected bool m_SpeechBusy;
protected bool m_SpeechEnabled;
protected int m_SpeechSelection = -1;
override void OnMenuOpen()
{
super.OnMenuOpen();
foreach (string name : {"TTSTest", "TTSDisable", "TTSPasteEndpoint", "TTSPasteToken"})
{
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(name, GetRootWidget());
if (!button) { Close(); return; }
button.m_OnClicked.Insert(OnSpeechButton); m_SpeechButtons.Insert(button);
if (name == "TTSTest" || name == "TTSDisable") ERR_SelectionStyle.Action(button, name == "TTSTest");
}
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("TTSToken", GetRootWidget());
if (edit)
{
EditBoxWidget secret = EditBoxWidget.Cast(edit.GetEditBoxWidget());
if (secret) secret.SetObfuscationChar("*");
edit.SetPlaceholderText("Leave blank to keep the saved token");
}
ERR_SelectionStyle.Status(GetRootWidget(), "TTSSelectionState", -1);
UpdateActions();
}
override void OnMenuClose()
{
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("TTSToken", GetRootWidget());
if (edit) edit.SetValue("");
foreach (SCR_ButtonTextComponent button : m_SpeechButtons) if (button) button.m_OnClicked.Remove(OnSpeechButton);
m_SpeechButtons.Clear(); m_SpeechInitialized = false;
super.OnMenuClose();
}
protected override void OnBack()
{
foreach (string name : {"TTSEndpoint", "TTSToken"})
{
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(name, GetRootWidget());
if (edit && edit.IsInWriteMode()) return;
}
super.OnBack();
}
protected override void UpdateActions()
{
super.UpdateActions();
bool ready = m_Ready && m_SpeechInitialized;
ERR_SelectionStyle.Pair(GetRootWidget(), "TTSTest", "TTSDisable", m_SpeechSelection);
foreach (SCR_ButtonTextComponent button : m_SpeechButtons)
{
bool enabled = ready && !m_SpeechBusy;
if (button.GetRootWidget().GetName() == "TTSDisable") enabled = ready && (m_SpeechEnabled || m_SpeechBusy);
button.SetEnabled(enabled);
}
foreach (string name : {"TTSEndpoint", "TTSToken"})
{
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(name, GetRootWidget());
if (edit) edit.SetEnabled(ready && !m_SpeechBusy);
}
if (!ready) ERR_SelectionStyle.Status(GetRootWidget(), "TTSSelectionState", -1);
}
override void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
string endpoint, message, tokenSaved; bool ready, present;
m_SpeechBusy = false; m_SpeechEnabled = false;
if (authorized && values)
foreach (string entry : values)
{
int divider = entry.IndexOf("="); if (divider < 0) continue;
string key = entry.Substring(0, divider), value = entry.Substring(divider + 1, entry.Length() - divider - 1);
if (key == "ttsUrl") { endpoint = value; present = true; }
if (key == "ttsStatus") message = value;
if (key == "ttsTokenSaved") tokenSaved = value;
if (key == "ttsReady") ready = value == "1";
if (key == "ttsBusy") m_SpeechBusy = value == "1";
if (key == "ttsEnabled") m_SpeechEnabled = value == "1";
}
if (!authorized)
{
SCR_EditBoxComponent secret = SCR_EditBoxComponent.GetEditBoxComponent("TTSToken", GetRootWidget());
if (secret) secret.SetValue("");
}
if (present && !m_SpeechInitialized)
{
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent("TTSEndpoint", GetRootWidget());
if (edit) edit.SetValue(endpoint);
m_SpeechInitialized = true;
}
int state; if (ready) state = 1; else if (m_SpeechBusy) state = -1;
m_SpeechSelection = state;
ERR_SelectionStyle.Status(GetRootWidget(), "TTSSelectionState", state);
SetText("TTSStatus", message);
string tokenInfo = "No token saved."; if (tokenSaved == "1") tokenInfo = "Token saved on the server. Blank keeps it only when the URL is unchanged.";
SetText("TTSTokenStatus", tokenInfo);
super.ReceiveSnapshot(authorized, values, status);
}
protected void OnSpeechButton(SCR_ButtonBaseComponent button)
{
if (!button || !m_Ready || !m_SpeechInitialized || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0) return;
string name = button.GetRootWidget().GetName();
if (name == "TTSPasteEndpoint" || name == "TTSPasteToken")
{
if (!m_SpeechBusy) PasteSpeechField(name == "TTSPasteToken");
return;
}
int action;
if (button.GetRootWidget().GetName() == "TTSDisable") action = 1;
SCR_EditBoxComponent endpoint = SCR_EditBoxComponent.GetEditBoxComponent("TTSEndpoint", GetRootWidget());
SCR_EditBoxComponent token = SCR_EditBoxComponent.GetEditBoxComponent("TTSToken", GetRootWidget());
if (!endpoint || !token) return;
string url, secret;
if (action == 0) { url = endpoint.GetValue().Trim(); secret = token.GetValue(); }
token.SetValue(""); // Never retain submitted secrets in a hidden menu.
m_ClickDelay = 0.3;
SetText("Feedback", "Sending speech settings to the server...");
m_Controller.ERR_AdminSpeech(action, url, secret);
}
protected void PasteSpeechField(bool tokenField)
{
// Explicit user action on this client only. Clipboard contents are never
// logged or sent to the server until the administrator tests the settings.
string value = System.ImportFromClipboard().Trim();
if (value.IsEmpty())
{
SetText("Feedback", "Game cannot read clipboard text. Copy again; on Linux, try launching without Gamescope.");
return;
}
int limit = 512;
string field = "TTSEndpoint";
if (tokenField) { limit = 256; field = "TTSToken"; }
if (value.Length() > limit || value.IndexOf("\n") >= 0 || value.IndexOf("\r") >= 0 || value.IndexOf("\t") >= 0)
{
SetText("Feedback", "Copy only one value, up to " + limit.ToString() + " characters. The field was left unchanged.");
return;
}
SCR_EditBoxComponent edit = SCR_EditBoxComponent.GetEditBoxComponent(field, GetRootWidget());
if (!edit) return;
edit.SetValue(value);
m_ClickDelay = 0.3;
SetText("Feedback", "Pasted. Choose Test & enable when both fields are ready.");
}
}
@@ -0,0 +1,237 @@
// Server-only configuration and reusable TTS requests. No provider/bridge secret
// is replicated, returned to an administrator, or passed through chat commands.
class ERR_SpeechRules
{
static bool Endpoint(string url)
{
if (!ERR_WelcomeVoiceRules.URL(url) || url.Length() > 512) return false;
return url.IndexOf("@") < 0 && url.IndexOf("?") < 0 && url.IndexOf("#") < 0 && url.IndexOf("|") < 0 && url.IndexOf("\t") < 0;
}
static string Normalize(string url)
{
url.TrimInPlace();
if (!url.IsEmpty() && !url.EndsWith("/")) url += "/";
return url;
}
static bool Token(string token)
{
return token.Length() >= 32 && token.Length() <= 256 && token.IndexOf("\n") < 0 && token.IndexOf("\r") < 0 && token.IndexOf("\t") < 0;
}
static bool Text(string text)
{
return !text.IsEmpty() && text.Length() <= 800 && text.IndexOf("\n") < 0 && text.IndexOf("\r") < 0 && text.IndexOf("\t") < 0;
}
}
class ERR_SpeechReceiver
{
void Complete(string url, string text) {}
void Failed() {}
}
class ERR_SpeechRequest
{
protected ERR_SpeechService m_Service;
protected ref RestCallback m_Callback;
protected bool m_Cancelled;
string Text;
string Endpoint;
string Token;
bool Enable;
bool Boot;
ERR_AdminSession Editor;
ref ERR_SpeechReceiver Receiver;
void Start(ERR_SpeechService service)
{
m_Service = service;
RestContext context = GetGame().GetRestApi().GetContext(Endpoint);
if (!context) { Finish(""); return; }
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
json.WriteValue("token", Token); json.WriteValue("text", Text);
m_Callback = new RestCallback(); m_Callback.SetOnSuccess(Success); m_Callback.SetOnError(Error);
context.SetHeaders("Content-Type,application/json"); context.SetTimeout(90);
context.POST(m_Callback, "", json.SaveToString());
}
protected void Success(RestCallback callback)
{
JsonLoadContext response = new JsonLoadContext(); string key;
if (response.LoadFromString(callback.GetData()) && response.ReadValue("clip", key) && ERR_WelcomeVoiceRules.ClipKey(key)) Finish(Endpoint + "clips/" + key + ".b64");
else Finish("");
}
protected void Error(RestCallback callback) { Finish(""); }
protected void Finish(string url)
{
m_Callback = null;
if (!m_Cancelled && m_Service) m_Service.Finished(this, url);
Token = ""; Editor = null; Receiver = null; m_Service = null;
}
void Cancel()
{
m_Cancelled = true; Token = ""; Editor = null; Receiver = null; m_Service = null;
}
}
class ERR_SpeechService
{
protected ERR_GameMode m_Mode;
protected string m_Endpoint;
protected string m_Token;
protected bool m_Enabled;
protected bool m_Ready;
protected bool m_BootAttempted;
protected bool m_Stopped;
protected string m_Status = "Not configured. Existing audio is unchanged.";
protected ref ERR_SpeechRequest m_Request;
protected float m_Deadline;
protected float m_LastTest = -60000;
protected float m_LastRequest = -5000;
protected string m_StaticURL;
string CacheNamespace() { return m_Endpoint; }
string StaticURL() { return m_StaticURL; }
bool Ready() { return !m_Stopped && m_Enabled && m_Ready; }
bool Busy() { return m_Request != null; }
bool IdleFor(float milliseconds)
{
return Current() && !Busy() && GetGame().GetWorld().GetWorldTime() - m_LastRequest >= milliseconds;
}
bool Current() { return !m_Stopped && Replication.IsServer() && GetGame() && m_Mode && m_Mode == ERR_GameMode.Get(); }
void ERR_SpeechService(ERR_GameMode mode)
{
m_Mode = mode;
JsonLoadContext json = new JsonLoadContext();
if (!ERR_ProfileConfig.Load(ERR_ProfileConfig.VOICE, json)) return;
json.ReadValue("welcomeUrl", m_StaticURL);
if (!m_StaticURL.IsEmpty() && !ERR_WelcomeVoiceRules.URL(m_StaticURL)) m_StaticURL = "";
string endpoint, token;
json.ReadValue("briefingUrl", endpoint); json.ReadValue("briefingToken", token);
endpoint = ERR_SpeechRules.Normalize(endpoint);
if (!endpoint.IsEmpty() && ERR_SpeechRules.Endpoint(endpoint)) m_Endpoint = endpoint;
if (ERR_SpeechRules.Token(token)) m_Token = token;
m_Enabled = !m_Endpoint.IsEmpty() && !m_Token.IsEmpty();
json.ReadValue("ttsEnabled", m_Enabled);
if (m_Enabled) m_Status = "Configured; waiting to verify the speech service. Existing audio remains available.";
else if (!m_Endpoint.IsEmpty()) m_Status = "Disabled. Existing audio is unchanged.";
}
void Snapshot(array<string> values)
{
values.Insert("ttsUrl=" + m_Endpoint);
string saved = "0", ready = "0", busy = "0", enabled = "0";
if (!m_Token.IsEmpty()) saved = "1";
if (Ready()) ready = "1";
if (Busy()) busy = "1";
if (m_Enabled) enabled = "1";
values.Insert("ttsTokenSaved=" + saved); values.Insert("ttsReady=" + ready);
values.Insert("ttsBusy=" + busy); values.Insert("ttsEnabled=" + enabled);
values.Insert("ttsStatus=" + m_Status);
}
protected bool Save(string endpoint, string token, bool enabled)
{
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
json.WriteValue("welcomeUrl", m_StaticURL); json.WriteValue("briefingUrl", endpoint);
json.WriteValue("briefingToken", token); json.WriteValue("ttsEnabled", enabled);
return ERR_ProfileConfig.Save(ERR_ProfileConfig.VOICE, json);
}
void Disable(ERR_AdminSession editor)
{
if (!Current() || !editor || !editor.Valid()) return;
if (!Save(m_Endpoint, m_Token, false)) { editor.SpeechReply("Could not save. Previous speech settings retained."); return; }
if (m_Request) m_Request.Cancel();
m_Request = null; m_Enabled = false; m_Ready = false; m_BootAttempted = false;
m_Status = "Disabled. Existing audio is unchanged.";
m_Mode.ERR_RestoreStaticWelcome();
editor.SpeechReply("TTS disabled. The original welcome remains available.");
}
void TestAndEnable(ERR_AdminSession editor, string endpoint, string token)
{
if (!Current() || !editor || !editor.Valid()) return;
endpoint = ERR_SpeechRules.Normalize(endpoint);
// Never send the stored secret to a different address merely because the
// operator left the password field blank.
if (token.IsEmpty() && endpoint == m_Endpoint) token = m_Token;
if (!ERR_SpeechRules.Endpoint(endpoint) || !ERR_SpeechRules.Token(token))
{ editor.SpeechReply("Enter a speech service URL and a token of 32-256 characters. A new URL requires its token. Nothing changed."); return; }
float now = GetGame().GetWorld().GetWorldTime();
if (Busy() || now - m_LastTest < 60000)
{ editor.SpeechReply("A test is running or was started within the last minute. Please wait."); return; }
if (!ERR_WelcomeVoiceRules.HTTPReady())
{ editor.SpeechReply("HTTP service is unavailable. Nothing changed."); return; }
m_LastTest = now;
ERR_SpeechRequest request = new ERR_SpeechRequest();
request.Endpoint = endpoint; request.Token = token; request.Enable = true; request.Editor = editor;
request.Text = m_Mode.ERR_CurrentWelcomeText();
m_Status = "Testing speech generation. Previous settings remain active until it succeeds.";
editor.SpeechReply("Testing the service. A successful test saves and enables TTS and plays the briefing for you.");
Begin(request);
}
protected void Begin(ERR_SpeechRequest request)
{
m_Request = request;
if (request.Boot) m_Mode.ERR_SetWelcomePending(true);
m_LastRequest = GetGame().GetWorld().GetWorldTime(); m_Deadline = m_LastRequest + 100000;
request.Start(this);
}
// Shared entry point for mission speech features. The caller owns the
// gameplay fallback and recipient selection. No settings means no HTTP or audio.
bool Request(string text, ERR_SpeechReceiver receiver)
{
if (!Current() || !Ready() || Busy() || !receiver || !ERR_SpeechRules.Text(text) || !ERR_WelcomeVoiceRules.HTTPReady()) return false;
if (GetGame().GetWorld().GetWorldTime() - m_LastRequest < 5000) return false;
ERR_SpeechRequest request = new ERR_SpeechRequest();
request.Endpoint = m_Endpoint; request.Token = m_Token; request.Text = text; request.Receiver = receiver;
Begin(request); return true;
}
void Tick()
{
if (!Current()) return;
if (m_Request && GetGame().GetWorld().GetWorldTime() > m_Deadline)
{
ERR_SpeechRequest expired = m_Request;
Finished(expired, ""); expired.Cancel();
}
if (Busy() || !m_Enabled || m_BootAttempted || !m_Mode.Active() || !ERR_WelcomeVoiceRules.HTTPReady()) return;
m_BootAttempted = true;
if (m_Endpoint.IsEmpty() || m_Token.IsEmpty()) { m_Status = "Incomplete settings. Existing audio is unchanged."; return; }
ERR_SpeechRequest request = new ERR_SpeechRequest();
request.Endpoint = m_Endpoint; request.Token = m_Token; request.Text = m_Mode.ERR_CurrentWelcomeText(); request.Boot = true;
m_Status = "Verifying the saved speech service. Existing audio remains available.";
Begin(request);
}
void Finished(ERR_SpeechRequest request, string url)
{
if (!Current() || request != m_Request) return;
m_Request = null;
if (request.Boot) m_Mode.ERR_SetWelcomePending(false);
if (request.Enable && (!request.Editor || !request.Editor.Valid()))
{ m_Status = "Test cancelled because administrator access ended. Previous settings retained."; return; }
if (url.IsEmpty())
{
m_Status = "Speech request failed. Check service availability, token and Google access on the speech host.";
if (request.Enable) request.Editor.SpeechReply("TTS test failed. Previous settings retained.");
else if (request.Boot) { m_Ready = false; m_Mode.ERR_RestoreStaticWelcome(); }
if (request.Receiver) request.Receiver.Failed();
return;
}
if (request.Enable)
{
if (!Save(request.Endpoint, request.Token, true))
{ m_Status = "Test passed, but settings could not be saved. Previous settings retained."; request.Editor.SpeechReply(m_Status); return; }
m_Endpoint = request.Endpoint; m_Token = request.Token; m_Enabled = true; m_BootAttempted = true;
}
m_Ready = true; m_Status = "Enabled. Speech generation verified; repeated text uses the service cache.";
if (request.Enable || request.Boot) m_Mode.ERR_PublishWelcome(url, request.Text);
if (request.Enable) request.Editor.SpeechPreview(url, request.Text);
if (request.Receiver) request.Receiver.Complete(url, request.Text);
}
void Stop()
{
m_Stopped = true; if (m_Request) m_Request.Cancel(); m_Request = null; m_Token = ""; m_Mode = null;
}
}
class ERR_SpeechAdminProvider : ERR_AdminProvider
{
protected ERR_SpeechService m_Service;
void ERR_SpeechAdminProvider(ERR_SpeechService service) { m_Service = service; }
override string Id() { return "tts"; }
override void Capabilities(array<string> values) { if (m_Service) m_Service.Snapshot(values); }
}
@@ -0,0 +1,23 @@
// Start selection is fixed once per mission. Geometry uses measured native
// model bounds only; it never substitutes guessed helicopter dimensions.
class ERR_StartRules
{
static bool Valid(int mode) { return mode == 0 || mode == 1; }
static bool Geometry(vector minimum, vector maximum, out vector offset, out vector center, out float halfSize, out float height)
{
offset = vector.Zero; center = vector.Zero; halfSize = 0; height = 0;
for (int axis = 0; axis < 3; axis++)
if (!ERR_ForceRules.Finite(minimum[axis]) || !ERR_ForceRules.Finite(maximum[axis]) || Math.AbsFloat(minimum[axis]) > 30 || Math.AbsFloat(maximum[axis]) > 30 || minimum[axis] >= maximum[axis] || maximum[axis] - minimum[axis] > 30) return false;
// Keep the measured wreck at least 12m east of the start anchor: all sixteen
// standing slots and the existing cache remain inside the original survey.
vector placed = Vector(12 - minimum[0], -minimum[1], 0);
float radius = Math.Ceil(Math.Max(maximum[0] - minimum[0], maximum[2] - minimum[2]) * 0.5 + 1);
float clearance = Math.Ceil(maximum[1] - minimum[1] + 1);
if (placed.Length() > 40 || radius > 20 || clearance > 20) return false;
offset = placed;
center = placed + (minimum + maximum) * 0.5;
center[1] = 0;
halfSize = radius; height = clearance;
return true;
}
}
@@ -0,0 +1,47 @@
// PRISON remains the internal single-start-slot role for existing catalog,
// map-stock and terrain-spacing rules. Crash gameplay never enters PRISON.
class ERR_StartSetup
{
static bool IsCrash(ERR_SiteDefinition site)
{
return site && site.Kind == ERR_LocationKind.PRISON && site.Prefab == ERR_CrashSiteResources.SitePrefab();
}
static bool Geometry(out vector offset, out vector center, out float halfSize, out float height)
{
offset = vector.Zero; center = vector.Zero; halfSize = 0; height = 0;
vector minimum, maximum;
return ERR_CrashSiteResources.Bounds(minimum, maximum) && ERR_StartRules.Geometry(minimum, maximum, offset, center, halfSize, height);
}
static bool Configure(ERR_ScenarioDefinition definition, int mode)
{
if (!ERR_StartRules.Valid(mode) || !definition) return false;
if (mode == 0) return true;
ERR_SiteDefinition start;
int count;
foreach (ERR_SiteDefinition site : definition.Sites)
{
if (!site || site.Kind != ERR_LocationKind.PRISON) continue;
start = site; count++;
}
if (count != 1 || !start || start.Container.IsEmpty()) return false;
vector offset, center; float halfSize, height;
if (!Geometry(offset, center, halfSize, height)) return false;
for (int axis = 0; axis < 3; axis++)
if (!ERR_ForceRules.Finite(start.ContainerOffset[axis])) return false;
if (Math.AbsFloat(start.ContainerOffset[0]) > 8 || Math.AbsFloat(start.ContainerOffset[2]) > 8 || Math.AbsFloat(start.ContainerOffset[1]) > 1) return false;
start.Prefab = ERR_CrashSiteResources.SitePrefab();
start.Label = "Crash site";
start.RootHeight = 0;
start.Guarded = false; start.Guards = ""; start.GuardCount = 0;
start.ResponseCount = 0; start.AllowFactionPool = false;
start.Patrol = null;
start.RandomClear = true;
start.Props = new array<ref ERR_SiteProp>();
ERR_SiteProp wreck = new ERR_SiteProp();
wreck.Prefab = ERR_CrashSiteResources.WreckPrefab();
wreck.Offset = offset;
start.Props.Insert(wreck);
// Preserve the original finite cache, stock, radio clues, map and site ID.
return definition.Valid();
}
}
@@ -0,0 +1,77 @@
class ERR_TerrainChoice
{
string Key;
string Title;
ResourceName Scenario;
ResourceName WorldResource;
}
// Enumerate mission headers in the mounted server resources, not a client's
// subscription list. A bare world does not guarantee a playable AI/game setup.
class ERR_TerrainCatalog
{
protected ref array<ref ERR_TerrainChoice> m_Choices = {};
protected bool m_Ready;
protected bool m_Truncated;
static string Key(ResourceName resource)
{
if (resource.Length() < 18 || resource.Substring(0, 1) != "{" || resource.Substring(17, 1) != "}") return "";
string key = resource.Substring(1, 16); key.ToLower();
for (int i = 0; i < key.Length(); i++)
if ("0123456789abcdef".IndexOf(key.Substring(i, 1)) < 0) return "";
return key;
}
ERR_TerrainChoice Find(string key)
{
foreach (ERR_TerrainChoice choice : m_Choices) if (choice.Key == key) return choice;
return null;
}
array<ref ERR_TerrainChoice> Choices() { return m_Choices; }
bool Truncated() { return m_Truncated; }
protected void Add(ResourceName resource, string exactPath = "")
{
string key = Key(resource);
if (key.IsEmpty() || Find(key)) return;
if (m_Choices.Count() >= 512) { m_Truncated = true; return; }
SCR_MissionHeader header = SCR_MissionHeader.Cast(MissionHeader.ReadMissionHeader(resource));
if (!header || !header.m_bShowInScenarioMenu || header.GetWorldResourceName().IsEmpty()) return;
if (!header.m_bERR_EnableEscape && header.m_sGameMode != "Escape") return;
ERR_TerrainChoice choice = new ERR_TerrainChoice();
choice.Key = key; choice.Scenario = resource; choice.WorldResource = header.GetWorldResourceName();
choice.Title = header.m_sName;
if (choice.Title.IsEmpty()) choice.Title = resource;
choice.Title.Replace("|", " "); choice.Title.Replace("\n", " "); choice.Title.Replace("\r", " ");
if (choice.Title.Length() > 120) choice.Title = choice.Title.Substring(0, 120);
m_Choices.Insert(choice);
}
void Build()
{
if (m_Ready || !Replication.IsServer()) return;
m_Ready = true;
// Also covers registered headers stored outside the usual Missions folder.
if (GetGame().GetBackendApi() && GetGame().GetBackendApi().GetWorkshop())
{
WorkshopApi workshop = GetGame().GetBackendApi().GetWorkshop();
for (int page = 0; page < 64; page++)
{
array<MissionWorkshopItem> items = {};
workshop.GetPageScenarios(items, page, 64);
foreach (MissionWorkshopItem item : items) if (item) Add(item.Id());
if (items.Count() < 64) break;
}
}
// Dedicated/offline launches need not initialize the Workshop scenario UI.
SearchResourcesFilter filter = new SearchResourcesFilter();
filter.fileExtensions = {"conf"}; filter.recursive = true;
array<string> loaded = {}; GameProject.GetLoadedAddons(loaded);
foreach (string guid : loaded)
{
filter.rootPath = "$" + GameProject.GetAddonID(guid) + ":Missions";
ResourceDatabase.SearchResources(filter, Add);
}
SCR_MissionHeader current = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
if (current) Add(current.GetHeaderResourceName());
Add("{C1E20667DCE324ED}Missions/ERR_Everon.conf");
PrintFormat("Reintroduced [terrain selection]: discovered %1 playable scenario headers; truncated=%2", m_Choices.Count(), m_Truncated);
}
}
@@ -0,0 +1,96 @@
// The authenticated server snapshot supplies choices. Client subscriptions and
// arbitrary resource text never select a server terrain.
modded class ERR_AdminMenu
{
protected ref array<string> m_TerrainKeys = {};
protected ref array<string> m_TerrainLabels = {};
protected ref array<SCR_ButtonTextComponent> m_TerrainButtons = {};
protected string m_TerrainSaved;
protected string m_TerrainCurrent;
protected bool m_TerrainInitialized;
override void OnMenuOpen()
{
super.OnMenuOpen();
foreach (string name : {"TerrainApply", "TerrainDefault"})
{
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText(name, GetRootWidget());
if (!button) { Close(); return; }
button.m_OnClicked.Insert(OnTerrainButton); m_TerrainButtons.Insert(button);
}
UpdateActions();
}
override void OnMenuClose()
{
foreach (SCR_ButtonTextComponent button : m_TerrainButtons) if (button) button.m_OnClicked.Remove(OnTerrainButton);
m_TerrainButtons.Clear(); m_TerrainKeys.Clear(); m_TerrainLabels.Clear();
m_TerrainSaved = ""; m_TerrainCurrent = ""; m_TerrainInitialized = false;
super.OnMenuClose();
}
protected override void UpdateActions()
{
super.UpdateActions();
bool ready = m_Ready && m_TerrainInitialized && !m_TerrainSaved.IsEmpty();
foreach (SCR_ButtonTextComponent button : m_TerrainButtons) if (button) button.SetEnabled(ready);
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("TerrainSelect", GetRootWidget());
if (combo) combo.SetEnabled(ready);
}
protected string TerrainLabel(string key)
{
int index = m_TerrainKeys.Find(key);
if (index >= 0) return m_TerrainLabels[index];
return "Unavailable scenario";
}
override void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
{
array<string> keys = {"default"}; array<string> labels = {"Server default (no terrain override)"};
m_TerrainSaved = ""; m_TerrainCurrent = "";
string warning;
if (authorized && values)
foreach (string entry : values)
{
int divider = entry.IndexOf("="); if (divider < 0) continue;
string field = entry.Substring(0, divider), value = entry.Substring(divider + 1, entry.Length() - divider - 1);
if (field == "terrainSaved") m_TerrainSaved = value;
if (field == "terrainCurrent") m_TerrainCurrent = value;
if (field == "terrainWarning") warning = value;
if (field != "terrainOption") continue;
int split = value.IndexOf("|"); if (split <= 0) continue;
string key = value.Substring(0, split);
if (keys.Contains(key)) continue;
keys.Insert(key); labels.Insert(WidgetManager.Translate(value.Substring(split + 1, value.Length() - split - 1)));
}
bool changed = keys.Count() != m_TerrainKeys.Count();
if (!changed) for (int i = 0; i < keys.Count(); i++) if (keys[i] != m_TerrainKeys[i] || labels[i] != m_TerrainLabels[i]) changed = true;
if (authorized && !m_TerrainSaved.IsEmpty() && (changed || !m_TerrainInitialized))
{
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("TerrainSelect", GetRootWidget());
if (combo)
{
string draft = m_TerrainSaved;
int previous = combo.GetCurrentIndex();
if (m_TerrainInitialized && previous >= 0 && previous < m_TerrainKeys.Count()) draft = m_TerrainKeys[previous];
combo.ClearAll(); foreach (string label : labels) combo.AddItem(label);
combo.SetCurrentItem(Math.Max(0, keys.Find(draft)), false, false, false);
m_TerrainKeys.Copy(keys); m_TerrainLabels.Copy(labels); m_TerrainInitialized = true;
}
}
SetText("TerrainCurrent", "Current: " + TerrainLabel(m_TerrainCurrent));
SetText("TerrainSaved", "After restart: " + TerrainLabel(m_TerrainSaved));
SetText("TerrainWarning", warning);
super.ReceiveSnapshot(authorized, values, status);
}
protected void OnTerrainButton(SCR_ButtonBaseComponent button)
{
if (!button || !m_Ready || !IsFocused() || !m_Controller || m_Age > 6 || m_ClickDelay > 0 || !m_TerrainInitialized) return;
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("TerrainSelect", GetRootWidget());
if (!combo) return;
if (button.GetRootWidget().GetName() == "TerrainDefault")
{
combo.SetCurrentItem(0); SetText("Feedback", "Server default selected. Press Save terrain to remove the override."); return;
}
int index = combo.GetCurrentIndex();
if (index < 0 || index >= m_TerrainKeys.Count()) return;
m_ClickDelay = 0.3; SetText("Feedback", "Saving terrain for the next restart...");
m_Controller.ERR_AdminSubmit("terrain set " + m_TerrainKeys[index], true);
}
}
@@ -0,0 +1,54 @@
// Empty is deliberately no override: let the native/server restart destination
// stand. Store a stable resource GUID/path, never a transient menu row number.
class ERR_TerrainPreference
{
static const string PATH = "$profile:EscapistsTerrain.json";
protected string m_Path;
protected ResourceName m_Scenario;
protected bool m_ReadOnly;
void ERR_TerrainPreference(string path = PATH)
{
m_Path = path;
if (!Replication.IsServer()) { m_ReadOnly = true; return; }
ResourceName loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Scenario = loaded;
else if (ERR_ProfileConfig.Exists(m_Path) || ERR_ProfileConfig.Exists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Reintroduced [terrain selection]: invalid preference; server scenario retained, damaged files preserved", LogLevel.ERROR);
}
}
ResourceName Scenario() { return m_Scenario; }
protected bool Read(string path, out ResourceName scenario)
{
JsonLoadContext json = new JsonLoadContext();
return ERR_ProfileConfig.Load(path, json) && Parse(json, scenario);
}
static bool Parse(JsonLoadContext json, out ResourceName scenario)
{
if (!json.ReadValue("scenario", scenario)) return false;
return scenario.IsEmpty() || (!ERR_TerrainCatalog.Key(scenario).IsEmpty() && scenario.Length() <= 512);
}
bool Save(ResourceName scenario)
{
if (!Replication.IsServer() || m_ReadOnly || scenario.Length() > 512 || (!scenario.IsEmpty() && ERR_TerrainCatalog.Key(scenario).IsEmpty())) return false;
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false); json.WriteValue("scenario", scenario);
if (m_Path == PATH)
{
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
m_Scenario = scenario; return true;
}
ResourceName checked, previous;
if (!json.SaveToFile(m_Path + ".tmp") || !Read(m_Path + ".tmp", checked) || checked != scenario) return false;
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) || checked != scenario)
{
if (hadPrevious) FileIO.CopyFile(m_Path + ".bak", m_Path);
else FileIO.DeleteFile(m_Path);
return false;
}
m_Scenario = checked;
return true;
}
}
@@ -0,0 +1,159 @@
class ERR_TerrainSelection
{
protected ref ERR_TerrainCatalog m_Catalog = new ERR_TerrainCatalog();
protected ref ERR_TerrainPreference m_Preference = new ERR_TerrainPreference();
ERR_TerrainCatalog Catalog() { m_Catalog.Build(); return m_Catalog; }
string SavedKey()
{
if (m_Preference.Scenario().IsEmpty()) return "default";
return ERR_TerrainCatalog.Key(m_Preference.Scenario());
}
ResourceName Destination()
{
// Cache source titles before takeover changes the current runtime header name.
ERR_TerrainCatalog available = Catalog();
if (m_Preference.Scenario().IsEmpty()) return "";
ERR_TerrainChoice choice = available.Find(SavedKey());
if (!choice) return "";
// Resolve against the current mount; a stored path cannot select an arbitrary
// file or make the server fetch/enable a different Workshop dependency set.
return choice.Scenario;
}
bool Select(string key)
{
if (!Replication.IsServer()) return false;
if (key == "default") return m_Preference.Save("");
ERR_TerrainChoice choice = Catalog().Find(key);
return choice && m_Preference.Save(choice.Scenario);
}
void Snapshot(array<string> values)
{
string saved = SavedKey();
values.Insert("terrainSaved=" + saved);
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
if (header) values.Insert("terrainCurrent=" + ERR_TerrainCatalog.Key(header.GetHeaderResourceName()));
foreach (ERR_TerrainChoice choice : Catalog().Choices()) values.Insert("terrainOption=" + choice.Key + "|" + choice.Title);
if (saved != "default" && !Catalog().Find(saved)) values.Insert("terrainWarning=Saved terrain unavailable; using the server scenario.");
else if (Catalog().Truncated()) values.Insert("terrainWarning=Scenario limit reached (512); some scenarios are not listed.");
}
}
class ERR_TerrainAdminProvider : ERR_AdminProvider
{
override string Id() { return "terrain"; }
override void Capabilities(array<string> values) { GetGame().ERR_Terrains().Snapshot(values); }
override bool Execute(ERR_GameMode mode, string arguments, array<string> replies)
{
array<string> parts = {}; arguments.Split(" ", parts, true);
if (parts.IsEmpty() || parts[0] != "terrain") return false;
ERR_TerrainSelection selection = GetGame().ERR_Terrains();
if (parts.Count() == 3 && parts[1] == "set")
{
if (selection.Select(parts[2])) replies.Insert("Terrain saved for the next restart. Current round unchanged. Players must rejoin when the terrain changes.");
else replies.Insert("Terrain unavailable or preference could not be saved. Previous selection retained.");
return true;
}
replies.Insert("Saved terrain: " + selection.SavedKey() + ". Use the Terrains menu, or /esc terrain set <scenario GUID>|default. Applies after restart; players must rejoin on map change.");
return true;
}
override bool Help(string topic, array<string> replies)
{
if (topic != "terrain") return false;
replies.Insert("Choose a scenario from enabled server mods in Terrains, then Save. Default removes the override. Restart normally or use mission abandon to finish the current round. Changing maps disconnects players without a ban; rejoin after loading.");
return true;
}
}
modded class ArmaReforgerScripted
{
protected ref ERR_TerrainSelection m_ERRTerrains;
protected BaseWorld m_ERRTerrainSelectionWorld;
protected ResourceName m_ERRTerrainDestination;
protected ResourceName m_ERRLastTerrainRedirect;
protected float m_ERRTerrainSelectionWait;
protected float m_ERRTerrainDisconnectWait;
protected float m_ERRTerrainEmptyWait;
ERR_TerrainSelection ERR_Terrains()
{
if (!m_ERRTerrains) m_ERRTerrains = new ERR_TerrainSelection();
return m_ERRTerrains;
}
// Called before the takeover starts deleting/replacing native managers.
// A pending map change must not race that asynchronous bootstrap.
bool ERR_PrepareTerrainSelection(World world)
{
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !GetGameMode()) return false;
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
if (!header) return false;
// Snapshot only at world startup: saving in the menu cannot switch a live round.
ResourceName destination = ERR_Terrains().Destination();
if (destination.IsEmpty()) return false;
if (ERR_TerrainCatalog.Key(destination) == ERR_TerrainCatalog.Key(header.GetHeaderResourceName()))
{
m_ERRLastTerrainRedirect = "";
return false;
}
if (destination == m_ERRLastTerrainRedirect)
{
Print("Reintroduced [terrain selection]: previous redirect did not reach its scenario; keeping current world to prevent a reload loop", LogLevel.ERROR);
return false;
}
m_ERRTerrainDestination = destination; m_ERRTerrainSelectionWorld = world; m_ERRTerrainSelectionWait = 0;
m_ERRLastTerrainRedirect = destination;
return true;
}
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
if (m_ERRTerrainDestination.IsEmpty()) return;
if (!Replication.IsServer() || world != m_ERRTerrainSelectionWorld || world != GetWorld()) { m_ERRTerrainDestination = ""; return; }
m_ERRTerrainSelectionWait += timeslice;
if (m_ERRTerrainSelectionWait >= 15)
{
m_ERRTerrainDestination = "";
Print("Reintroduced [terrain selection]: startup transition stayed busy; current scenario retained", LogLevel.ERROR);
return;
}
// Let the native world/game-state initialization finish before switching.
if (m_ERRTerrainSelectionWait < 3 || GameStateTransitions.IsTransitionRequestedOrInProgress()) return;
// Connected clients otherwise reuse the old scenario header during this
// native transition (verified with a real DS/client). Fresh joins get the
// correct destination. Disconnect without a ban and wait for an empty session.
PlayerManager players = GetPlayerManager();
if (players.GetPlayerCount() > 0)
{
m_ERRTerrainEmptyWait = 0; m_ERRTerrainDisconnectWait += timeslice;
if (m_ERRTerrainDisconnectWait >= 1)
{
m_ERRTerrainDisconnectWait = 0;
array<int> connected = {}; players.GetPlayers(connected);
foreach (int id : connected) players.KickPlayer(id, PlayerManagerKickReason.KICK, 0);
PrintFormat("Reintroduced [terrain selection]: disconnected %1 player(s) for map change; rejoin after loading", connected.Count());
}
return;
}
m_ERRTerrainEmptyWait += timeslice;
if (m_ERRTerrainEmptyWait < 1) return;
ResourceName destination = m_ERRTerrainDestination; m_ERRTerrainDestination = "";
// Never use an empty addon list: the native API interprets it as vanilla.
array<string> loaded = {}; GameProject.GetLoadedAddons(loaded);
string addons;
foreach (string guid : loaded)
{
if (GameProject.IsVanillaAddon(guid)) continue;
if (!addons.IsEmpty()) addons += ",";
addons += guid;
}
if (addons.IsEmpty()) { Print("Reintroduced [terrain selection]: loaded addon list unavailable; current scenario retained", LogLevel.ERROR); return; }
bool accepted = GameStateTransitions.RequestScenarioChangeTransition(destination, "", addons);
PrintFormat("Reintroduced [terrain selection]: next terrain=%1 accepted=%2; enabled addons retained", destination, accepted);
}
override protected void OnBeforeWorldCleanup()
{
m_ERRTerrains = null;
m_ERRTerrainSelectionWorld = null; m_ERRTerrainDestination = ""; m_ERRTerrainSelectionWait = 0;
m_ERRTerrainDisconnectWait = 0; m_ERRTerrainEmptyWait = 0;
// Retain the redirect guard until its destination is actually observed.
super.OnBeforeWorldCleanup();
}
}
@@ -0,0 +1,178 @@
// Automatic terrain placement: all positions come from this world's native geometry.
// The original Everon authored/fallback contract remains in ERR_GeneratedSites.
class ERR_TerrainSites : ERR_GeneratedSites
{
protected float m_Spacing;
protected bool m_HaveCandidate, m_WaitingNav;
protected vector m_Candidate;
protected float m_CandidateHeading;
protected int m_CandidateBegan;
protected ref ERR_ReconAirports m_Airports = new ERR_ReconAirports();
protected bool m_AirportsReady;
protected int m_GeometryRejected, m_NavigationRejected, m_PadRejected;
static bool Bounds(vector low, vector high)
{
float width = high[0] - low[0], depth = high[2] - low[2];
return width >= 2000 && depth >= 2000 && width <= 100000 && depth <= 100000;
}
static float Spacing(vector low, vector high)
{
return Math.Clamp(Math.Min(high[0] - low[0], high[2] - low[2]) * 0.06, 150, 600);
}
override bool Begin(ERR_ScenarioDefinition definition)
{
if (m_Started || !Replication.IsServer() || !definition || !definition.Valid() || !GetGame().GetWorldEntity()) return false;
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
if (!Bounds(m_Low, m_High)) { m_Error = "Terrain bounds must span 2-100 km on each axis."; return false; }
AIWorld world = GetGame().GetAIWorld();
if (!world || !world.GetNavmeshWorldComponent("Soldiers")) { m_Error = "Terrain requires its own Soldiers navigation mesh."; return false; }
m_Started = true; m_Definition = definition; definition.TerrainGenerated = true;
m_Spacing = Spacing(m_Low, m_High);
// Resolve the terrain's airport first, then keep the prison in escape range.
array<int> order = {ERR_LocationKind.EXTRACTION, ERR_LocationKind.PRISON, ERR_LocationKind.RADIO, ERR_LocationKind.DEPOT, ERR_LocationKind.HOSPITAL, ERR_LocationKind.MORTAR, ERR_LocationKind.OUTPOST};
foreach (int kind : order)
foreach (ERR_SiteDefinition site : definition.Sites)
{
if (site.Kind != kind) continue;
site.PlacementResolved = false;
// The queue, rather than RandomClear, owns placement including extraction.
site.RandomClear = false;
if (kind == ERR_LocationKind.EXTRACTION) { site.RequireAirfield = true; site.AircraftOffset = vector.Zero; }
if (kind == ERR_LocationKind.DEPOT) site.Label = "Supply cache";
m_Pending.Insert(site);
}
m_Terrain.Collect(m_Low, m_High);
m_Began = System.GetTickCount();
PrintFormat("Reintroduced [terrain]: bounds=%1..%2 spacing=%3 sites=%4; native terrain/navmesh placement, no authored fallback", m_Low, m_High, m_Spacing, m_Pending.Count());
return true;
}
protected override bool Separated(ERR_SiteDefinition candidate, vector position, bool fallback)
{
foreach (ERR_SiteDefinition other : m_Definition.Sites)
{
if (other == candidate || !other.PlacementResolved) continue;
float minimum = m_Spacing;
if (candidate.Kind == ERR_LocationKind.RADIO && other.Kind == ERR_LocationKind.PRISON) minimum = m_Spacing * 2;
if (vector.DistanceXZ(position, other.Position) < minimum) return false;
// Every required destination must be inside the prison's escape boundary.
if (other.Kind == ERR_LocationKind.PRISON && vector.DistanceXZ(position, other.Position) + 200 >= ERR_GameMode.Get().EscapeRadius()) return false;
if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.EXTRACTION && vector.DistanceXZ(position, other.Position) + 200 >= ERR_GameMode.Get().EscapeRadius()) return false;
}
return true;
}
protected bool Navigable(vector position)
{
NavmeshWorldComponent mesh = GetGame().GetAIWorld().GetNavmeshWorldComponent("Soldiers");
if (!mesh) return false;
if (!mesh.IsTileLoaded(position)) { m_WaitingNav = mesh.LoadTileIn(position); return false; }
vector reachable;
return mesh.GetReachablePoint(position, 10, reachable);
}
protected bool Survey(ERR_SiteDefinition site, vector position, float heading, out float floor)
{
if (!m_Terrain.Site(site, position, heading, floor)) { m_GeometryRejected++; return false; }
vector ground = Vector(position[0], floor, position[2]);
if (!Navigable(ground)) { m_NavigationRejected++; return false; }
if (site.Guarded && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.GuardOffset, heading)))) { m_NavigationRejected++; return false; }
if (site.ResponseCount > 0 && !Navigable(ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(site.ResponseOffset, heading)))) { m_NavigationRejected++; return false; }
if (site.Kind != ERR_LocationKind.EXTRACTION) return true;
float padFloor;
if (m_Terrain.Clear(ground, heading, 14, 0.8, padFloor))
{
bool primaryClear;
if (site.RequireAirfield) primaryClear = ERR_AirfieldPlacement.Clear(ground);
else primaryClear = ERR_AirfieldPlacement.ClearGround(ground);
if (primaryClear && ERR_RescueLanding.Fits(ground)) return true;
}
m_PadRejected++; return false;
}
protected vector SamplePosition(ERR_SiteDefinition site)
{
if (site.Kind != ERR_LocationKind.EXTRACTION || m_Airports.m_Positions.IsEmpty())
return Vector(Math.RandomFloat(m_Low[0] + 60, m_High[0] - 60), 0, Math.RandomFloat(m_Low[2] + 60, m_High[2] - 60));
vector centre = m_Airports.m_Positions[Math.RandomInt(0, m_Airports.m_Positions.Count())];
float angle = Math.RandomFloat(0, Math.PI2), radius = Math.RandomFloat(0, 450);
return centre + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius);
}
protected bool PrepareAirports()
{
if (m_AirportsReady) return true;
// The scenario ticks every 250ms. One 2km tile per tick made Chernarus
// spend roughly 48 seconds just waiting between airport queries. Batch
// inexpensive tiles, but yield after 4ms or eight queries. A native query
// itself cannot be interrupted, so check the budget after every call.
int started = System.GetTickCount();
for (int tile = 0; tile < 8; tile++)
{
m_Airports.Update();
if (m_Airports.Complete() || System.GetTickCount() - started >= 4) break;
}
if (!m_Airports.Complete()) return false;
if (m_Airports.m_Positions.IsEmpty())
{
// A painted runway or an island without an airport must not require a
// connector or administrator coordinates. Survey a helicopter landing zone
// with the same ground/navmesh/aircraft checks and finite placement budget.
foreach (ERR_SiteDefinition site : m_Pending)
if (site.Kind == ERR_LocationKind.EXTRACTION)
{
site.RequireAirfield = false;
site.Label = "Extraction landing zone";
}
Print("Reintroduced [terrain]: no airport infrastructure found; surveying a clear helicopter extraction landing zone");
}
PrintFormat("Reintroduced [generation]: airport survey ready in %1 ms; airports=%2", System.GetTickCount() - m_Began, m_Airports.m_Positions.Count());
m_AirportsReady = true; m_Began = System.GetTickCount();
return true;
}
override ERR_PrepareResult Tick()
{
if (m_Result != ERR_PrepareResult.PENDING) return m_Result;
if (!m_Started || !Replication.IsServer()) return ERR_PrepareResult.FAILED;
if (!PrepareAirports()) return m_Result;
int tick = System.GetTickCount();
while (m_Index < m_Pending.Count())
{
if (m_StartupOnly && StartupReady()) return ERR_PrepareResult.PENDING;
ERR_SiteDefinition site = m_Pending[m_Index];
if (m_Attempts >= 12000 || System.GetTickCount() - m_Began >= 30000)
{
PrintFormat("Reintroduced [terrain survey exhausted]: id=%1 attempts=%2 rejected geometry=%3 navigation=%4 aircraft=%5", site.Id, m_Attempts, m_GeometryRejected, m_NavigationRejected, m_PadRejected);
if (!SkipOptional(site))
{
m_Error = "Terrain has no safe navigable placement for " + site.Label + ". Check this map's Soldiers navmesh, terrain clearance and size; no foreign-map fallback was used.";
m_Result = ERR_PrepareResult.FAILED; return m_Result;
}
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount(); m_HaveCandidate = false;
m_GeometryRejected = 0; m_NavigationRejected = 0; m_PadRejected = 0;
}
else
{
if (!m_HaveCandidate)
{
m_Candidate = SamplePosition(site);
m_CandidateHeading = Math.RandomFloat(0, 360); m_CandidateBegan = System.GetTickCount();
m_HaveCandidate = true; m_Attempts++;
}
vector position = m_Candidate;
float heading = m_CandidateHeading, floor;
m_WaitingNav = false;
if (Separated(site, position, false) && Survey(site, position, heading, floor))
{
position[1] = floor + 0.02;
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
PrintFormat("Reintroduced [terrain site]: id=%1 position=%2 heading=%3 attempts=%4", site.Id, position, heading, m_Attempts);
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
m_GeometryRejected = 0; m_NavigationRejected = 0; m_PadRejected = 0;
}
// Give this tile time to stream before testing a different random point.
// A missing/stalled tile must never consume the whole preparation budget.
else if (m_WaitingNav && System.GetTickCount() - m_CandidateBegan < 5000) return ERR_PrepareResult.PENDING;
m_HaveCandidate = false;
}
if (System.GetTickCount() - tick >= 8) return ERR_PrepareResult.PENDING;
}
if (!m_Definition.Valid()) { m_Error = "Generated terrain sites do not preserve required objectives and independent intel sources."; m_Result = ERR_PrepareResult.FAILED; return m_Result; }
m_Result = ERR_PrepareResult.READY; return m_Result;
}
}
@@ -0,0 +1,324 @@
// Loading Escape starts it on the selected island scenario automatically.
// Existing authored Escape modes and editor worlds are never replaced.
enum ERR_TerrainTakeoverStage
{
INACTIVE,
SETTLING,
DRAINING,
RUNNING,
FAILED
}
class ERR_TerrainTakeoverRules
{
static bool TimedOut(float elapsed) { return elapsed >= 10; }
static bool MayDrain(float elapsed, bool sameMode) { return elapsed >= 0.5 && sameMode; }
static bool MayCreate(float elapsed, bool empty) { return empty && !TimedOut(elapsed); }
static bool SameAdmission(bool connected, bool sameController, bool identityWasPresent, bool sameIdentity)
{
return connected && sameController && (!identityWasPresent || sameIdentity);
}
}
class ERR_TerrainTakeoverAdmission
{
int Connection;
SCR_PlayerController Controller;
UUID AccountId;
}
modded class SCR_EntityCatalogManagerComponent
{
// Vanilla's singleton is not explicitly reset when its owner is removed.
static void ERR_ReleaseTerrainCatalog(IEntity owner)
{
if (s_Instance && s_Instance.GetOwner() == owner) s_Instance = null;
}
}
modded class SCR_BaseGameMode
{
override void OnPlayerAuditSuccess(int iPlayerID)
{
if (GetGame().ERR_QueueTerrainAdmission(this, iPlayerID)) return;
super.OnPlayerAuditSuccess(iPlayerID);
}
protected override void OnPlayerDisconnected(int playerId, KickCauseCode cause, int timeout)
{
GetGame().ERR_ForgetTerrainAdmission(playerId);
super.OnPlayerDisconnected(playerId, cause, timeout);
}
}
modded class ERR_GameMode
{
[RplProp()] protected bool m_ERRTerrainTakenOver;
bool ERR_TerrainTakenOver() { return m_ERRTerrainTakenOver; }
void ERR_StartTerrainTakeover(World world)
{
m_ERRTerrainTakenOver = true;
Replication.BumpMe();
OnWorldPostProcess(world);
OnGameStart();
}
}
modded class ArmaReforgerScripted
{
protected ERR_TerrainTakeoverStage m_ERRTerrainStage;
protected float m_ERRTerrainElapsed;
protected RadioManagerEntity m_ERRTerrainRadio;
protected EntityID m_ERRTerrainOldLoadoutId;
protected BaseWorld m_ERRTerrainWorld;
protected SCR_BaseGameMode m_ERRTerrainOriginal;
protected ref array<ref ERR_TerrainTakeoverAdmission> m_ERRTerrainAdmissions = {};
protected ref array<vector> m_ERRTerrainAirfields = {};
protected ref array<int> m_ERRTerrainMarkerSlots = {};
override void UnregisterLoadoutManager(SCR_LoadoutManager instance)
{
if (Replication.IsServer() && instance && instance.GetID() == m_ERRTerrainOldLoadoutId && instance != GetLoadoutManager())
{
Print("Reintroduced [terrain takeover]: completed original loadout unregister");
return;
}
super.UnregisterLoadoutManager(instance);
}
void ERR_ForgetTerrainAdmission(int connection)
{
for (int i = m_ERRTerrainAdmissions.Count() - 1; i >= 0; i--)
if (m_ERRTerrainAdmissions[i].Connection == connection) m_ERRTerrainAdmissions.Remove(i);
}
bool ERR_QueueTerrainAdmission(SCR_BaseGameMode mode, int connection)
{
if (m_ERRTerrainStage != ERR_TerrainTakeoverStage.SETTLING && m_ERRTerrainStage != ERR_TerrainTakeoverStage.DRAINING) return false;
if (mode != m_ERRTerrainOriginal || ERR_GameMode.Cast(mode) || !Replication.IsServer()) return false;
ERR_ForgetTerrainAdmission(connection);
SCR_PlayerController controller = SCR_PlayerController.Cast(GetPlayerManager().GetPlayerController(connection));
if (!controller || !GetPlayerManager().IsPlayerConnected(connection)) return true;
ERR_TerrainTakeoverAdmission admission = new ERR_TerrainTakeoverAdmission();
admission.Connection = connection;
admission.Controller = controller;
admission.AccountId = SCR_PlayerIdentityUtils.GetPlayerIdentityId(connection);
m_ERRTerrainAdmissions.Insert(admission);
PrintFormat("Reintroduced [terrain takeover]: held native admission connection=%1", connection);
return true;
}
protected void ERR_ResetTerrainTakeover()
{
m_ERRTerrainStage = ERR_TerrainTakeoverStage.INACTIVE;
m_ERRTerrainElapsed = 0;
m_ERRTerrainRadio = null;
m_ERRTerrainOldLoadoutId = EntityID.INVALID;
m_ERRTerrainWorld = null;
m_ERRTerrainOriginal = null;
m_ERRTerrainAdmissions.Clear();
m_ERRTerrainAirfields.Clear();
m_ERRTerrainMarkerSlots.Clear();
}
protected void ERR_FailTerrainTakeover(string reason)
{
m_ERRTerrainStage = ERR_TerrainTakeoverStage.FAILED;
m_ERRTerrainAdmissions.Clear();
Print("Reintroduced [terrain takeover failed]: " + reason, LogLevel.ERROR);
}
protected bool ERR_PreflightTerrainTakeover()
{
if (!m_ERRTerrainOriginal || !GetWorldEntity() || !GetAIWorld() || !GetAIWorld().GetNavmeshWorldComponent("Soldiers"))
{
ERR_FailTerrainTakeover("Select a plain scenario with SCR_BaseGameMode and a Soldiers navmesh; original scenario retained.");
return false;
}
Resource prefab = Resource.Load("{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et");
if (!prefab || !prefab.IsValid())
{
ERR_FailTerrainTakeover("Escape bootstrap prefab is missing; original scenario retained.");
return false;
}
ChimeraWorld chimera = ChimeraWorld.CastFrom(GetWorld());
if (!chimera || !chimera.GetRadioManager())
{
Resource radio = Resource.Load("{B8E09FAB91C4ECCD}Prefabs/Systems/Radio/RadioManager.et");
if (!radio || !radio.IsValid())
{
ERR_FailTerrainTakeover("Radio manager prefab is missing; original scenario retained.");
return false;
}
}
vector low, high;
GetWorldEntity().GetWorldBounds(low, high);
if (!ERR_TerrainSites.Bounds(low, high) || !ERR_TerrainTakeoverSettings.ParseAirfields(ERR_TerrainTakeoverSettings.Airfields(), m_ERRTerrainAirfields))
{
ERR_FailTerrainTakeover("Invalid terrain size or m_sERR_Airfields; use up to 16 X Z pairs separated by semicolons. Original scenario retained.");
return false;
}
foreach (vector centre : m_ERRTerrainAirfields)
{
if (centre[0] < low[0] || centre[0] > high[0] || centre[2] < low[2] || centre[2] > high[2] || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetWorld(), centre))
{
ERR_FailTerrainTakeover("An airport centre is outside this terrain or over water; original scenario retained.");
return false;
}
}
for (int slot = 0; slot < 16; slot++)
if (!GetWorld().FindEntityByName("ERR_Airfield_" + slot.ToString())) m_ERRTerrainMarkerSlots.Insert(slot);
if (m_ERRTerrainMarkerSlots.Count() < m_ERRTerrainAirfields.Count())
{
ERR_FailTerrainTakeover("Not enough unused ERR_Airfield marker names for configured centres; original scenario retained.");
return false;
}
return true;
}
protected override void OnWorldPostProcess(World world)
{
bool changingTerrain;
if (GetGame()) changingTerrain = GetGame().ERR_PrepareTerrainSelection(world);
// Do not replace managers while a saved terrain transition is pending.
// Existing authored Escape missions are never replaced either.
if (!changingTerrain && ERR_TerrainTakeoverSettings.Enabled() && !ERR_GameMode.Get() && Replication.IsServer() && GetGameMode())
{
ERR_ResetTerrainTakeover();
m_ERRTerrainWorld = world;
m_ERRTerrainOriginal = SCR_BaseGameMode.Cast(GetGameMode());
if (ERR_PreflightTerrainTakeover())
{
// Escape owns a fresh round; do not save the replaced scenario's state.
SaveGameManager saves = GetSaveGameManager();
if (saves) saves.SetSavingAllowed(false);
m_ERRTerrainStage = ERR_TerrainTakeoverStage.SETTLING;
PrintFormat("Reintroduced [terrain takeover]: enabled; configured airports=%1", m_ERRTerrainAirfields.Count());
}
}
super.OnWorldPostProcess(world);
}
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
if (m_ERRTerrainStage != ERR_TerrainTakeoverStage.SETTLING && m_ERRTerrainStage != ERR_TerrainTakeoverStage.DRAINING) return;
if (world != m_ERRTerrainWorld || GetWorld() != m_ERRTerrainWorld || !Replication.IsServer()) { ERR_ResetTerrainTakeover(); return; }
m_ERRTerrainElapsed += timeslice;
if (ERR_TerrainTakeoverRules.TimedOut(m_ERRTerrainElapsed)) { ERR_FailTerrainTakeover("Native manager registrations did not settle within 10 seconds; restart the scenario."); return; }
if (m_ERRTerrainStage == ERR_TerrainTakeoverStage.SETTLING)
{
if (!m_ERRTerrainOriginal || GetGameMode() != m_ERRTerrainOriginal) { ERR_FailTerrainTakeover("Original mode changed during startup; takeover cancelled."); return; }
if (ERR_TerrainTakeoverRules.MayDrain(m_ERRTerrainElapsed, true)) ERR_DrainTerrainMode();
}
else if (ERR_TerrainTakeoverRules.MayCreate(m_ERRTerrainElapsed, ERR_TerrainRegistrationsEmpty())) ERR_CreateTerrainMode();
}
protected void ERR_DrainTerrainMode()
{
SCR_EditorManagerCore editorCore = SCR_EditorManagerCore.Cast(SCR_EditorManagerCore.GetInstance(SCR_EditorManagerCore));
if (editorCore) editorCore.ERR_SetTerrainModeEvents(m_ERRTerrainOriginal, false);
array<IEntity> managers = {m_ERRTerrainOriginal, GetLoadoutManager(), GetFactionManager(), GetPerceptionManager(), GetChat()};
ERR_TerrainTakeoverReplication.CollectDelegates(SCR_DelegateFactionManagerComponent.GetInstance(), managers);
ChimeraWorld chimera = ChimeraWorld.CastFrom(GetWorld());
if (chimera) m_ERRTerrainRadio = chimera.GetRadioManager();
// Native radio teardown clears the world pointer even after a replacement
// registers. Retain the terrain radio, detaching it from any removed owner.
if (m_ERRTerrainRadio)
{
IEntity ancestor = m_ERRTerrainRadio.GetParent();
while (ancestor)
{
if (managers.Contains(ancestor))
{
m_ERRTerrainRadio.GetParent().RemoveChild(m_ERRTerrainRadio, true);
if (m_ERRTerrainRadio.GetParent()) { ERR_FailTerrainTakeover("Could not retain the radio manager; original mode retained."); return; }
break;
}
ancestor = ancestor.GetParent();
}
}
Print("Reintroduced [terrain takeover]: stopping original scenario; preserving radio manager");
m_ERRTerrainOriginal.OnGameEnd();
if (m_DataCollectorComponent && m_DataCollectorComponent.GetOwner() == m_ERRTerrainOriginal) m_DataCollectorComponent = null;
SCR_EntityCatalogManagerComponent.ERR_ReleaseTerrainCatalog(m_ERRTerrainOriginal);
if (GetLoadoutManager()) m_ERRTerrainOldLoadoutId = GetLoadoutManager().GetID();
// Script component destructors can run at the end of the frame. Release only
// the original mode's singleton instances before creating the new components.
SCR_AdditionalGameModeSettingsComponent.ERR_ReleaseTerrainSingleton(SCR_AdditionalGameModeSettingsComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_AdditionalGameModeSettingsComponent)));
SCR_PerceivedFactionManagerComponent.ERR_ReleaseTerrainSingleton(SCR_PerceivedFactionManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_PerceivedFactionManagerComponent)));
SCR_PlayerListedAdminManagerComponent.ERR_ReleaseTerrainSingleton(SCR_PlayerListedAdminManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_PlayerListedAdminManagerComponent)));
SCR_IdentityManagerComponent.ERR_ReleaseTerrainSingleton(SCR_IdentityManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_IdentityManagerComponent)));
SCR_ArsenalManagerComponent.ERR_ReleaseTerrainSingleton(SCR_ArsenalManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_ArsenalManagerComponent)));
SCR_GroupsManagerComponent.ERR_ReleaseTerrainSingleton(SCR_GroupsManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_GroupsManagerComponent)));
SCR_SupportStationManagerComponent.ERR_ReleaseTerrainSingleton(SCR_SupportStationManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_SupportStationManagerComponent)));
SCR_TaskFinishHistoryManagerComponent.ERR_ReleaseTerrainSingleton(SCR_TaskFinishHistoryManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_TaskFinishHistoryManagerComponent)));
SCR_TaskManagerUIComponent.ERR_ReleaseTerrainSingleton(SCR_TaskManagerUIComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_TaskManagerUIComponent)));
SCR_TextsTaskManagerComponent.ERR_ReleaseTerrainSingleton(SCR_TextsTaskManagerComponent.Cast(m_ERRTerrainOriginal.FindComponent(SCR_TextsTaskManagerComponent)));
foreach (IEntity manager : managers) if (manager) SCR_EntityHelper.DeleteEntityAndChildren(manager);
m_ERRTerrainStage = ERR_TerrainTakeoverStage.DRAINING;
m_ERRTerrainElapsed = 0;
// Do not leave an artificial gap without a game mode: native connection
// processing cannot create a player controller while no mode is registered.
// On the authority, deletion normally clears these registrations immediately.
if (ERR_TerrainRegistrationsEmpty()) ERR_CreateTerrainMode();
}
protected bool ERR_TerrainRegistrationsEmpty()
{
ChimeraWorld chimera = ChimeraWorld.CastFrom(GetWorld());
return !GetGameMode() && !GetLoadoutManager() && !GetFactionManager() && !GetPerceptionManager() && !GetChat() && (!chimera || chimera.GetRadioManager() == m_ERRTerrainRadio) && !SCR_EntityCatalogManagerComponent.GetInstance() && !m_DataCollectorComponent;
}
protected void ERR_CreateTerrainMode()
{
Print("Reintroduced [terrain takeover]: native registrations cleared");
if (!m_ERRTerrainRadio)
{
m_ERRTerrainRadio = RadioManagerEntity.Cast(SpawnEntityPrefab(Resource.Load("{B8E09FAB91C4ECCD}Prefabs/Systems/Radio/RadioManager.et"), GetWorld()));
if (!m_ERRTerrainRadio) { ERR_FailTerrainTakeover("Could not create missing radio manager; restart the scenario."); return; }
}
for (int i = 0; i < m_ERRTerrainAirfields.Count(); i++)
{
EntitySpawnParams parameters = new EntitySpawnParams();
parameters.TransformMode = ETransformMode.WORLD;
parameters.Transform[3] = m_ERRTerrainAirfields[i];
IEntity marker = SpawnEntity(GenericEntity, GetWorld(), parameters);
if (!marker) { ERR_FailTerrainTakeover("Could not create configured airport marker; restart the scenario."); return; }
marker.SetName("ERR_Airfield_" + m_ERRTerrainMarkerSlots[i].ToString());
}
ERR_GameMode mode = ERR_GameMode.Cast(SpawnEntityPrefab(Resource.Load("{9D182739ABEF405C}Prefabs/ERR/TerrainTakeover.et"), GetWorld()));
if (!mode || GetGameMode() != mode) { ERR_FailTerrainTakeover("Escape game mode could not register; restart the scenario."); return; }
if (!ERR_TerrainTakeoverReplication.Link(mode)) { ERR_FailTerrainTakeover("Escape replication hierarchy could not register; restart the scenario."); return; }
SCR_EditorManagerCore editorCore = SCR_EditorManagerCore.Cast(SCR_EditorManagerCore.GetInstance(SCR_EditorManagerCore));
if (editorCore) editorCore.ERR_SetTerrainModeEvents(mode, true);
m_ERRTerrainStage = ERR_TerrainTakeoverStage.RUNNING;
mode.ERR_StartTerrainTakeover(GetWorld());
GetGame().ERR_PresentTerrainTakeover();
foreach (ERR_TerrainTakeoverAdmission admission : m_ERRTerrainAdmissions)
{
int connection = admission.Connection;
bool connected = GetPlayerManager().IsPlayerConnected(connection);
bool sameController = admission.Controller && GetPlayerManager().GetPlayerController(connection) == admission.Controller;
UUID identity = SCR_PlayerIdentityUtils.GetPlayerIdentityId(connection);
if (ERR_TerrainTakeoverRules.SameAdmission(connected, sameController, !admission.AccountId.IsNull(), identity == admission.AccountId)) mode.OnPlayerAuditSuccess(connection);
else PrintFormat("Reintroduced [terrain takeover]: discarded stale admission connection=%1", connection);
}
if (editorCore) editorCore.ERR_ReconcileTerrainEditors(mode);
m_ERRTerrainAdmissions.Clear();
m_ERRTerrainOriginal = null;
Print("Reintroduced [terrain takeover]: Escape started on selected scenario");
}
override protected void OnBeforeWorldCleanup()
{
ERR_ResetTerrainTakeover();
super.OnBeforeWorldCleanup();
}
}
modded class SCR_SpawnRequestComponent
{
override SCR_SpawnHandlerComponent GetHandlerComponent()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.ERR_TerrainTakenOver())
{
// Controllers that arrived before handover retain the removed handler.
// Resolve directly from the current owner, avoiding any old singleton.
SCR_RespawnSystemComponent system = SCR_RespawnSystemComponent.Cast(mode.FindComponent(SCR_RespawnSystemComponent));
if (system) return SCR_SpawnHandlerComponent.Cast(system.FindComponent(GetHandlerType()));
return null;
}
return super.GetHandlerComponent();
}
}
@@ -0,0 +1,248 @@
// Client bootstrap for the explicitly selected takeover prefab. The native game
// mode pointer already refers to the incoming entity inside its constructor, so
// capture original managers during the original mode's initialization instead.
class ERR_TerrainTakeoverModeClass : ERR_GameModeClass {}
class ERR_TerrainTakeoverMode : ERR_GameMode
{
void ERR_TerrainTakeoverMode(IEntitySource src, IEntity parent)
{
if (!GetGame() || Replication.IsServer()) return;
GetGame().ERR_BeginClientTerrainTakeover(this);
GetGame().ERR_PresentTerrainTakeover();
}
}
modded class ArmaReforgerScripted
{
protected ref array<IEntity> m_ERRClientOldManagers = {};
protected bool m_ERRClientTerrainTakeover;
bool ERR_IsClientTerrainTakeover() { return m_ERRClientTerrainTakeover; }
protected SCR_EntityCatalogManagerComponent m_ERROldCatalog;
protected EntityID m_ERROldLoadoutId;
protected SCR_AdditionalGameModeSettingsComponent m_ERROldSettings;
protected SCR_PerceivedFactionManagerComponent m_ERROldPerceivedFaction;
protected SCR_PlayerListedAdminManagerComponent m_ERROldAdmins;
protected SCR_IdentityManagerComponent m_ERROldIdentity;
protected SCR_ArsenalManagerComponent m_ERROldArsenal;
protected SCR_GroupsManagerComponent m_ERROldGroups;
protected SCR_SupportStationManagerComponent m_ERROldSupport;
protected SCR_TaskFinishHistoryManagerComponent m_ERROldTaskHistory;
protected SCR_TaskManagerUIComponent m_ERROldTaskUI;
protected SCR_TextsTaskManagerComponent m_ERROldTaskTexts;
void ERR_ClientCaptureTerrainDelegates(SCR_DelegateFactionManagerComponent manager)
{
if (Replication.IsServer() || ERR_GameMode.Get()) return;
ERR_TerrainTakeoverReplication.CollectDelegates(manager, m_ERRClientOldManagers);
}
void ERR_ClientCaptureOriginal(SCR_BaseGameMode mode)
{
if (Replication.IsServer() || ERR_GameMode.Cast(mode)) return;
m_ERROldCatalog = SCR_EntityCatalogManagerComponent.GetInstance();
if (GetLoadoutManager()) m_ERROldLoadoutId = GetLoadoutManager().GetID();
m_ERROldSettings = SCR_AdditionalGameModeSettingsComponent.GetInstance();
m_ERROldPerceivedFaction = SCR_PerceivedFactionManagerComponent.GetInstance();
m_ERROldAdmins = SCR_PlayerListedAdminManagerComponent.GetInstance();
m_ERROldIdentity = SCR_IdentityManagerComponent.GetInstance();
m_ERROldArsenal = SCR_ArsenalManagerComponent.Cast(mode.FindComponent(SCR_ArsenalManagerComponent));
m_ERROldGroups = SCR_GroupsManagerComponent.Cast(mode.FindComponent(SCR_GroupsManagerComponent));
m_ERROldSupport = SCR_SupportStationManagerComponent.Cast(mode.FindComponent(SCR_SupportStationManagerComponent));
m_ERROldTaskHistory = SCR_TaskFinishHistoryManagerComponent.Cast(mode.FindComponent(SCR_TaskFinishHistoryManagerComponent));
m_ERROldTaskUI = SCR_TaskManagerUIComponent.Cast(mode.FindComponent(SCR_TaskManagerUIComponent));
m_ERROldTaskTexts = SCR_TextsTaskManagerComponent.Cast(mode.FindComponent(SCR_TextsTaskManagerComponent));
// Factions can initialize before or after the original mode; keep any
// delegates already captured by their manager's initialization callback.
m_ERRClientOldManagers.InsertAt(mode, 0);
m_ERRClientOldManagers.Insert(GetLoadoutManager());
m_ERRClientOldManagers.Insert(GetFactionManager());
m_ERRClientOldManagers.Insert(GetPerceptionManager());
m_ERRClientOldManagers.Insert(GetChat());
}
override void UnregisterLoadoutManager(SCR_LoadoutManager instance)
{
if (m_ERRClientTerrainTakeover && instance && instance.GetID() == m_ERROldLoadoutId && instance != GetLoadoutManager())
{
Print("Reintroduced [terrain client]: completed original loadout unregister");
return;
}
super.UnregisterLoadoutManager(instance);
}
override protected void OnBeforeWorldCleanup()
{
m_ERRClientTerrainTakeover = false;
m_ERRClientOldManagers.Clear();
m_ERROldCatalog = null;
m_ERROldSettings = null;
m_ERROldPerceivedFaction = null;
m_ERROldAdmins = null;
m_ERROldIdentity = null;
m_ERROldArsenal = null;
m_ERROldGroups = null;
m_ERROldSupport = null;
m_ERROldTaskHistory = null;
m_ERROldTaskUI = null;
m_ERROldTaskTexts = null;
m_ERROldLoadoutId = EntityID.INVALID;
super.OnBeforeWorldCleanup();
}
void ERR_BeginClientTerrainTakeover(ERR_TerrainTakeoverMode incoming)
{
if (m_ERRClientOldManagers.IsEmpty()) return;
m_ERRClientTerrainTakeover = true;
SCR_BaseGameMode mode = SCR_BaseGameMode.Cast(m_ERRClientOldManagers[0]);
PrintFormat("Reintroduced [terrain client]: drain retained mode=%1 incoming=%2", mode, incoming);
if (mode) mode.OnGameEnd();
// Owner entities can already be invalid while native destruction is deferred.
// Compare the captured component itself, never clear a newer singleton.
SCR_EntityCatalogManagerComponent.ERR_ReleaseClientCatalog(m_ERROldCatalog);
m_DataCollectorComponent = null;
SCR_AdditionalGameModeSettingsComponent.ERR_ReleaseTerrainSingleton(m_ERROldSettings);
SCR_PerceivedFactionManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldPerceivedFaction);
SCR_PlayerListedAdminManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldAdmins);
SCR_IdentityManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldIdentity);
SCR_ArsenalManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldArsenal);
SCR_GroupsManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldGroups);
SCR_SupportStationManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldSupport);
SCR_TaskFinishHistoryManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldTaskHistory);
SCR_TaskManagerUIComponent.ERR_ReleaseTerrainSingleton(m_ERROldTaskUI);
SCR_TextsTaskManagerComponent.ERR_ReleaseTerrainSingleton(m_ERROldTaskTexts);
foreach (IEntity manager : m_ERRClientOldManagers) if (manager && manager != incoming) delete manager;
m_ERRClientOldManagers.Clear();
PrintFormat("Reintroduced [terrain client]: drained native mode=%1 factions=%2 loadouts=%3", GetGameMode(), GetFactionManager(), GetLoadoutManager());
}
}
modded class SCR_EntityCatalogManagerComponent
{
static void ERR_ReleaseClientCatalog(SCR_EntityCatalogManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
protected bool m_ERRWaitingForFaction;
void ERR_CompleteFactionInit()
{
if (!m_ERRWaitingForFaction || !GetGame().GetFactionManager()) return;
m_ERRWaitingForFaction = false;
ClearEventMask(GetOwner(), EntityEvent.FRAME);
super.EOnInit(GetOwner());
Print("Reintroduced [terrain client]: catalog completed during faction initialization");
}
override void EOnInit(IEntity owner)
{
if (!Replication.IsServer() && ERR_TerrainTakeoverMode.Cast(owner) && !GetGame().GetFactionManager())
{
m_ERRWaitingForFaction = true;
SetEventMask(owner, EntityEvent.FRAME);
return;
}
super.EOnInit(owner);
}
override void EOnFrame(IEntity owner, float timeSlice)
{
if (!m_ERRWaitingForFaction || !GetGame().GetFactionManager()) return;
m_ERRWaitingForFaction = false;
ClearEventMask(owner, EntityEvent.FRAME);
super.EOnInit(owner);
Print("Reintroduced [terrain client]: catalog initialized after factions");
}
}
modded class SCR_DelegateFactionManagerComponent
{
override void OnFactionsInit(array<Faction> factions)
{
if (!Replication.IsServer() && GetGame().ERR_IsClientTerrainTakeover())
{
SCR_EntityCatalogManagerComponent catalog = SCR_EntityCatalogManagerComponent.GetInstance();
if (catalog) catalog.ERR_CompleteFactionInit();
SCR_IdentityManagerComponent identity = SCR_IdentityManagerComponent.GetInstance();
if (identity) identity.ERR_CompleteTerrainFactionInit();
// The server replicates these delegates. During the initial snapshot the
// vanilla Runtime() guard is false and would create duplicates on the client.
return;
}
super.OnFactionsInit(factions);
if (!Replication.IsServer()) GetGame().ERR_ClientCaptureTerrainDelegates(this);
}
}
// Both peers consume the same extra index before the vanilla payload. Vanilla
// RplLoad dereferences its faction before applying its own serialized index;
// runtime takeover delegates must bind first. Ordinary missions only consume
// the prefix and retain their normal initialization.
modded class SCR_EditableFactionComponent
{
override bool RplSave(ScriptBitWriter writer)
{
writer.WriteInt(m_iFactionIndex);
return super.RplSave(writer);
}
override bool RplLoad(ScriptBitReader reader)
{
int initialIndex;
if (!reader.ReadInt(initialIndex)) return false;
if (GetGame().ERR_IsClientTerrainTakeover() && !m_ScrFaction)
{
SetFactionIndexBroadcast(initialIndex);
if (!m_ScrFaction) return false;
}
return super.RplLoad(reader);
}
}
modded class SCR_BaseGameMode
{
override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
GetGame().ERR_ClientCaptureOriginal(this);
}
}
modded class SCR_AdditionalGameModeSettingsComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_AdditionalGameModeSettingsComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
}
modded class SCR_PerceivedFactionManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_PerceivedFactionManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
}
modded class SCR_PlayerListedAdminManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_PlayerListedAdminManagerComponent expected)
{
if (m_Instance == expected) m_Instance = null;
}
}
modded class SCR_IdentityManagerComponent
{
protected bool m_ERRWaitingForTerrainFaction;
override void EOnInit(IEntity owner)
{
if (!Replication.IsServer() && GetGame().ERR_IsClientTerrainTakeover() && ERR_TerrainTakeoverMode.Cast(owner) && !GetGame().GetFactionManager())
{
m_ERRWaitingForTerrainFaction = true;
return;
}
super.EOnInit(owner);
}
void ERR_CompleteTerrainFactionInit()
{
if (!m_ERRWaitingForTerrainFaction || !GetGame().GetFactionManager()) return;
m_ERRWaitingForTerrainFaction = false;
super.EOnInit(GetOwner());
}
static void ERR_ReleaseTerrainSingleton(SCR_IdentityManagerComponent expected)
{
if (m_sInstance == expected) m_sInstance = null;
}
}
@@ -0,0 +1,286 @@
// The editor core survives a game-mode replacement, but vanilla attaches its
// player lifecycle/role listeners only once, during OnGameStart. Move those
// listeners without restarting the core or replacing per-player editors.
[BaseContainerProps(configRoot: true)]
modded class SCR_EditorManagerCore
{
protected ERR_GameMode m_ERREditorRetryMode;
protected bool m_ERREditorRetryQueued;
protected bool m_ERREditorRetryNeeded;
protected int m_ERREditorRetryAttempts;
// A retained controller may still carry the server's temporary identity while
// a connected restart restores its network owner. Never give it an editor yet.
protected bool ERR_EditorConnectionReady(int playerID)
{
PlayerManager players = GetGame().GetPlayerManager();
PlayerController controller = players.GetPlayerController(playerID);
if (!players.IsPlayerConnected(playerID) || !controller) return false;
RplIdentity identity = controller.GetRplIdentity();
if (identity == RplIdentity.Invalid()) return false;
if (RplSession.Mode() == RplMode.Dedicated && identity == RplIdentity.Local()) return false;
array<SCR_EditorManagerEntity> editors = {};
GetEditorEntities(editors);
foreach (SCR_EditorManagerEntity editor : editors)
{
int otherID = editor.GetPlayerID();
if (otherID == playerID) continue;
PlayerController other = players.GetPlayerController(otherID);
if (other && other.GetRplIdentity() == identity) return false;
}
return true;
}
protected override SCR_EditorManagerEntity CreateEditorManager(int playerID)
{
// Keep vanilla behavior outside the replaced terrain mode.
ERR_TerrainTakeoverMode mode = ERR_TerrainTakeoverMode.Cast(GetGame().GetGameMode());
if (!Replication.IsServer() || !mode) return super.CreateEditorManager(playerID);
SCR_EditorManagerEntity existing = GetEditorManager(playerID);
if (existing) return existing;
if (!ERR_EditorConnectionReady(playerID))
{
if (!m_ERREditorRetryQueued && m_ERREditorRetryAttempts == 0)
PrintFormat("Reintroduced [terrain editor]: waiting for distinct network ownership; player=%1", playerID);
ERR_QueueTerrainEditorRetry(mode);
return null;
}
return super.CreateEditorManager(playerID);
}
protected void ERR_QueueTerrainEditorRetry(ERR_GameMode mode)
{
m_ERREditorRetryNeeded = true;
if (m_ERREditorRetryQueued || m_ERREditorRetryAttempts >= 80) return;
m_ERREditorRetryMode = mode;
m_ERREditorRetryQueued = true;
GetGame().GetCallqueue().CallLater(ERR_RetryTerrainEditors, 250, false);
}
protected void ERR_RetryTerrainEditors()
{
m_ERREditorRetryQueued = false;
if (!m_ERREditorRetryMode || m_ERREditorRetryMode != ERR_GameMode.Get()) { ERR_StopTerrainEditorRetry(); return; }
m_ERREditorRetryAttempts++;
m_ERREditorRetryNeeded = false;
ERR_ReconcileTerrainEditors(m_ERREditorRetryMode, true);
if (m_ERREditorRetryNeeded && m_ERREditorRetryAttempts == 80)
Print("Reintroduced [terrain editor]: ownership repair window ended; future player registration can still create its editor", LogLevel.WARNING);
if (!m_ERREditorRetryNeeded || m_ERREditorRetryAttempts >= 80) ERR_StopTerrainEditorRetry();
}
protected void ERR_StopTerrainEditorRetry()
{
if (GetGame()) GetGame().GetCallqueue().Remove(ERR_RetryTerrainEditors);
m_ERREditorRetryMode = null;
m_ERREditorRetryNeeded = false;
m_ERREditorRetryQueued = false;
m_ERREditorRetryAttempts = 0;
}
override void OnGameEnd()
{
ERR_StopTerrainEditorRetry();
super.OnGameEnd();
}
// Registered-player events can precede takeover (notably on #restart).
// Restore only missing managers, and let vanilla derive modes from actual roles.
void ERR_ReconcileTerrainEditors(ERR_GameMode mode, bool missingOnly = false)
{
if (!Replication.IsServer() || !mode || mode != ERR_GameMode.Get() || !mode.ERR_TerrainTakenOver()) return;
PlayerManager players = GetGame().GetPlayerManager();
array<int> ids = {}; players.GetPlayers(ids);
foreach (int id : ids)
{
if (!players.IsPlayerConnected(id) || !players.GetPlayerController(id)) continue;
SCR_EditorManagerEntity editor = GetEditorManager(id);
if (editor && missingOnly) continue;
if (!editor)
{
OnPlayerRegistered(id);
editor = GetEditorManager(id);
}
if (!editor) continue; // Ownership is still settling; the bounded retry owns repair.
editor.RecreateEditorModes();
IEntity body = players.GetPlayerControlledEntity(id);
if (body && ERR_Actors.Alive(body)) OnPlayerSpawn(id, body);
PrintFormat("Reintroduced [terrain editor]: player=%1 admin=%2 limited=%3 canOpen=%4", id, SCR_Global.IsAdmin(id), editor.IsLimited(), editor.CanOpen());
}
}
void ERR_SetTerrainModeEvents(SCR_BaseGameMode mode, bool attach)
{
if (!mode || !Replication.IsServer()) return;
ERR_StopTerrainEditorRetry();
mode.GetOnPlayerRegistered().Remove(OnPlayerRegistered);
mode.GetOnPostCompPlayerDisconnected().Remove(OnPlayerDisconnected);
mode.GetOnPlayerSpawned().Remove(OnPlayerSpawn);
mode.GetOnPlayerKilled().Remove(OnPlayerKilled);
mode.GetOnPlayerDeleted().Remove(OnPlayerDeleted);
mode.GetOnPlayerRoleChange().Remove(OnPlayerRoleChange);
if (!attach) return;
mode.GetOnPlayerRegistered().Insert(OnPlayerRegistered);
mode.GetOnPostCompPlayerDisconnected().Insert(OnPlayerDisconnected);
mode.GetOnPlayerSpawned().Insert(OnPlayerSpawn);
mode.GetOnPlayerKilled().Insert(OnPlayerKilled);
mode.GetOnPlayerDeleted().Insert(OnPlayerDeleted);
mode.GetOnPlayerRoleChange().Insert(OnPlayerRoleChange);
Print("Reintroduced [terrain takeover]: Game Master player and admin events attached to Escape");
}
}
// Vanilla caches are tied to the original game mode. Release its exact
// components before takeover. Some vanilla destructors unconditionally clear
// their static cache later; resolve those getters from the current takeover
// owner so delayed destruction cannot invalidate the replacement.
modded class SCR_ArsenalManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_ArsenalManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
}
modded class SCR_GroupsManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_GroupsManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
}
modded class SCR_SupportStationManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_SupportStationManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
}
modded class SCR_TaskFinishHistoryManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_TaskFinishHistoryManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
override static SCR_TaskFinishHistoryManagerComponent GetInstance()
{
ERR_TerrainTakeoverMode mode;
if (GetGame()) mode = ERR_TerrainTakeoverMode.Cast(GetGame().GetGameMode());
if (mode) return SCR_TaskFinishHistoryManagerComponent.Cast(mode.FindComponent(SCR_TaskFinishHistoryManagerComponent));
return super.GetInstance();
}
}
modded class SCR_TaskManagerUIComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_TaskManagerUIComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
override static SCR_TaskManagerUIComponent GetInstance()
{
ERR_TerrainTakeoverMode mode;
if (GetGame()) mode = ERR_TerrainTakeoverMode.Cast(GetGame().GetGameMode());
if (mode) return SCR_TaskManagerUIComponent.Cast(mode.FindComponent(SCR_TaskManagerUIComponent));
return super.GetInstance();
}
}
modded class SCR_TextsTaskManagerComponent
{
static void ERR_ReleaseTerrainSingleton(SCR_TextsTaskManagerComponent expected)
{
if (s_Instance == expected) s_Instance = null;
}
override static SCR_TextsTaskManagerComponent GetInstance()
{
ERR_TerrainTakeoverMode mode;
if (GetGame()) mode = ERR_TerrainTakeoverMode.Cast(GetGame().GetGameMode());
if (mode) return SCR_TextsTaskManagerComponent.Cast(mode.FindComponent(SCR_TextsTaskManagerComponent));
return super.GetInstance();
}
}
modded class SCR_MapMarkerManagerComponent
{
override static SCR_MapMarkerManagerComponent GetInstance()
{
ERR_TerrainTakeoverMode mode;
if (GetGame()) mode = ERR_TerrainTakeoverMode.Cast(GetGame().GetGameMode());
if (mode) return SCR_MapMarkerManagerComponent.Cast(mode.FindComponent(SCR_MapMarkerManagerComponent));
return super.GetInstance();
}
}
modded class SCR_RespawnSystemComponent
{
override static SCR_RespawnSystemComponent GetInstance()
{
ERR_TerrainTakeoverMode mode;
if (GetGame()) mode = ERR_TerrainTakeoverMode.Cast(GetGame().GetGameMode());
if (mode) return SCR_RespawnSystemComponent.Cast(mode.FindComponent(SCR_RespawnSystemComponent));
return super.GetInstance();
}
}
// World systems survive mode replacement too. Vanilla caches this component
// once in OnInit; rebinding also wakes a system disabled by the original mode.
modded class DeployMenuSystem
{
void ERR_BindEscapeRespawn(SCR_RespawnSystemComponent respawn)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !respawn || respawn != SCR_RespawnSystemComponent.Cast(mode.FindComponent(SCR_RespawnSystemComponent))) return;
if (m_RespawnSystem != respawn)
{
m_RespawnSystem = respawn;
m_bReady = false;
Print("Reintroduced [loading]: respawn animation and preparation display attached to Escape");
}
Enable(true);
}
}
// Standalone global nodes otherwise have no delivery-order relationship. During
// a connected restart a faction delegate can arrive before its new mode and
// faction manager. Express that dependency to replication before publishing the
// newly spawned hierarchy, rather than relying on an arbitrary frame delay.
class ERR_TerrainTakeoverReplication
{
static void CollectDelegates(SCR_DelegateFactionManagerComponent manager, array<IEntity> entities)
{
if (!manager) return;
map<Faction, SCR_EditableFactionComponent> delegates = new map<Faction, SCR_EditableFactionComponent>();
manager.GetFactionDelegates(delegates);
for (int i = 0; i < delegates.Count(); i++)
{
SCR_EditableFactionComponent delegate = delegates.GetElement(i);
if (delegate && delegate.GetOwner() && !entities.Contains(delegate.GetOwner())) entities.Insert(delegate.GetOwner());
}
}
protected static bool Parent(IEntity entity, IEntity parent)
{
RplComponent childRpl = SCR_EntityHelper.GetEntityRplComponent(entity);
RplComponent parentRpl = SCR_EntityHelper.GetEntityRplComponent(parent);
if (!childRpl || !parentRpl) return false;
RplNode childNode = childRpl.GetNode();
RplNode parentNode = parentRpl.GetNode();
if (!childNode || !parentNode || childNode == parentNode) return false;
if (childNode.GetParent() != parentNode) childNode.SetParent(parentNode);
return childNode.GetParent() == parentNode;
}
static bool Link(ERR_GameMode mode)
{
IEntity factions = GetGame().GetFactionManager();
if (!factions || !Parent(factions, mode)) return false;
if (!Parent(GetGame().GetLoadoutManager(), mode) || !Parent(GetGame().GetChat(), mode)) return false;
SCR_DelegateFactionManagerComponent manager = SCR_DelegateFactionManagerComponent.GetInstance();
if (!manager) return false;
map<Faction, SCR_EditableFactionComponent> delegates = new map<Faction, SCR_EditableFactionComponent>();
manager.GetFactionDelegates(delegates);
for (int i = 0; i < delegates.Count(); i++)
{
SCR_EditableFactionComponent delegate = delegates.GetElement(i);
if (!delegate || !Parent(delegate.GetOwner(), factions)) return false;
}
return !delegates.IsEmpty();
}
}
@@ -0,0 +1,219 @@
// Resolve from the scenario header even before a world exists: the backend asks
// for its mission name during startup. Child scenario filenames are not terrains.
class ERR_TerrainDisplayName
{
// GetWorldFile() returns a path, often $Addon:Worlds/..., not a GUID resource.
// ResourceName.GetPath() asks the resource system to resolve it and logs a
// zero-GUID warning on every call. Compare/display paths without that lookup.
static string Path(string world)
{
if (world.Length() >= 18 && world.Substring(0, 1) == "{" && world.Substring(17, 1) == "}")
return world.Substring(18, world.Length() - 18);
return world;
}
static string Label(string world)
{
if (world.IsEmpty()) return "";
string name = FilePath.StripPath(Path(world));
int extension = name.IndexOf(".");
if (extension >= 0) name = name.Substring(0, extension);
// Packed worlds may hide their parent container before native world loading.
// Strip conventional scenario wrappers for that early callback fallback.
string lower = name; lower.ToLower();
foreach (string prefix : {"gm_", "err_", "conflict_", "combatops_", "campaign_"})
{
if (lower.IndexOf(prefix) != 0) continue;
name = name.Substring(prefix.Length(), name.Length() - prefix.Length());
lower = name; lower.ToLower(); break;
}
foreach (string suffix : {"_test", "_gm", "_exploration", "_conflict", "_combatops"})
{
int start = lower.Length() - suffix.Length();
if (start < 0 || lower.Substring(start, suffix.Length()) != suffix) continue;
name = name.Substring(0, start); lower = name; lower.ToLower(); break;
}
if (lower == "eden") return "Everon";
name.Replace("_", " "); name.Replace("\n", " "); name.Replace("\r", " ");
name = name.Trim();
if (name.Length() > 80) name = name.Substring(0, 80);
if (!name.IsEmpty()) { string first = name.Substring(0, 1); first.ToUpper(); name = first + name.Substring(1, name.Length() - 1); }
return name;
}
static string Resolve(ResourceName world)
{
array<ResourceName> seen = {};
for (int depth = 0; depth < 16 && !world.IsEmpty(); depth++)
{
if (seen.Contains(world)) break;
seen.Insert(world);
Resource resource = BaseContainerTools.LoadContainer(world);
if (!resource || !resource.IsValid()) break;
BaseContainer container = resource.GetResource().ToBaseContainer();
if (!container) break;
ResourceName parent;
if (!container.Get("Parent", parent) || parent.IsEmpty() || seen.Contains(parent)) break;
world = parent;
}
return Label(world);
}
}
// Scenario selection still loads the terrain's header. Advertise the configured
// Escape mode through the native DS name callback, then update local metadata
// only when the authority actually starts Escape or its bootstrap reaches a client.
modded class ArmaReforgerScripted
{
protected ref SCR_MissionHeader m_ERRPresentedTerrainHeader;
protected string m_ERROriginalTerrainTitle;
protected string m_ERROriginalTerrainGameMode;
protected string m_ERRAppliedTerrainTitle;
protected string m_ERRTitleWorld;
protected string m_ERRTerrainLabel;
protected BaseWorld m_ERRPresentedTerrainWorld;
protected bool m_ERRTitleLoaded;
protected float m_ERRTerrainPresentationWait;
string ERR_TerrainMissionTitle(SCR_MissionHeader header)
{
string world = header.GetWorldResourceName();
string loadedWorld = GetWorldFile();
// After the actual Escape bootstrap, the running world wins over a stale
// source header. Before that, do not label an incoming scenario with the
// outgoing world's terrain. A saved next-round selection is never consulted.
bool confirmed = m_ERRPresentedTerrainHeader == header && m_ERRPresentedTerrainWorld && m_ERRPresentedTerrainWorld == GetWorld();
if (confirmed && !loadedWorld.IsEmpty()) world = loadedWorld;
bool loaded = GetWorld() && GetWorld().GetNumSubScenes() > 0 && !world.IsEmpty() && ERR_TerrainDisplayName.Path(loadedWorld) == ERR_TerrainDisplayName.Path(world);
if (loaded)
world = GetWorld().GetSubSceneName(0);
if (world != m_ERRTitleWorld || loaded != m_ERRTitleLoaded)
{
m_ERRTitleWorld = world; m_ERRTitleLoaded = loaded;
m_ERRTerrainLabel = ERR_TerrainDisplayName.Resolve(world);
}
string title = "Co10 Escape Reintroduced";
if (!m_ERRTerrainLabel.IsEmpty()) title += " - " + m_ERRTerrainLabel;
return title;
}
override string GetMissionName()
{
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
// Our own presentation has already set GameMode=Escape. It still needs to
// follow the running terrain when the root scene becomes available later.
if (header && header == m_ERRPresentedTerrainHeader)
{
ERR_RefreshTerrainPresentation();
return header.m_sName;
}
// The engine requests this before the dedicated world has loaded. Preserve
// authored Escape names and explicit opt-outs; do not change the scenario ID.
if (Replication.IsServer() && header && header.m_sGameMode != "Escape" && m_ERRTerrainStage != ERR_TerrainTakeoverStage.FAILED && ERR_TerrainTakeoverSettings.Enabled())
return ERR_TerrainMissionTitle(header);
return super.GetMissionName();
}
void ERR_PresentTerrainTakeover()
{
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
if (!header || SCR_Global.IsEditMode()) return;
if (m_ERRPresentedTerrainHeader == header && m_ERRPresentedTerrainWorld == GetWorld())
{ ERR_RefreshTerrainPresentation(); return; }
ERR_RestoreTerrainPresentation();
m_ERRPresentedTerrainHeader = header;
m_ERRPresentedTerrainWorld = GetWorld();
m_ERROriginalTerrainTitle = header.m_sName;
m_ERROriginalTerrainGameMode = header.m_sGameMode;
m_ERRAppliedTerrainTitle = ERR_TerrainMissionTitle(header);
header.m_sName = m_ERRAppliedTerrainTitle;
header.m_sGameMode = "Escape";
PrintFormat("Reintroduced [terrain presentation]: mission=%1; source scenario=%2", header.m_sName, header.GetHeaderResourceName());
}
void ERR_RefreshTerrainPresentation()
{
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
if (!header || header != m_ERRPresentedTerrainHeader || SCR_Global.IsEditMode()) return;
if (m_ERRPresentedTerrainWorld != GetWorld() || header.m_sName != m_ERRAppliedTerrainTitle) return;
string title = ERR_TerrainMissionTitle(header);
if (title == m_ERRAppliedTerrainTitle) return;
m_ERRAppliedTerrainTitle = title; header.m_sName = title;
PrintFormat("Reintroduced [terrain presentation]: refreshed mission=%1; loaded world=%2", title, GetWorldFile());
}
string ERR_TerrainLoadingTitle(SCR_MissionHeader requested)
{
if (!requested) return "";
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
// The loading screen can hold a separate copy of the same scenario header.
// Never replace another incoming scenario's title with the outgoing world.
if (!header || header != m_ERRPresentedTerrainHeader || m_ERRPresentedTerrainWorld != GetWorld()) return requested.m_sName;
if (requested != header && (requested.GetHeaderResourceName().IsEmpty() || requested.GetHeaderResourceName() != header.GetHeaderResourceName())) return requested.m_sName;
ERR_RefreshTerrainPresentation();
return header.m_sName;
}
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
if (!m_ERRPresentedTerrainHeader) return;
m_ERRTerrainPresentationWait += timeslice;
if (m_ERRTerrainPresentationWait < 0.5) return;
m_ERRTerrainPresentationWait = 0;
ERR_RefreshTerrainPresentation();
}
protected void ERR_RestoreTerrainPresentation()
{
if (m_ERRPresentedTerrainHeader)
{
// A later change by another system belongs to that system.
if (m_ERRPresentedTerrainHeader.m_sName == m_ERRAppliedTerrainTitle) m_ERRPresentedTerrainHeader.m_sName = m_ERROriginalTerrainTitle;
if (m_ERRPresentedTerrainHeader.m_sGameMode == "Escape") m_ERRPresentedTerrainHeader.m_sGameMode = m_ERROriginalTerrainGameMode;
}
m_ERRPresentedTerrainHeader = null;
m_ERRPresentedTerrainWorld = null;
m_ERROriginalTerrainTitle = "";
m_ERROriginalTerrainGameMode = "";
m_ERRAppliedTerrainTitle = "";
}
override protected void OnBeforeWorldCleanup()
{
ERR_RestoreTerrainPresentation();
m_ERRTitleWorld = ""; m_ERRTerrainLabel = ""; m_ERRTitleLoaded = false; m_ERRTerrainPresentationWait = 0;
super.OnBeforeWorldCleanup();
}
}
// Vanilla copies the header title once, before the replicated Escape bootstrap
// may arrive. Refresh only the title; do not recreate loading widgets/hints.
modded class SCR_LoadingScreenComponent
{
protected ref SCR_MissionHeader m_ERRLoadingTerrainHeader;
protected string m_ERRLoadingTerrainTitle;
protected float m_ERRLoadingTerrainWait;
override void OnMissionDataRetrieved(SCR_MissionHeader header)
{
super.OnMissionDataRetrieved(header);
m_ERRLoadingTerrainHeader = header;
m_ERRLoadingTerrainTitle = ""; m_ERRLoadingTerrainWait = 0;
ERR_RefreshLoadingTerrainTitle();
}
protected void ERR_RefreshLoadingTerrainTitle()
{
if (!m_ERRLoadingTerrainHeader || !m_Widgets || !m_Widgets.m_wTitle || !GetGame()) return;
string title = GetGame().ERR_TerrainLoadingTitle(m_ERRLoadingTerrainHeader);
if (title == m_ERRLoadingTerrainTitle) return;
m_ERRLoadingTerrainTitle = title;
m_Widgets.m_wTitle.SetText(title);
}
override void Update(float timeSlice, float progress = 0, float minDurationRatio = 0)
{
super.Update(timeSlice, progress, minDurationRatio);
m_ERRLoadingTerrainWait += timeSlice;
if (m_ERRLoadingTerrainWait < 0.5) return;
m_ERRLoadingTerrainWait = 0;
ERR_RefreshLoadingTerrainTitle();
}
}
@@ -0,0 +1,67 @@
// The server selects the island's existing scenario. No island dependency is
// added to Escape, and no world resource is changed by these settings.
modded class SCR_MissionHeader
{
[Attribute("1", desc: "Automatically start Escape on the selected terrain scenario. Set false to keep its original game mode.")]
bool m_bERR_EnableEscape;
[Attribute("", desc: "Optional airport centres: X Z pairs separated by semicolons, for example 1525 5075. Up to 16 centres.")]
string m_sERR_Airfields;
}
class ERR_TerrainTakeoverSettings
{
static bool Coordinate(string value, out float number)
{
if (value.IsEmpty() || value.Length() > 16) return false;
bool digit, dot;
for (int i = 0; i < value.Length(); i++)
{
string character = value.Substring(i, 1);
if (i == 0 && character == "-") continue;
if (character == "." && !dot) { dot = true; continue; }
if ("0123456789".IndexOf(character) < 0) return false;
digit = true;
}
if (!digit) return false;
number = value.ToFloat();
return number >= -1000000 && number <= 1000000;
}
static bool ParseAirfields(string text, array<vector> centres)
{
centres.Clear();
text.TrimInPlace();
if (text.IsEmpty()) return true;
if (text.Length() > 1024) return false;
array<string> entries = {};
text.Split(";", entries, false);
if (entries.IsEmpty() || entries.Count() > 16) return false;
foreach (string entry : entries)
{
entry.TrimInPlace();
array<string> pair = {};
entry.Split(" ", pair, true);
float x, z;
if (pair.Count() != 2 || !Coordinate(pair[0], x) || !Coordinate(pair[1], z)) { centres.Clear(); return false; }
centres.Insert(Vector(x, 0, z));
}
return true;
}
static bool Enabled()
{
if (!GetGame() || SCR_Global.IsEditMode()) return false;
// Loading Escape is the opt-in. Explicit CLI values override the header,
// including 0 so a host can temporarily keep the original scenario.
string enabled;
if (System.GetCLIParam("errTerrainTakeover", enabled)) return enabled == "1";
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
return !header || header.m_bERR_EnableEscape;
}
static string Airfields()
{
string centres;
if (System.GetCLIParam("errAirfields", centres)) return centres;
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
if (header) return header.m_sERR_Airfields;
return "";
}
}
@@ -1,9 +1,48 @@
#ifdef WORKBENCH
// The input handoff fixture uses the real native panel and camera gate. It
// never submits text, handles passwords or changes administrator privileges.
modded class SCR_ChatPanel
{
bool ERR_ProbeSubmitEmptyChat()
{
string outcome;
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera" || !IsOpen() || !m_Widgets.m_MessageEditBox || !m_Widgets.m_MessageEditBox.GetText().IsEmpty()) return false;
Callback_OnSendMessageAction();
return !IsOpen();
}
}
modded class ERR_ObserverCamera
{
bool ERR_ProbeChatInputDisabled()
{
string outcome;
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera" || GetGame().GetCameraManager().CurrentCamera() != this) return false;
return IsDisabledByMenu();
}
}
// The local native runner has one real player. A physical stand-in tests camera
// tracking; canonical multiplayer target selection is tested separately.
modded class SCR_PlayerController
{
protected IEntity m_ERRCameraProbeTarget;
protected bool m_ERRProbeViewMissing;
protected ref ERR_ObserverViewBuffer m_ERRProbeViewBuffer = new ERR_ObserverViewBuffer();
void ERR_ProbeMissingView(bool missing) { m_ERRProbeViewMissing = missing; }
override bool ERR_ReadPlayerView(IEntity target, float seconds, out vector transform[4], out float fov, out float nearPlane)
{
string outcome;
if (!m_ERRCameraProbeTarget || target != m_ERRCameraProbeTarget || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera") return super.ERR_ReadPlayerView(target, seconds, transform, fov, nearPlane);
RplComponent component = RplComponent.Cast(target.FindComponent(RplComponent));
if (!component) return false;
float now = GetGame().GetWorld().GetWorldTime();
if (!m_ERRProbeViewMissing) m_ERRProbeViewBuffer.Push(component.Id(), now, now, "0 1.6 0", "0 0 1", "0 1 0", 45, 0.0125, RplId.Invalid(), vector.Zero);
vector local[4], world[4];
if (!m_ERRProbeViewBuffer.Read(component.Id(), now, seconds, local, fov, nearPlane)) return false;
target.GetWorldTransform(world); Math3D.MatrixMultiply4(world, local, transform);
return true;
}
string ERR_ProbeAcceptedRecoveryStatus() { return m_ERRShownRecoveryStatus; }
void ERR_ProbeCameraTarget(IEntity target) { m_ERRCameraProbeTarget = target; }
override IEntity ERR_ObservedEntity()
{
@@ -20,6 +59,32 @@ modded class SCR_PlayerController
{
return m_ERRObserverCamera && GetGame().GetCameraManager().CurrentCamera() == m_ERRObserverCamera;
}
// Exercise native action values and the production frame method, not physical
// mouse delivery. Restore actions synchronously; the next real frame applies
// the orbit state retained by the ordinary follow component.
bool ERR_ProbeOrbitFrame(float yaw, float pitch, float zoom, bool enabled, out vector transform[4])
{
string outcome;
if (!System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera" || this != GetGame().GetPlayerController() || !ERR_ProbeCameraActive() || ERR_ObservedEntity()) return false;
ERR_FollowSurvivorCameraComponent follow = ERR_ProbeFollowComponent();
InputManager input = GetGame().GetInputManager();
if (!follow || !input) return false;
float savedYaw = input.GetActionValue("ManualCameraRotateYaw");
float savedPitch = input.GetActionValue("ManualCameraRotatePitch");
float savedZoom = input.GetActionValue("ManualCameraZoom");
input.SetActionValue("ManualCameraRotateYaw", yaw);
input.SetActionValue("ManualCameraRotatePitch", pitch);
input.SetActionValue("ManualCameraZoom", zoom);
bool supplied = Math.AbsFloat(input.GetActionValue("ManualCameraRotateYaw") - yaw) < 0.001 && Math.AbsFloat(input.GetActionValue("ManualCameraRotatePitch") - pitch) < 0.001 && Math.AbsFloat(input.GetActionValue("ManualCameraZoom") - zoom) < 0.001;
vector original[4]; m_ERRObserverCamera.GetWorldTransform(original);
SCR_ManualCameraParam param = new SCR_ManualCameraParam(original, 0.1, m_ERRObserverCamera.GetWorld(), m_ERRObserverCamera.GetVerticalFOV(), enabled, vector.Zero, 0);
if (supplied) follow.EOnCameraFrame(param);
input.SetActionValue("ManualCameraRotateYaw", savedYaw);
input.SetActionValue("ManualCameraRotatePitch", savedPitch);
input.SetActionValue("ManualCameraZoom", savedZoom);
Math3D.MatrixCopy(param.transform, transform);
return supplied && param.isDirty;
}
}
modded class ERR_GameMode
{
@@ -30,6 +95,11 @@ modded class ERR_GameMode
protected IEntity m_ERRCameraBody, m_ERRCameraTarget;
protected SCR_CharacterDamageManagerComponent m_ERRCameraDamage;
protected SCR_ManualCamera m_ERRCameraOther;
protected vector m_ERRCameraPoseOffset;
protected vector m_ERRCameraOrbitPose[4];
protected int m_ERRCameraHeadAlpha;
protected Widget m_ERRCameraChatRoot;
protected SCR_ChatPanel m_ERRCameraChatPanel;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
@@ -42,8 +112,50 @@ modded class ERR_GameMode
{
if (ok) { Print("ERR SMOKE PASS: unconscious camera " + message); return true; }
Print("ERR SMOKE FAIL: unconscious camera " + message, LogLevel.ERROR);
ERR_CameraCloseChat();
GetGame().GetCallqueue().Remove(ERR_CameraProbeTick); return false;
}
protected void ERR_CameraCloseChat()
{
if (m_ERRCameraChatPanel) m_ERRCameraChatPanel.Internal_Close();
if (m_ERRCameraChatRoot) m_ERRCameraChatRoot.RemoveFromHierarchy();
m_ERRCameraChatPanel = null; m_ERRCameraChatRoot = null;
}
protected bool ERR_CameraOpenChat(SCR_PlayerController pc, bool unregister)
{
InputManager input = GetGame().GetInputManager();
SCR_ChatPanelManager manager = SCR_ChatPanelManager.GetInstance();
if (!ERR_CameraCheck(manager && !input.IsContextActive("ChatContext"), "chat fixture begins after native ChatContext expired")) return false;
m_ERRCameraChatRoot = GetGame().GetWorkspace().CreateWidgets("{28C54D576A8997CF}UI/layouts/HUD/Chat/ChatPanel.layout");
if (m_ERRCameraChatRoot) m_ERRCameraChatPanel = SCR_ChatPanel.Cast(m_ERRCameraChatRoot.FindHandler(SCR_ChatPanel));
if (!ERR_CameraCheck(m_ERRCameraChatPanel != null, "real native chat panel layout and handlers created")) return false;
manager.OpenChatPanel(m_ERRCameraChatPanel);
if (unregister)
{
manager.Unregister(m_ERRCameraChatPanel);
GetGame().GetWorkspace().SetFocusedWidget(m_ERRCameraChatRoot.FindAnyWidget("m_MessageEditBox"));
}
else GetGame().GetWorkspace().SetFocusedWidget(null);
bool hasFocus = GetGame().GetWorkspace().GetFocusedWidget() != null;
if (!ERR_CameraCheck(m_ERRCameraChatPanel.IsOpen() && hasFocus == unregister && !input.IsContextActive("ChatContext"), "native chat open state precedes context activation with expected focus")) return false;
ERR_ObserverCamera camera = ERR_ObserverCamera.Cast(GetGame().GetCameraManager().CurrentCamera());
if (!ERR_CameraCheck(camera && camera.ERR_ProbeChatInputDisabled(), "actual observer gate yields to open chat before native context resolution")) return false;
PrintFormat("ERR CAMERA CHAT: unregistered=%1 immediateChatContext=%2", unregister, input.IsContextActive("ChatContext"));
ERR_FollowSurvivorCameraComponent follow = pc.ERR_ProbeFollowComponent();
bool view = follow.IsPlayerView();
follow.Input(!camera.ERR_ProbeChatInputDisabled(), false, false, true);
if (!ERR_CameraCheck(follow.IsPlayerView() == view, "Enter state through actual chat gate does not switch observer view")) return false;
return true;
}
protected bool ERR_CameraSubmitChat(SCR_PlayerController pc)
{
InputManager input = GetGame().GetInputManager();
ERR_FollowSurvivorCameraComponent follow = pc.ERR_ProbeFollowComponent();
if (!ERR_CameraCheck(m_ERRCameraChatPanel && m_ERRCameraChatPanel.IsOpen() && input.IsContextActive("ChatContext") && !follow.InputContextActive() && !follow.IsPlayerView(), "subsequent native frames keep chat active and observer controls inactive without HUD updates")) return false;
if (!ERR_CameraCheck(m_ERRCameraChatPanel.ERR_ProbeSubmitEmptyChat() && !follow.IsPlayerView(), "vanilla empty submission callback closes chat without changing camera view")) return false;
ERR_CameraCloseChat();
return true;
}
protected void ERR_CameraKnockout()
{
m_ERRCameraDamage.ForceUnconsciousness();
@@ -51,6 +163,21 @@ modded class ERR_GameMode
foreach (SCR_PersistentDamageEffect effect : effects) m_ERRCameraDamage.TerminateDamageEffect(effect);
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true);
}
protected bool ERR_CameraNoTargetOrbit(SCR_PlayerController pc)
{
vector body = m_ERRCameraBody.GetOrigin();
vector baseline[4], yaw[4], pitch[4], zoom[4], blocked[4];
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, 0, 0, true, baseline), "no-target frame accepts native orbit action values")) return false;
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(1, 0, 0, true, yaw) && vector.Distance(baseline[2], yaw[2]) > 0.05, "no-target yaw action changes production camera direction")) return false;
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, -1, 0, true, pitch) && Math.AbsFloat(yaw[2][1] - pitch[2][1]) > 0.03, "no-target pitch action changes production camera elevation")) return false;
vector aim = pc.ERR_ObserverAnchor() + "0 1.5 0";
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(0, 0, -1, true, zoom) && vector.Distance(zoom[3], aim) < vector.Distance(pitch[3], aim) - 0.05, "no-target zoom action moves production camera closer to casualty anchor")) return false;
if (!ERR_CameraCheck(pc.ERR_ProbeOrbitFrame(1, -1, -1, false, blocked) && vector.Distance(zoom[3], blocked[3]) < 0.001 && vector.Distance(zoom[2], blocked[2]) < 0.001, "disabled no-target input preserves camera pose")) return false;
if (!ERR_CameraCheck(vector.Distance(body, m_ERRCameraBody.GetOrigin()) < 0.001 && pc.GetControlledEntity() == m_ERRCameraBody && !pc.ERR_ObservedEntity(), "no-target orbit leaves casualty and possession unchanged")) return false;
Math3D.MatrixCopy(zoom, m_ERRCameraOrbitPose);
Print("ERR CAMERA ORBIT: native action/frame regression; physical mouse delivery and multiplayer are not tested");
return true;
}
protected void ERR_CameraProbeTick()
{
if (System.GetTickCount() >= m_ERRCameraDeadline || !PreparationError().IsEmpty()) { ERR_CameraCheck(false, "deadline/preparation " + PreparationError()); return; }
@@ -90,6 +217,36 @@ modded class ERR_GameMode
if (!ERR_CameraCheck(pc.GetControlledEntity() == m_ERRCameraBody && ERR_Actors.Alive(m_ERRCameraBody) && !ERR_Actors.CanFight(m_ERRCameraBody), "possession and living unconscious body unchanged")) return;
if (!ERR_CameraCheck(!pc.ERR_ObservedEntity() && vector.DistanceXZ(pc.ERR_ObserverAnchor(), m_ERRCameraBody.GetOrigin()) < 1, "no teammate falls back to the casualty body")) return;
if (!ERR_CameraCheck(playerInventory.Contains(m_ERRCameraWeapon), "enabled retention keeps the exact rifle with the unconscious player")) return;
if (!ERR_CameraCheck(!RecoveryStatus().IsEmpty() && RecoveryStatus().Contains("Treatment or natural recovery") && pc.ERR_ProbeAcceptedRecoveryStatus() == RecoveryStatus(), "accepted observer hint contains the current ongoing recovery status")) return;
if (!ERR_CameraNoTargetOrbit(pc)) return;
m_ERRCameraStage = 11; m_ERRCameraWait = now + 500; return;
}
if (m_ERRCameraStage == 11)
{
vector applied[4]; GetGame().GetCameraManager().CurrentCamera().GetWorldTransform(applied);
if (!ERR_CameraCheck(pc.ERR_ProbeCameraActive() && vector.Distance(applied[2], m_ERRCameraOrbitPose[2]) < 0.01, "native camera applies and retains no-target orbit direction on subsequent frames")) return;
if (!ERR_CameraOpenChat(pc, false)) return;
m_ERRCameraStage = 111; m_ERRCameraWait = now + 500; return;
}
if (m_ERRCameraStage == 111)
{
if (!ERR_CameraSubmitChat(pc)) return;
m_ERRCameraStage = 112; m_ERRCameraWait = now + 500; return;
}
if (m_ERRCameraStage == 112)
{
if (!ERR_CameraOpenChat(pc, true)) return;
m_ERRCameraStage = 113; m_ERRCameraWait = now + 500; return;
}
if (m_ERRCameraStage == 113)
{
if (!ERR_CameraSubmitChat(pc)) return;
m_ERRCameraStage = 114; m_ERRCameraWait = now + 500; return;
}
if (m_ERRCameraStage == 114)
{
if (!ERR_CameraCheck(pc.ERR_ProbeCameraActive() && pc.ERR_ProbeFollowComponent().InputContextActive() && !GetGame().GetInputManager().IsContextActive("ChatContext") && !pc.ERR_ProbeFollowComponent().IsPlayerView(), "closing native chat restores observer controls without an Enter toggle")) return;
Print("ERR CAMERA CHAT: real native panel lifecycle and empty submission callback; simulated Enter state, not physical keyboard or administrator authentication proof");
vector point = ERR_Actors.Ground(m_ERRCameraBody.GetOrigin() + "25 0 25");
m_ERRCameraTarget = ERR_Actors.Spawn(Factions().PrisonerPrefab, point); Own(m_ERRCameraTarget);
if (!ERR_CameraCheck(m_ERRCameraTarget != null, "physical follow-target stand-in created")) return;
@@ -119,6 +276,7 @@ modded class ERR_GameMode
if (inputName.Contains("Observer") || inputName == "ManualCameraRotateYaw") Print("ERR CAMERA INPUT REGISTERED: " + inputName);
}
if (!ERR_CameraCheck(follow.InputContextActive() && input.IsActionActive("ERR_ObserverPrevious") && input.IsActionActive("ERR_ObserverNext") && input.IsActionActive("ERR_ObserverView") && input.IsActionActive("ManualCameraRotateYaw"), "dedicated arrow/Enter actions and inherited orbit inputs loaded and active")) return;
m_ERRCameraHeadAlpha = SCR_ChimeraCharacter.Cast(m_ERRCameraTarget).m_fFaceAlphaTest;
follow.Input(true, false, false, true);
if (!ERR_CameraCheck(follow.IsPlayerView(), "Enter handler selects player view")) return;
follow.Input(true, false, false, true);
@@ -129,7 +287,25 @@ modded class ERR_GameMode
if (m_ERRCameraStage == 30)
{
vector expected[4];
if (!ERR_CameraCheck(ERR_FollowSurvivorCameraComponent.PlayerView(m_ERRCameraTarget, expected) && vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), expected[3]) < 0.1, "native camera follows teammate head in player view")) return;
if (!ERR_CameraCheck(vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.CoordToParent("0 1.6 0")) < 0.1, "native camera follows supplied player-view pose")) return;
if (!ERR_CameraCheck(Math.AbsFloat(GetGame().GetCameraManager().CurrentCamera().GetVerticalFOV() - 45) < 0.1, "player-view field of view follows the supplied zoom")) return;
System.MakeScreenshot("$logs:err-observer-player-view");
m_ERRCameraPoseOffset = GetGame().GetCameraManager().CurrentCamera().GetOrigin() - m_ERRCameraTarget.GetOrigin();
pc.ERR_ProbeMissingView(true);
m_ERRCameraTarget.SetOrigin(m_ERRCameraTarget.GetOrigin() + "5 0 0");
m_ERRCameraStage = 301; m_ERRCameraWait = now + 1500; return;
}
if (m_ERRCameraStage == 301)
{
vector relative = GetGame().GetCameraManager().CurrentCamera().GetOrigin() - m_ERRCameraTarget.GetOrigin();
if (!ERR_CameraCheck(pc.ERR_ProbeFollowComponent().IsPlayerView() && vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.GetOrigin()) > 2, "expired view stream uses stable follow camera while preserving player-view selection")) return;
pc.ERR_ProbeMissingView(false);
m_ERRCameraStage = 302; m_ERRCameraWait = now + 1000; return;
}
if (m_ERRCameraStage == 302)
{
vector expected[4];
if (!ERR_CameraCheck(vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.CoordToParent("0 1.6 0")) < 0.1, "fresh supplied camera pose resumes after stream interruption")) return;
pc.ERR_ProbeFollowComponent().Input(true, false, false, true);
pc.ERR_ProbeFollowComponent().Input(true, false, false, false);
m_ERRCameraStage = 31; m_ERRCameraWait = now + 1000; return;
@@ -137,6 +313,8 @@ modded class ERR_GameMode
if (m_ERRCameraStage == 31)
{
if (!ERR_CameraCheck(!pc.ERR_ProbeFollowComponent().IsPlayerView() && vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.GetOrigin()) > 2, "second Enter restores orbit view")) return;
if (!ERR_CameraCheck(SCR_ChimeraCharacter.Cast(m_ERRCameraTarget).m_fFaceAlphaTest == m_ERRCameraHeadAlpha, "leaving player view restores the target head visibility")) return;
System.MakeScreenshot("$logs:err-observer-orbit-view");
Resource otherResource = Resource.Load("{EB1210AAE1BB8F54}Prefabs/ERR/SpectatorCamera.et");
m_ERRCameraOther = SCR_ManualCamera.Cast(GetGame().SpawnEntityPrefab(otherResource, GetWorld()));
if (!ERR_CameraCheck(m_ERRCameraOther && GetGame().GetCameraManager().CurrentCamera() == m_ERRCameraOther && !pc.ERR_ProbeCameraActive(), "separate manual camera takes over before recovery")) return;
@@ -148,6 +326,7 @@ modded class ERR_GameMode
{
if (!ERR_CameraCheck(ERR_Actors.CanFight(m_ERRCameraBody) && pc.GetControlledEntity() == m_ERRCameraBody && pc.ERR_ObserverState() == ERR_ObserverReason.NONE && !pc.ERR_HasObserverCamera(), "native healing closes spectator and retains the original conscious player")) return;
if (!ERR_CameraCheck(GetGame().GetCameraManager().CurrentCamera() == pc.GetPlayerCamera(), "normal player camera restored")) return;
if (!ERR_CameraCheck(pc.ERR_ProbeAcceptedRecoveryStatus().IsEmpty(), "awakening clears the accepted observer recovery-status cache")) return;
if (m_ERRCameraOther) { m_ERRCameraOther.Terminate(); delete m_ERRCameraOther; m_ERRCameraOther = null; }
if (!ERR_CameraCheck(character.GetWeaponManagerComponent().GetCurrentWeapon() && character.GetWeaponManagerComponent().GetCurrentWeapon().GetOwner() == m_ERRCameraWeapon, "revival equips the same rifle without ground pickup")) return;
if (!ERR_CameraCheck(Settings().Set("keepWeaponOnUnconscious", 0), "retention disabled through persistent setting")) return;
@@ -6,6 +6,15 @@ class ERR_VehicleSecurityComponent : ScriptComponent
{
[RplProp()] protected bool m_Configured;
[RplProp()] protected bool m_Breached;
// Monotonic authority state: unused, active, completed. Completion also
// latches locally so duplicate or delayed active snapshots cannot replay.
[RplProp(onRplName: "ApplyAlarm")] protected int m_AlarmState;
protected bool m_AlarmStarted;
protected bool m_AlarmFinished;
protected bool m_AlarmAudioAttempted;
protected float m_AlarmBegan = -1;
protected AudioHandle m_AlarmHandle = AudioHandle.Invalid;
protected ERR_GameMode m_AlarmMode;
// Same authored local point as the dedicated exterior action context.
[Attribute("-0.95 1.05 0.3")] protected vector m_LockAnchor;
@@ -99,6 +108,99 @@ class ERR_VehicleSecurityComponent : ScriptComponent
if (!CanBreach(user, reason)) return false;
m_Breached = true;
Replication.BumpMe();
BeginAlarm();
return true;
}
bool AlarmActive() { return m_AlarmState == 1 && !m_AlarmFinished; }
bool AlarmPlaying()
{
// Native IsSoundPlayed reports completion, as in SCR_SoundManagerModule.
return !m_AlarmFinished && m_AlarmHandle != AudioHandle.Invalid && !AudioSystem.IsSoundPlayed(m_AlarmHandle);
}
protected void BeginAlarm()
{
if (!Replication.IsServer() || !m_Breached || m_AlarmState != 0 || m_AlarmFinished) return;
m_AlarmState = 1;
Replication.BumpMe();
// RplProp callbacks apply on replicas; authority must apply its own state.
ApplyAlarm();
if (AlarmActive()) ERR_VehicleAlarmPerception.Emit(GetOwner());
}
protected void ApplyAlarm()
{
if (m_AlarmState >= 2) { CloseAlarm(); return; }
if (m_AlarmState != 1 || m_AlarmStarted || m_AlarmFinished) return;
if (!GetGame() || !GetGame().GetWorld() || SCR_Global.IsEditMode()) { StopAlarm(); return; }
m_AlarmStarted = true;
m_AlarmBegan = GetGame().GetWorld().GetWorldTime();
// One fixed server deadline. Replicas also cap their own playback if a stop
// update is delayed. No callback, duplicate state or retry renews either cap.
GetGame().GetCallqueue().CallLater(StopAlarm, 8000, false);
GetGame().GetCallqueue().CallLater(AlarmTick, 100, true);
AlarmTick();
}
protected void AlarmTick()
{
if (!AlarmActive() || !GetGame() || !GetGame().GetWorld() || !ERR_Actors.Alive(GetOwner())) { StopAlarm(); return; }
float elapsed = GetGame().GetWorld().GetWorldTime() - m_AlarmBegan;
if (!(elapsed >= 0 && elapsed < 8000)) { StopAlarm(); return; }
ERR_GameMode mode = ERR_GameMode.Get();
if (m_AlarmMode && mode != m_AlarmMode) { StopAlarm(); return; }
// A joining replica can receive the vehicle state before its mission phase.
// Wait only within the original local cap; never restart once completed.
if (!mode) return;
if (ERR_MissionRules.Terminal(mode.Phase())) { StopAlarm(); return; }
if (!mode.Active())
{
if (m_AlarmMode) StopAlarm();
return;
}
if (!m_AlarmMode)
{
m_AlarmMode = mode;
m_AlarmMode.OnStop().Insert(StopAlarm);
}
if (RplSession.Mode() == RplMode.Dedicated) return;
vector transform[4]; GetOwner().GetWorldTransform(transform);
vector horn = "0 1 1.2";
transform[3] = horn.Multiply4(transform);
if (!m_AlarmAudioAttempted)
{
m_AlarmAudioAttempted = true;
array<string> signals = {"HornState"}; array<float> values = {1};
m_AlarmHandle = AudioSystem.PlayEvent("{7E64D37BE3E1F3FA}Sounds/Vehicles/Wheeled/UAZ469/Vehicles_UAZ469_Horn.acp", "SOUND_VEHICLE_HORN_LP", transform, signals, values);
}
else if (m_AlarmHandle != AudioHandle.Invalid)
AudioSystem.SetSoundTransformation(m_AlarmHandle, transform);
}
protected void StopAlarm()
{
if (Replication.IsServer() && m_AlarmState == 1)
{
m_AlarmState = 2;
Replication.BumpMe();
}
CloseAlarm();
}
protected void CloseAlarm()
{
m_AlarmFinished = true;
if (GetGame())
{
GetGame().GetCallqueue().Remove(AlarmTick);
GetGame().GetCallqueue().Remove(StopAlarm);
}
if (m_AlarmMode) m_AlarmMode.OnStop().Remove(StopAlarm);
m_AlarmMode = null;
// Never stop the driver's ordinary horn, vehicle sounds or another source.
if (m_AlarmHandle != AudioHandle.Invalid) AudioSystem.TerminateSound(m_AlarmHandle);
m_AlarmHandle = AudioHandle.Invalid;
}
protected override void OnDelete(IEntity owner)
{
// Owner deletion already removes replicated state; do not bump during teardown.
CloseAlarm();
super.OnDelete(owner);
}
}
@@ -0,0 +1,38 @@
// Per-client temporary override. Never writes the player's video settings.
class ERR_ViewDistance
{
protected float m_Original = -1;
protected float m_Applied = -1;
static int ServerDistance(int requested, float serverLimit)
{
if (requested <= 0) return 0;
if (serverLimit > 0) return Math.Min(requested, serverLimit);
return requested;
}
static float LocalDistance(int requested, float minimum, float maximum, float serverLimit)
{
if (serverLimit > 0) maximum = Math.Min(maximum, serverLimit);
// Never exceed the server maximum, even if below the platform minimum.
return Math.Min(maximum, Math.Max(minimum, requested));
}
void Apply(int requested)
{
if (!GetGame() || System.IsConsoleApp()) return;
if (requested <= 0) { Restore(); return; }
float current = GetGame().GetViewDistance();
float target = LocalDistance(requested, GetGame().GetMinimumViewDistance(), GetGame().GetMaximumViewDistance(), GetGame().GetViewDistanceServerLimit());
if (m_Original < 0) m_Original = current;
if (Math.AbsFloat(current - target) > 0.5) GetGame().SetViewDistance(target);
m_Applied = GetGame().GetViewDistance();
}
void Restore()
{
if (m_Original < 0) return;
// A different owner may have changed the distance during teardown/menu use.
if (GetGame() && !System.IsConsoleApp() && Math.AbsFloat(GetGame().GetViewDistance() - m_Applied) <= 0.5)
GetGame().SetViewDistance(m_Original);
m_Original = -1;
m_Applied = -1;
}
}
@@ -0,0 +1,68 @@
// Server-session ledger survives client crashes, controller IDs and round reloads.
// Identity comes only from the audited roster, never from a client RPC argument.
class ERR_WelcomeLedger
{
protected ref set<UUID> m_Claimed = new set<UUID>();
bool Claim(UUID identity)
{
if (identity.IsNull() || m_Claimed.Find(identity) >= 0 || m_Claimed.Count() >= 10000) return false;
m_Claimed.Insert(identity);
return true;
}
}
modded class ArmaReforgerScripted
{
protected ref ERR_WelcomeLedger m_ERRWelcomeLedger = new ERR_WelcomeLedger();
bool ERR_ClaimWelcomeIdentity(UUID identity)
{
return Replication.IsServer() && m_ERRWelcomeLedger.Claim(identity);
}
}
modded class SCR_PlayerController
{
protected int m_ERRWelcomeSerial;
protected int m_ERRWelcomeDecision;
protected float m_ERRWelcomeRetry;
protected int m_ERRWelcomeServerSerial;
protected bool m_ERRWelcomeServerGranted;
protected float m_ERRWelcomeServerReply = -1000;
bool ERR_WelcomePermit(int serial)
{
if (this != GetGame().GetPlayerController()) return false;
if (serial != m_ERRWelcomeSerial)
{ m_ERRWelcomeSerial = serial; m_ERRWelcomeDecision = 0; m_ERRWelcomeRetry = -1; }
if (m_ERRWelcomeDecision != 0) return m_ERRWelcomeDecision > 0;
float now = GetGame().GetWorld().GetWorldTime();
if (now >= m_ERRWelcomeRetry)
{
m_ERRWelcomeRetry = now + 2000;
Rpc(ERR_RequestWelcomePermit, serial);
}
return m_ERRWelcomeDecision > 0;
}
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
protected void ERR_RequestWelcomePermit(int serial)
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!Replication.IsServer() || !mode || !mode.Active() || serial <= 0 || mode.ERR_WelcomePending() || !ERR_WelcomeVoiceRules.URL(mode.ERR_WelcomeVoiceURL())) return;
ERR_PlayerLife life = mode.Players().CurrentActor(GetControlledEntity());
if (!life || GetGame().GetPlayerManager().GetPlayerController(life.ConnectionId) != this) return;
float now = GetGame().GetWorld().GetWorldTime();
if (now - m_ERRWelcomeServerReply < 250) return;
m_ERRWelcomeServerReply = now;
// Retrying the same reliable request must return its original decision.
if (m_ERRWelcomeServerSerial != serial)
{
m_ERRWelcomeServerSerial = serial;
m_ERRWelcomeServerGranted = GetGame().ERR_ClaimWelcomeIdentity(life.AccountId);
}
Rpc(ERR_ReceiveWelcomePermit, serial, m_ERRWelcomeServerGranted);
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_ReceiveWelcomePermit(int serial, bool allowed)
{
if (this != GetGame().GetPlayerController() || serial != m_ERRWelcomeSerial) return;
m_ERRWelcomeDecision = -1;
if (allowed) m_ERRWelcomeDecision = 1;
}
}
@@ -0,0 +1,469 @@
// Cached synthetic briefings. Only public audio URLs and spoken text replicate.
// Bridge authentication and Google credentials never belong to game clients.
class ERR_WelcomeVoiceRules
{
static bool Decode(string text, array<int> bytes)
{
bytes.Clear();
int length = text.Length();
if (length < 60 || length > 2796204 || length % 4 != 0) return false;
string alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
string block;
for (int i = 0; i < length; i += 4)
{
// Enforce's UTF-8 substring offsets scan from the start. Keep offsets small.
int offset = i % 1024;
if (offset == 0) block = text.Substring(i, Math.Min(1024, length - i));
int a = alphabet.IndexOf(block.Substring(offset, 1));
int b = alphabet.IndexOf(block.Substring(offset + 1, 1));
string cs = block.Substring(offset + 2, 1), ds = block.Substring(offset + 3, 1);
int c = alphabet.IndexOf(cs), d = alphabet.IndexOf(ds);
bool padC = cs == "=", padD = ds == "=";
if (a < 0 || b < 0 || (c < 0 && !padC) || (d < 0 && !padD)) return false;
if ((padC || padD) && i != length - 4) return false;
if (padC && (!padD || (b & 15) != 0)) return false;
if (!padC && padD && (c & 3) != 0) return false;
bytes.Insert((a << 2) | (b >> 4));
if (!padC) bytes.Insert(((b & 15) << 4) | (c >> 2));
if (!padD) bytes.Insert(((c & 3) << 6) | d);
}
return bytes.Count() <= 2097152;
}
static bool URL(string value)
{
if (value.IsEmpty() || value.Length() > 1024 || value.IndexOf("\n") >= 0 || value.IndexOf("\r") >= 0 || value.IndexOf(" ") >= 0) return false;
return value.IndexOf("https://") == 0 || value.IndexOf("http://") == 0;
}
static string Weather(string state)
{
state.ToLower();
if (state == "clear" || state == "sunny") return "clear skies";
if (state == "cloudy") return "cloudy skies";
if (state == "overcast") return "overcast skies";
if (state == "rainy" || state == "rain") return "rain";
if (state == "storm" || state == "stormy") return "storms";
if (state == "fog" || state == "foggy") return "fog";
return "";
}
static string Briefing(string terrain, string current, string next)
{
string text = "Listen up. Welcome to Escape Reintroduced.";
if (!terrain.IsEmpty()) text += " This is " + terrain + ". Your escape was generated for this terrain.";
string weather = Weather(current), forecast = Weather(next);
if (!weather.IsEmpty()) text += " Current weather: " + weather + ".";
if (!forecast.IsEmpty() && forecast != weather) text += " Expected next: " + forecast + ". Timing is uncertain.";
else text += " Keep an eye on the sky; conditions can change.";
return text + " Stay together, keep your heads down. Good luck.";
}
static bool ClipKey(string key)
{
if (key.Length() != 64) return false;
string alphabet = "0123456789abcdef";
for (int i = 0; i < key.Length(); i++)
if (alphabet.IndexOf(key.Substring(i, 1)) < 0) return false;
return true;
}
static bool Ready(bool console, bool active, bool controller, bool menu)
{
return !console && active && controller && !menu;
}
static bool HTTPReady()
{
if (!GetGame()) return false;
BackendApi backend = GetGame().GetBackendApi();
return backend && backend.IsRunning();
}
}
// REST callbacks run on the game thread. Keep reconstruction out of the callback
// and yield between bounded pieces, including when several clips arrive at once.
class ERR_WelcomeVoiceDecoder
{
protected string m_Text;
protected string m_Block;
protected int m_Length;
protected int m_Offset;
protected int m_Bytes;
void Clear()
{
m_Text = ""; m_Block = ""; m_Length = 0; m_Offset = 0; m_Bytes = 0;
}
bool Begin(string text)
{
Clear();
int length = text.Length();
if (length < 60 || length > 2796204 || length % 4 != 0) return false;
m_Text = text; m_Length = length;
return true;
}
protected static int Value(int character)
{
if (character >= 65 && character <= 90) return character - 65;
if (character >= 97 && character <= 122) return character - 71;
if (character >= 48 && character <= 57) return character + 4;
if (character == 43) return 62;
if (character == 47) return 63;
return -1;
}
// -1 rejects the whole clip, 0 yields, 1 has reconstructed every byte.
int Step(array<int> bytes, int encodedLimit = 5460, int milliseconds = 2)
{
bytes.Clear();
if (m_Length == 0) return -1;
if (m_Offset >= m_Length) return 1;
int limit = Math.Min(encodedLimit, 5460);
limit -= limit % 4;
if (limit <= 0) return 0;
int started = System.GetTickCount();
int end = Math.Min(m_Offset + limit, m_Length);
int first = m_Offset;
for (; m_Offset < end; m_Offset += 4)
{
// Check every sixteen quartets; never defer the whole clip to one frame.
if (m_Offset > first && (m_Offset - first) % 64 == 0 && System.GetTickCount() - started >= milliseconds) break;
int offset = m_Offset % 1024;
if (offset == 0) m_Block = m_Text.Substring(m_Offset, Math.Min(1024, m_Length - m_Offset));
int a = Value(m_Block.ToAscii(offset));
int b = Value(m_Block.ToAscii(offset + 1));
int cs = m_Block.ToAscii(offset + 2), ds = m_Block.ToAscii(offset + 3);
int c = Value(cs), d = Value(ds);
bool padC = cs == 61, padD = ds == 61;
if (a < 0 || b < 0 || (c < 0 && !padC) || (d < 0 && !padD)) return -1;
if ((padC || padD) && m_Offset != m_Length - 4) return -1;
if (padC && (!padD || (b & 15) != 0)) return -1;
if (!padC && padD && (c & 3) != 0) return -1;
bytes.Insert((a << 2) | (b >> 4)); m_Bytes++;
if (!padC) { bytes.Insert(((b & 15) << 4) | (c >> 2)); m_Bytes++; }
if (!padD) { bytes.Insert(((c & 3) << 6) | d); m_Bytes++; }
if (m_Bytes > 2097152) return -1;
}
if (m_Offset >= m_Length) { m_Text = ""; m_Block = ""; return 1; }
return 0;
}
}
modded class SCR_RespawnSystemComponent
{
bool ERR_WelcomeAudioReady()
{
return !m_bAudioMuted && (!m_wLoadingPlaceholder || !m_wLoadingPlaceholder.IsVisible());
}
}
class ERR_WelcomeSpeechReceiver : ERR_SpeechReceiver
{
protected ERR_GameMode m_Mode;
void ERR_WelcomeSpeechReceiver(ERR_GameMode mode) { m_Mode = mode; }
override void Complete(string url, string text)
{
if (GetGame() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active()) m_Mode.ERR_PublishWelcome(url, text);
}
}
modded class ERR_GameMode
{
[RplProp()] protected string m_ERRWelcomeVoiceURL;
[RplProp()] protected string m_ERRWelcomeVoiceText;
[RplProp()] protected bool m_ERRWelcomePending;
protected ref ERR_SpeechService m_ERRSpeech;
protected string m_ERRWelcomeAttempt;
protected float m_ERRWelcomeWait;
ERR_SpeechService ERR_Speech() { return m_ERRSpeech; }
string ERR_WelcomeVoiceURL() { return m_ERRWelcomeVoiceURL; }
string ERR_WelcomeVoiceText() { return m_ERRWelcomeVoiceText; }
bool ERR_WelcomePending() { return m_ERRWelcomePending; }
void ERR_SetWelcomePending(bool pending) { m_ERRWelcomePending = pending; Replication.BumpMe(); }
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (!Replication.IsServer()) return;
m_ERRSpeech = new ERR_SpeechService(this);
Admin().Register(new ERR_SpeechAdminProvider(m_ERRSpeech));
OnStop().Insert(m_ERRSpeech.Stop);
ERR_RestoreStaticWelcome();
}
void ERR_RestoreStaticWelcome()
{
if (!Replication.IsServer() || !m_ERRSpeech) return;
m_ERRWelcomePending = false;
m_ERRWelcomeVoiceURL = m_ERRSpeech.StaticURL();
m_ERRWelcomeVoiceText = "Welcome to Escape Reintroduced. Work together, stay alert, and find your way off the island. Good luck.";
Replication.BumpMe();
}
void ERR_PublishWelcome(string url, string text)
{
if (!Replication.IsServer() || !ERR_WelcomeVoiceRules.URL(url)) return;
m_ERRWelcomeVoiceURL = url; m_ERRWelcomeVoiceText = text; m_ERRWelcomeAttempt = text;
m_ERRWelcomePending = false; Replication.BumpMe();
}
string ERR_CurrentWelcomeText()
{
// Resolve the actual loaded terrain, never the next-round preference.
string terrain = GetGame().ERR_CurrentWelcomeTerrain();
string current, next;
ChimeraWorld world = ChimeraWorld.CastFrom(GetWorld());
if (world)
{
TimeAndWeatherManagerEntity manager = world.GetTimeAndWeatherManager();
if (manager)
{
WeatherState state = manager.GetCurrentWeatherState();
if (state) current = state.GetStateName();
BaseWeatherStateTransitionManager transition = manager.GetTransitionManager();
if (transition)
{
WeatherState expected = transition.GetNextState();
if (expected) next = expected.GetStateName();
}
}
}
return ERR_WelcomeVoiceRules.Briefing(terrain, current, next);
}
void ERR_UpdateWelcomeBriefing(float seconds)
{
if (!Replication.IsServer() || !m_ERRSpeech) return;
m_ERRSpeech.Tick();
if (!Active() || !m_ERRSpeech.Ready()) return;
m_ERRWelcomeWait -= seconds;
if (m_ERRWelcomeWait > 0) return;
m_ERRWelcomeWait = 60;
string text = ERR_CurrentWelcomeText();
if (text == m_ERRWelcomeAttempt) return;
if (m_ERRSpeech.Request(text, new ERR_WelcomeSpeechReceiver(this))) m_ERRWelcomeAttempt = text;
}
}
// Lives on the game instance, not the world: death, respawn and terrain redirects
// cannot replay the greeting. Returning to the main menu starts a new connection.
class ERR_WelcomeConnection
{
protected bool m_Attempted;
void MainMenu() { m_Attempted = false; }
bool Claim()
{
if (m_Attempted) return false;
m_Attempted = true;
return true;
}
bool Attempted() { return m_Attempted; }
}
class ERR_WelcomeVoiceClient : Managed
{
protected ERR_GameMode m_Mode;
protected string m_Path;
protected string m_Text;
protected string m_Title = "Welcome";
protected ref ERR_WelcomeConnection m_Connection;
protected int m_Serial;
protected bool m_RequirePermit;
protected float m_Wait;
protected bool m_Started;
protected bool m_Stopped;
protected bool m_Downloaded;
protected ref RestCallback m_Callback;
protected ref ERR_WelcomeVoiceDecoder m_Decoder;
protected ref FileHandle m_Output;
protected int m_PreparationBytes;
protected int m_PreparationFrames;
protected int m_PreparationLongest;
protected AudioHandle m_Handle = AudioHandle.Invalid;
void ERR_WelcomeVoiceClient(ERR_GameMode mode, int serial, ERR_WelcomeConnection connection, bool requirePermit = true, string title = "Welcome")
{
m_Mode = mode; m_Connection = connection; m_Serial = serial; m_RequirePermit = requirePermit; m_Title = title;
m_Path = "$profile:ERR_WelcomeVoice_" + serial.ToString() + ".wav";
}
protected bool Current()
{
return !m_Stopped && GetGame() && m_Mode && m_Mode == ERR_GameMode.Get() && m_Mode.Active();
}
protected bool Permit()
{
if (!m_RequirePermit) return true; // Server-requested mission announcement or administrator preview.
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
return controller && controller.ERR_WelcomePermit(m_Serial);
}
bool ForMode(ERR_GameMode mode) { return m_Mode == mode && !m_Stopped; }
bool PreparingDownload() { return m_Decoder != null || m_Downloaded; }
void Tick(float seconds, bool ready, string url, string text)
{
if (m_Stopped) return;
if (m_Downloaded && ready && Current()) { m_Downloaded = false; Play(); }
if (m_Started || m_Connection.Attempted()) return;
if (!ready || !ERR_WelcomeVoiceRules.URL(url) || !ERR_WelcomeVoiceRules.HTTPReady()) { m_Wait = 0; return; }
m_Wait += seconds;
if (m_Wait < 3 || !Permit()) return;
if (!m_Connection.Claim()) return;
m_Started = true; m_Text = text; // One attempt per connection, including failures.
FileIO.DeleteFile(m_Path);
m_Callback = new RestCallback();
m_Callback.SetOnSuccess(Downloaded); m_Callback.SetOnError(Failed);
RestContext context = GetGame().GetRestApi().GetContext(url);
if (!context) { m_Callback = null; Print("Reintroduced [welcome voice]: HTTP unavailable; no retry", LogLevel.WARNING); return; }
context.SetTimeout(20);
context.GET(m_Callback, "");
Print("Reintroduced [welcome voice]: downloading startup clip");
}
protected void Downloaded(RestCallback callback)
{
m_Callback = null;
if (!Current()) { FileIO.DeleteFile(m_Path); return; }
m_Decoder = new ERR_WelcomeVoiceDecoder();
if (!m_Decoder.Begin(callback.GetData()))
{ m_Decoder = null; Print("Reintroduced [welcome voice]: invalid base64 clip", LogLevel.WARNING); return; }
GetGame().ERR_QueueVoiceDownload(this);
}
protected void CancelDownload()
{
if (m_Output) { m_Output.Close(); m_Output = null; }
m_Decoder = null; m_Downloaded = false;
FileIO.DeleteFile(m_Path);
}
// Called for exactly one queued client per game update, shared by greetings,
// announcements and soldier speech. True means remove this job from the queue.
bool PrepareDownload()
{
if (!Current() || !m_Decoder) { CancelDownload(); return true; }
int started = System.GetTickCount();
if (!m_Output) m_Output = FileIO.OpenFile(m_Path, FileMode.WRITE);
if (!m_Output) { CancelDownload(); return true; }
array<int> bytes = {};
int result = m_Decoder.Step(bytes);
if (result < 0)
{ CancelDownload(); Print("Reintroduced [welcome voice]: invalid base64 clip", LogLevel.WARNING); return true; }
if (!bytes.IsEmpty())
{
int written = m_Output.WriteArray(bytes, 1);
if (written != bytes.Count()) { CancelDownload(); return true; }
m_PreparationBytes += written;
}
m_PreparationFrames++;
m_PreparationLongest = Math.Max(m_PreparationLongest, System.GetTickCount() - started);
if (result == 0) return false;
m_Output.Close(); m_Output = null; m_Decoder = null;
FileHandle file = FileIO.OpenFile(m_Path, FileMode.READ);
if (!file) { CancelDownload(); return true; }
int size = file.GetLength();
string signature; file.Read(signature, 4);
file.Seek(8); string format; file.Read(format, 4); file.Close();
if (size < 44 || size > 2097152 || signature != "RIFF" || format != "WAVE")
{ CancelDownload(); Print("Reintroduced [welcome voice]: rejected non-WAV or oversized clip", LogLevel.WARNING); return true; }
m_Downloaded = true; // Recheck loading/menu readiness on the next update.
m_PreparationLongest = Math.Max(m_PreparationLongest, System.GetTickCount() - started);
PrintFormat("Reintroduced [welcome voice]: prepared bytes=%1 frames=%2 longest slice=%3 ms", m_PreparationBytes, m_PreparationFrames, m_PreparationLongest);
return true;
}
protected void Play()
{
m_Handle = AudioSystem.PlaySound(m_Path);
if (m_Handle == AudioHandle.Invalid) Print("Reintroduced [welcome voice]: playback unavailable", LogLevel.WARNING);
else
{
Print("Reintroduced [welcome voice]: playing startup clip");
SCR_HintManagerComponent hints = SCR_HintManagerComponent.GetInstance();
if (hints) hints.ShowCustom(m_Text + "\nSynthetic voice.", m_Title, 25);
}
}
protected void Failed(RestCallback callback)
{
m_Callback = null;
FileIO.DeleteFile(m_Path);
if (!m_Stopped) PrintFormat("Reintroduced [welcome voice]: download failed (HTTP %1); no retry", callback.GetHttpCode());
}
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
if (GetGame()) GetGame().ERR_CancelVoiceDownload(this);
if (m_Handle != AudioHandle.Invalid) AudioSystem.TerminateSound(m_Handle);
m_Handle = AudioHandle.Invalid;
CancelDownload();
m_Mode = null;
}
}
modded class ArmaReforgerScripted
{
protected ref ERR_WelcomeVoiceClient m_ERRWelcomeVoice;
protected int m_ERRWelcomeSerial;
protected ref ERR_WelcomeVoiceClient m_ERRSpeechPreview;
protected ref ERR_WelcomeConnection m_ERRWelcomeConnection = new ERR_WelcomeConnection();
protected ref array<ref ERR_WelcomeVoiceClient> m_ERRVoiceDownloads = {};
void ERR_QueueVoiceDownload(ERR_WelcomeVoiceClient client)
{
if (client && m_ERRVoiceDownloads.Find(client) < 0) m_ERRVoiceDownloads.Insert(client);
}
void ERR_CancelVoiceDownload(ERR_WelcomeVoiceClient client) { m_ERRVoiceDownloads.RemoveItemOrdered(client); }
protected void ERR_PrepareVoiceDownloads()
{
if (m_ERRVoiceDownloads.IsEmpty()) return;
ERR_WelcomeVoiceClient client = m_ERRVoiceDownloads[0];
m_ERRVoiceDownloads.RemoveOrdered(0);
if (!client.PrepareDownload()) m_ERRVoiceDownloads.Insert(client);
}
protected void ERR_StopVoiceDownloads()
{
while (!m_ERRVoiceDownloads.IsEmpty())
{
ERR_WelcomeVoiceClient client = m_ERRVoiceDownloads[0];
m_ERRVoiceDownloads.RemoveOrdered(0); client.Stop();
}
}
void ERR_PlaySpeechPreview(string url, string text, string title = "Speech test")
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !ERR_WelcomeVoiceRules.URL(url) || !ERR_SpeechRules.Text(text)) return;
if (m_ERRSpeechPreview) m_ERRSpeechPreview.Stop();
m_ERRSpeechPreview = new ERR_WelcomeVoiceClient(mode, ++m_ERRWelcomeSerial, new ERR_WelcomeConnection(), false, title);
// Mission announcements and admin previews do not reset the welcome.
m_ERRSpeechPreview.Tick(3, true, url, text);
}
string ERR_CurrentWelcomeTerrain()
{
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetMissionHeader());
if (header) { ERR_TerrainMissionTitle(header); return m_ERRTerrainLabel; }
return ERR_TerrainDisplayName.Label(GetWorldFile());
}
override void OnUpdate(BaseWorld world, float timeslice)
{
super.OnUpdate(world, timeslice);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && Replication.IsServer()) mode.ERR_UpdateWelcomeBriefing(timeslice);
if (System.IsConsoleApp()) return;
if (m_bIsMainMenuOpen)
{
ERR_StopVoiceDownloads();
if (m_ERRWelcomeVoice) m_ERRWelcomeVoice.Stop();
if (m_ERRSpeechPreview) m_ERRSpeechPreview.Stop();
m_ERRSpeechPreview = null;
m_ERRWelcomeVoice = null; m_ERRWelcomeConnection.MainMenu(); return;
}
if (m_ERRSpeechPreview)
{
if (!mode || !m_ERRSpeechPreview.ForMode(mode)) { m_ERRSpeechPreview.Stop(); m_ERRSpeechPreview = null; }
else
{
SCR_RespawnSystemComponent previewRespawn = SCR_RespawnSystemComponent.GetInstance();
bool previewReady = !ArmaReforgerLoadingAnim.IsOpen() && (!previewRespawn || previewRespawn.ERR_WelcomeAudioReady());
m_ERRSpeechPreview.Tick(timeslice, previewReady, "", "");
}
}
ERR_PrepareVoiceDownloads();
if (!mode) return;
if (m_ERRWelcomeVoice && !m_ERRWelcomeVoice.ForMode(mode)) { m_ERRWelcomeVoice.Stop(); m_ERRWelcomeVoice = null; }
if (!m_ERRWelcomeVoice) m_ERRWelcomeVoice = new ERR_WelcomeVoiceClient(mode, ++m_ERRWelcomeSerial, m_ERRWelcomeConnection);
bool menu = GetMenuManager() && GetMenuManager().GetTopMenu();
menu = menu || ArmaReforgerLoadingAnim.IsOpen();
SCR_RespawnSystemComponent respawn = SCR_RespawnSystemComponent.GetInstance();
if (respawn && !respawn.ERR_WelcomeAudioReady()) menu = true;
bool ready = ERR_WelcomeVoiceRules.Ready(false, mode.Active(), GetPlayerController() && GetPlayerController().GetControlledEntity() != null, menu);
m_ERRWelcomeVoice.Tick(timeslice, ready && !mode.ERR_WelcomePending(), mode.ERR_WelcomeVoiceURL(), mode.ERR_WelcomeVoiceText());
}
override protected void OnBeforeWorldCleanup()
{
ERR_StopVoiceDownloads();
if (m_ERRWelcomeVoice) m_ERRWelcomeVoice.Stop();
if (m_ERRSpeechPreview) m_ERRSpeechPreview.Stop();
m_ERRSpeechPreview = null; m_ERRWelcomeVoice = null;
super.OnBeforeWorldCleanup();
}
}
@@ -33,6 +33,12 @@ class ERR_AirAssaultTeam
class ERR_AirDropGeometry
{
static string LastFailure;
// Once a fresh radio order is accepted, give the physical approach a bounded
// travel budget. A large island may require several minutes of flying.
static float ApproachBudget(float distance, float descent)
{
return Math.Clamp(Math.Max(distance / 10, Math.Max(0, descent) / 2) + 90, 120, 600) * 1000;
}
static float Along(vector position, vector center, vector direction)
{ vector offset = position - center;
offset[1] = 0;
@@ -77,7 +83,8 @@ class ERR_AirAssaultLoad
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;
protected float m_CoverUntil, m_ApproachUntil;
protected bool m_HasDropped;
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;
@@ -95,6 +102,7 @@ class ERR_AirAssaultLoad
void ReuseRemaining()
{
m_Used = false;
m_HasDropped = false;
m_Order = null;
m_CoverUntil = 0;
foreach (ERR_AirPassenger member : m_Passengers)
@@ -282,7 +290,11 @@ class ERR_AirAssaultLoad
if (!found) return false;
m_RunStart = m_Center - m_Direction * 350;
m_RunEnd = m_Center + m_Direction * 450;
m_CoverUntil = now + 300000;
m_CoverUntil = 0;
m_HasDropped = false;
vector aircraftPosition = m_Aircraft.GetOrigin();
m_ApproachUntil = now + ERR_AirDropGeometry.ApproachBudget(vector.DistanceXZ(aircraftPosition, m_RunStart), aircraftPosition[1] - m_Center[1] - 120);
ERR_HQ.Event("AIR DROP APPROACH", string.Format("distance=%1m budget=%2s; original passengers retained", vector.DistanceXZ(aircraftPosition, m_RunStart), (m_ApproachUntil - now) / 1000));
m_Order = order;
m_Used = true;
m_Approaching = true;
@@ -290,7 +302,7 @@ class ERR_AirAssaultLoad
m_NextPassenger = 0;
m_NextJump = now;
foreach (ERR_AirAssaultTeam team : m_Teams) team.Order = order;
m_AirspaceUntil = order.ExpiresAt + 150000;
m_AirspaceUntil = m_ApproachUntil + 150000;
return true;
}
bool Approach(out vector point)
@@ -300,9 +312,15 @@ class ERR_AirAssaultLoad
return m_Approaching && !m_Stopped;
}
void Abort()
{ if (m_Approaching && m_Order) m_CoverUntil = GetGame().GetWorld().GetWorldTime() + 300000;
{
m_Approaching = false;
m_RunCommitted = false;
// An approach with no jump is not a completed drop. The same original
// passengers can accept a later order; no fictitious landed-team cover.
if (!m_HasDropped)
{
m_Used = false; m_Order = null; m_CoverUntil = 0; m_AirspaceUntil = 0;
}
}
bool BlocksMortar(vector impact, float now)
{
@@ -319,9 +337,9 @@ class ERR_AirAssaultLoad
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)
if (!m_Flight || m_Flight.Lost() || m_Flight.Phase() >= ERR_ReconPhase.RETURNING || !ERR_Actors.Alive(m_Aircraft) || now >= m_ApproachUntil)
{ Abort();
ERR_HQ.Event("AIR DROP ABORTED", "authorization expired or aircraft unavailable; no new passengers created");
ERR_HQ.Event("AIR DROP ABORTED", "approach deadline reached or aircraft unavailable; no new passengers created");
return;
}
foreach (ERR_AirAssaultTeam originalTeam : m_Teams)
@@ -331,6 +349,7 @@ class ERR_AirAssaultLoad
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();
ERR_HQ.Event("AIR DROP ABORTED", "drop run passed the safe corridor; unjumped original passengers retained");
return;
}
float height = position[1] - SCR_TerrainHelper.GetTerrainY(position);
@@ -351,6 +370,7 @@ class ERR_AirAssaultLoad
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);
if (passenger.Jumped) { m_HasDropped = true; m_CoverUntil = now + 300000; }
ERR_HQ.Event("AIR DROP JUMP", string.Format("original passenger=%1 transfer requested=%2", index, passenger.Jumped));
break;
}
@@ -38,7 +38,7 @@ class ERR_AirReconSystem
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);
m_Schedule.Change(m_Mode.Settings().Runtime("heliTimerMinutes"), GetGame().GetWorld().GetWorldTime(), m_Current != null);
if (!m_Current) m_PadAttempts = 0;
ERR_HQ.Event("HELICOPTER TIMER", Status());
return true;
@@ -28,9 +28,6 @@ class ERR_AirSupportPolicy
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 (checkAirspace && s.ArtilleryBusy) return "mortar order or shells occupy the approach area";
return "";
}
@@ -38,7 +35,7 @@ class ERR_AirSupportPolicy
{
if (!ERR_HQMemoryRules.Finite(now) || now < 0) return false;
// Artillery temporarily closes the approach; it does not invalidate fresh
// contact history or the confirmed lack of ground support. Restarting the
// contact history. Restarting the
// timer on every salvo could prevent a drop through every clear interval.
string refusal = Refusal(s, false);
if (!refusal.IsEmpty()) { Candidate = 0;
@@ -55,15 +52,18 @@ class ERR_AirSupportPolicy
if (s.ArtilleryBusy) { Reason = Refusal(s);
return false;
}
float delay = 45000;
if (s.ConfirmedLosses >= 2) delay = 20000;
if (now - ConsideringSince < delay) { Reason = "confirming persistent contact and ground-force shortfall";
// Ground hunters complement airborne troops rather than vetoing all drops.
int needed = Math.Clamp(s.Spotted * 2, 4, 12);
float delay = 20000;
if (s.GroundFit + s.CommittedFit >= needed) delay = 45000;
if (s.ConfirmedLosses >= 2) delay = 10000;
if (now - ConsideringSince < delay) { Reason = "confirming contact before airborne reinforcement";
return false;
}
Reason = "persistent infantry contact exceeds available ground response";
Reason = "persistent infantry contact; reinforcing the ground hunt";
return true;
}
void Committed(float now) { NextDrop = now + 480000;
void Committed(float now) { NextDrop = now + 240000;
Candidate = 0;
ConsideringSince = -1;
}

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