Improve AI garrisons, player recovery and mission administration

This commit is contained in:
2026-09-24 22:12:45 +02:00
parent 1b1a8b5173
commit 2551d171b3
115 changed files with 3622 additions and 547 deletions
@@ -8,12 +8,12 @@ class ERR_AdminMenuFields
{
static array<string> Keys()
{
return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount"};
return {"radioDefenceSeconds", "aiIntelChancePercent", "intelRevealChancePercent", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "convoyCount", "antiAirChancePercent", "antiPersonnelMineChancePercent"};
}
static bool Available(string key, bool hq, bool intel)
{
if (key == "aiIntelChancePercent") return intel;
if (key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres") return hq;
if (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.
@@ -23,10 +23,9 @@ class ERR_AdminMenuFields
}
// TextWidget interprets hash-prefixed tokens as localization IDs. Keep the
// server audit and retained entries intact; render their numeric IDs literally.
static string JournalText(string line)
static string JournalText(string line, bool details = false)
{
line.Replace("#", "No. ");
return line;
return ERR_JournalText.Menu(line, details);
}
static string Display(string key, string value)
{
@@ -49,6 +48,8 @@ class ERR_AdminMenuFields
else if (key == "reportIntervalSeconds") prefix = "hq interval";
else if (key == "supportSquadLimit") prefix = "hq squads";
else if (key == "responseRadiusMetres") prefix = "hq radius";
else if (key == "antiAirChancePercent") prefix = "hq aa";
else if (key == "antiPersonnelMineChancePercent") prefix = "hq mines";
else if (key == "convoyCount") prefix = "hq convoy count";
value = value.Trim();
if (prefix.IsEmpty() || value.IsEmpty() || value.Length() > 32) return "";
@@ -66,6 +67,7 @@ class ERR_AdminMenu : ChimeraMenuBase
protected ref array<string> m_AvailableFields = {};
protected ref array<string> m_LogLines = {};
protected bool m_Bound;
protected bool m_LogDetails;
protected bool m_Ready;
protected bool m_HQ;
protected bool m_Intel;
@@ -98,7 +100,7 @@ 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", "LatestJournal", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus"};
array<string> names = {"Close", "OpenSettings", "OpenWorld", "OpenFactions", "OpenHQ", "OpenJournal", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "IntelSummary", "RerollIntel", "OpenMods", "OpenIntel", "OpenStatus"};
names.Insert("RandomDay_startTimeMinutes");
foreach (string key : ERR_AdminMenuFields.Keys())
{
@@ -221,6 +223,7 @@ class ERR_AdminMenu : ChimeraMenuBase
}
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);
}
}
void ReceiveSnapshot(bool authorized, array<string> values, array<string> status)
@@ -241,6 +244,13 @@ class ERR_AdminMenu : ChimeraMenuBase
if (ERR_AdminMenuFields.Keys().Contains(resolvedKey)) SetText("Saved_" + resolvedKey, "Saved: Random (using " + entry.Substring(divider + 1, entry.Length() - divider - 1) + ")");
continue;
}
if (key == "keepWeaponOnUnconscious")
{
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));
m_AvailableFields.Insert(key);
@@ -262,7 +272,7 @@ class ERR_AdminMenu : ChimeraMenuBase
}
if (summary.IsEmpty()) summary = "No mission status is available yet.";
SetText("Status", summary);
string connection = "Connected. Saved values update live; your drafts are kept until this menu closes.";
string connection = "Connected to server. Editing a value does not save it: press Apply. 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();
@@ -293,17 +303,21 @@ class ERR_AdminMenu : ChimeraMenuBase
m_LogLines.Insert(line);
}
m_LogCursor = cursor;
string content;
foreach (string entry : m_LogLines)
{
if (!content.IsEmpty()) content += "\n";
content += ERR_AdminMenuFields.JournalText(entry);
}
if (content.IsEmpty()) content = "Waiting for the first HQ event...";
SetText("JournalText", content);
RenderJournal();
m_ScrollToEnd = follow;
}
SetText("JournalState", "Live. Up to 300 received entries are retained here. Pause to read older entries.");
SetText("JournalState", "Live. Times use the server clock (HH:MM:SS). Up to 300 entries; pause to read older ones.");
}
protected void RenderJournal()
{
string content;
foreach (string entry : m_LogLines)
{
if (!content.IsEmpty()) content += "\n\n";
content += ERR_AdminMenuFields.JournalText(entry, m_LogDetails);
}
if (content.IsEmpty()) content = "Waiting for the first HQ event...";
SetText("JournalText", content);
}
protected void OnButton(SCR_ButtonBaseComponent button)
{
@@ -312,6 +326,17 @@ class ERR_AdminMenu : ChimeraMenuBase
if (name == "Close") { Close(); return; }
int page = Navigation().Find(name);
if (page >= 0) { ShowPage(page); return; }
if (name == "JournalDetails")
{
m_LogDetails = !m_LogDetails;
SCR_ButtonTextComponent detailsButton = SCR_ButtonTextComponent.Cast(button);
if (detailsButton)
{
if (m_LogDetails) detailsButton.SetText("Show readable summaries");
else detailsButton.SetText("Show technical details");
}
RenderJournal(); return;
}
if (name == "PauseJournal")
{
m_LogPaused = !m_LogPaused;
@@ -324,11 +349,18 @@ class ERR_AdminMenu : ChimeraMenuBase
else
{
if (label) label.SetText("Pause live");
SetText("JournalState", "Live - catching up..."); m_LogPoll = 1;
SetText("JournalState", "Live - catching up. Times use the server clock (HH:MM:SS)."); m_LogPoll = 1;
}
return;
}
if (!m_Controller || !m_Ready || m_Age > 6 || m_ClickDelay > 0) return;
if (name == "KeepWeaponOn" || name == "KeepWeaponOff")
{
string retain = "0"; if (name == "KeepWeaponOn") retain = "1";
m_ClickDelay = 0.3;
SetText("Feedback", "Saving weapon retention for future knockouts...");
m_Controller.ERR_AdminSubmit("mission keepweapon set " + retain, true); return;
}
if (name == "LatestJournal")
{
if (!m_HQ || m_LogPending) return;
@@ -4,6 +4,7 @@ class ERR_CommandArguments
{
static string CoreSetting(string section, string option)
{
if (section == "mission" && option == "keepweapon") return "keepWeaponOnUnconscious";
if (section == "mission" && option == "radio") return "radioDefenceSeconds";
if (section == "intel" && option == "reveal") return "intelRevealChancePercent";
return "";
@@ -106,6 +107,9 @@ class ERR_AdminService
}
protected string SettingHelp(string key)
{
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.";
if (key == "intelRevealChancePercent")
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 "";
@@ -11,6 +11,8 @@ class ERR_ContentOption
string NativeFactionKey;
[Attribute("", UIWidgets.ResourceNamePicker, "Physical guard group belonging to this faction", "et")]
ResourceName GuardGroup;
[Attribute("", UIWidgets.ResourceNamePicker, "Optional faction MANPADS override; must spawn loaded and usable. Otherwise FIM-92 is used.", "et")]
ResourceName AntiAirLauncher;
[Attribute("", UIWidgets.EditBox, "Exact resource GUIDs for optional equipment owned by this pack")]
ref array<string> EquipmentGuids;
}
@@ -23,7 +23,7 @@ class ERR_ContentSettings
else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak"))
{
m_ReadOnly = true;
Print("Escapists: invalid content preferences; authored defaults active, existing files preserved", LogLevel.ERROR);
Print("Co10 Escape Reintroduced: invalid content preferences; authored defaults active, existing files preserved", LogLevel.ERROR);
}
}
bool Enabled(string id) { return m_Values.Contains(id) && m_Values.Get(id) == 1; }
@@ -21,7 +21,7 @@ class ERR_DiscoveryMarker : SCR_MapMarkerBase
if (kind == ERR_LocationKind.RADIO) return "radio";
if (kind == ERR_LocationKind.MORTAR) return "artilleryFireMission";
if (kind == ERR_LocationKind.OUTPOST) return "building";
if (id.Contains("medical")) return "medical";
if (kind == ERR_LocationKind.HOSPITAL || id.Contains("medical")) return "medical";
return "supplies";
}
void SetLocationIcon(ERR_LocationKind kind, string id, int knowledge = 2)
@@ -80,9 +80,9 @@ modded class ERR_AdminMenu
if (name == "FactionsRandomAll")
{
foreach (string role : ERR_FactionChoices.Roles()) SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget()).SetCurrentItem(0);
return;
}
if (name != "FactionsApply") return;
// Random all saves the mode immediately; resolving a round never replaces it.
if (name != "FactionsApply" && name != "FactionsRandomAll") return;
string command = "factions set";
foreach (string role : ERR_FactionChoices.Roles())
{
@@ -97,7 +97,7 @@ class ERR_FactionSelection
map<string, string> loaded;
if (Read(m_Path, loaded) || Read(m_Path + ".bak", loaded)) m_Saved = loaded;
else if (FileIO.FileExists(m_Path) || FileIO.FileExists(m_Path + ".bak"))
{ m_ReadOnly = true; Print("Escapists: invalid faction selections; defaults active and files preserved", LogLevel.ERROR); }
{ 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)
{
@@ -192,7 +192,7 @@ class ERR_FactionSelection
if (!player.IsPlayable()) return false;
m_Ready = true;
mode.PublishFactionRoles(Choice("escapists").Key, Choice("enemy").Key);
PrintFormat("Escapists factions: players=%1 enemy=%2 independent=%3", Choice("escapists").Key, Choice("enemy").Key, Choice("independent").Key);
PrintFormat("Co10 Escape Reintroduced factions: players=%1 enemy=%2 independent=%3", Choice("escapists").Key, Choice("enemy").Key, Choice("independent").Key);
return true;
}
void Snapshot(ERR_ContentSettings content, array<string> values)
@@ -343,6 +343,8 @@ class ERR_GameMode : SCR_BaseGameMode
{
if (!Replication.IsServer() || !actor || ERR_MissionRules.Terminal(m_Phase)) return;
m_Roster.Spawned(id, actor);
SCR_CharacterDamageManagerComponent medical = SCR_CharacterDamageManagerComponent.Cast(actor.FindComponent(SCR_CharacterDamageManagerComponent));
if (medical) medical.ERR_EnableCasualtyProtection();
m_StartedPlayers = true;
m_NoSurvivorsSince = -1;
}
@@ -493,6 +495,13 @@ class ERR_GameMode : SCR_BaseGameMode
if (RefreshRadioFeedback()) Replication.BumpMe();
m_Recovery.Tick();
if (!Active()) return;
if (m_Recovery.DefeatDue(now))
{
m_EndReason = "All prisoners are unconscious and no treatment or recovery is progressing.";
ERR_HQ.Event("MISSION FAILED", m_EndReason);
Transition(ERR_Phase.DEFEAT);
return;
}
if (m_Command) m_Command.Maintain(now);
// Maintenance can finish or stop the round; do not resume tactical work.
if (!Active()) return;
@@ -71,6 +71,7 @@ class ERR_CampTerrain
if (!Clear(position, heading, 10, 0.6, baseHeight)) return false;
float guardHeight;
if (site.Guarded && !Clear(position + Rotate(site.GuardOffset, heading), heading, 6, 0.6, guardHeight)) return false;
if (site.ResponseCount > 0 && !Clear(position + Rotate(site.ResponseOffset, heading), heading, 6, 0.6, guardHeight)) return false;
return true;
}
}
@@ -93,6 +94,8 @@ class ERR_GeneratedSites
foreach (ERR_SiteDefinition prison : definition.Sites) if (prison.RandomClear && prison.Kind == ERR_LocationKind.PRISON) m_Pending.Insert(prison);
foreach (ERR_SiteDefinition radio : definition.Sites) if (radio.RandomClear && radio.Kind == ERR_LocationKind.RADIO) m_Pending.Insert(radio);
foreach (ERR_SiteDefinition mortar : definition.Sites) if (mortar.RandomClear && mortar.Kind == ERR_LocationKind.MORTAR) m_Pending.Insert(mortar);
foreach (ERR_SiteDefinition optional : definition.Sites)
if (optional.RandomClear && (optional.Kind == ERR_LocationKind.HOSPITAL || optional.Kind == ERR_LocationKind.OUTPOST || optional.Kind == ERR_LocationKind.DEPOT)) m_Pending.Insert(optional);
GetGame().GetWorldEntity().GetWorldBounds(m_Low, m_High);
if (!m_Pending.IsEmpty()) m_Terrain.Collect(m_Low, m_High);
m_Began = System.GetTickCount();
@@ -103,8 +106,12 @@ class ERR_GeneratedSites
foreach (ERR_SiteDefinition other : m_Definition.Sites)
{
if (other == candidate) continue;
// Unresolved random sites have no occupied footprint yet. Later placements
// compare against all committed positions, not their unused fallback seeds.
if (other.RandomClear && !other.PlacementResolved) continue;
if (candidate.Kind == ERR_LocationKind.PRISON && other.Kind == ERR_LocationKind.RADIO && other.RandomClear) continue;
float minimum = 300;
if (candidate.Kind == ERR_LocationKind.HOSPITAL || candidate.Kind == ERR_LocationKind.OUTPOST || candidate.Kind == ERR_LocationKind.DEPOT) minimum = 600;
if (candidate.Kind == ERR_LocationKind.MORTAR && other.Kind == ERR_LocationKind.MORTAR) minimum = 1000;
if (candidate.Kind == ERR_LocationKind.MORTAR && other.Kind == ERR_LocationKind.PRISON) minimum = 500;
if (candidate.Kind == ERR_LocationKind.RADIO && other.Kind == ERR_LocationKind.PRISON) minimum = 1000;
@@ -133,8 +140,8 @@ class ERR_GeneratedSites
{
position[1] = floor + 0.02;
site.Position = position; site.Heading = heading; site.PlacementResolved = true;
PrintFormat("Escapists [generated site]: id=%1 position=%2 heading=%3 attempts=%4 fallback=%5", site.Id, position, heading, m_Attempts, fallback);
m_Index++; m_Attempts = 0;
PrintFormat("Co10 Escape Reintroduced [generated site]: id=%1 position=%2 heading=%3 attempts=%4 fallback=%5", site.Id, position, heading, m_Attempts, fallback);
m_Index++; m_Attempts = 0; m_Began = System.GetTickCount();
}
else if (fallback)
{
@@ -0,0 +1,113 @@
// Presentation only. Original records stay available in the menu and log file.
class ERR_JournalText
{
static string Field(string message, string key)
{
int start = message.IndexOf(key + "=");
if (start < 0) return "unknown";
start += key.Length() + 1;
int end = start;
if (start < message.Length() && message.Substring(start, 1) == "<")
{
while (end < message.Length() && message.Substring(end, 1) != ">") end++;
if (end < message.Length()) end++;
}
else while (end < message.Length() && message.Substring(end, 1) != " " && message.Substring(end, 1) != ";") end++;
if (end == start) return "unknown";
return message.Substring(start, end - start);
}
static string Short(string text)
{
int address = text.IndexOf("<0x");
if (address >= 0) text = text.Substring(0, address) + " [entity details available]";
int resource = text.IndexOf("{");
if (resource >= 0) text = text.Substring(0, resource);
if (text.Length() > 190) text = text.Substring(0, 187) + "...";
text.Replace("\n", " ");
return text;
}
static string Subject(string message)
{
int end = message.IndexOf(" ");
if (end < 0) return Short(message);
string result = message.Substring(0, end);
result.Replace("aggression-uaz-", "UAZ patrol ");
return result;
}
static string YesNo(string value)
{
if (value == "1" || value == "true") return "yes";
if (value == "0" || value == "false") return "no";
return "unknown";
}
static string Description(string kind, string message)
{
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 == "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") + ".";
if (kind == "HUNTER OPENING") return "Three starting UAZ patrol positions found on clear roads, at least 90 degrees apart around the players.";
if (kind == "HUNTER OPENING FAILED") return "Could not find three safe, widely separated road positions. Starting UAZ spawns were withheld.";
if (kind == "HUNTER WAIT") return "UAZ deployment delayed: " + Short(message);
if (kind == "HUNTER FAILED") return "UAZ deployment failed. This deployment is used up; no replacement is spawned. " + Short(message);
if (kind == "HUNTER RELEASE") return "UAZ driving task ended: " + Short(message);
if (kind == "REPORT") return "Sighting report queued for radio transmission: " + Short(message);
if (kind == "BODY REPORT") return "Body report queued for radio transmission: " + Short(message);
if (kind == "STATUS REPORT") return "Squad status report queued: " + Short(message);
if (kind == "CALL") return "A soldier is making a radio report; transmission starts after three uninterrupted seconds.";
if (kind == "CALL COMPLETE") return "The soldier finished making the report. Radio delivery can now proceed.";
if (kind == "TRANSMIT") return "Radio message in transit: " + Short(message);
if (kind == "RECEIVE") return "Radio message received: " + Short(message);
if (kind == "LOST") return "Radio delivery failed: " + Short(message);
if (kind == "BELIEF") return "HQ received and retained a report. Orders can use this reported information.";
if (kind == "ORDER QUEUED") return "HQ order queued; units have not received it yet. " + Short(message);
if (kind == "ORDER DELIVERED") return "HQ order delivered: " + Short(message);
if (kind == "ORDER FAILED") return "HQ order could not be carried out: " + Short(message);
if (kind == "CHECK-IN OVERDUE") return "A squad missed its expected check-in. This does not confirm that its soldiers are dead. " + Short(message);
if (kind == "AA PROFILE") return "Anti-air equipment selected for faction " + Field(message, "faction") + "; FIM-92 is the fallback.";
if (kind == "AA FALLBACK") return "The faction anti-air launcher could not be used. Trying FIM-92 instead.";
if (kind == "AA EQUIPMENT")
{
if (message.Contains("physically equipped")) return "A soldier has a loaded anti-air launcher. Dropping or firing it will not grant a replacement.";
if (message.Contains("quarantined")) return "Anti-air preparation timed out. An unresolved grant is held to prevent duplicate weapons.";
if (message.Contains("looted")) return "The issued anti-air launcher is no longer with its soldier; no replacement was granted.";
if (message.Contains("FIM-92")) return "The FIM-92 fallback could not be confirmed usable. Check technical details for the cause.";
return "Anti-air preparation stopped before a usable weapon could be confirmed. Check technical details.";
}
if (kind == "MINE EQUIPMENT")
{
if (message.Contains("two-mine allowance")) return "A soldier received carried AP mines (up to two). Used or looted mines are not replaced.";
if (message.Contains("capacity")) return "No inventory space for AP mines; no loose mines were spawned.";
if (message.Contains("deadline")) return "AP-mine preparation timed out; any pending grant will not be duplicated.";
return "AP mines could not be added to a soldier's inventory. No loose mines were spawned.";
}
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 == "INTEL COMPROMISE") return "HQ learned that intel is missing: " + Short(message);
if (kind == "OBJECTIVE DEFENCE") return "Objective security: " + Short(message);
if (kind == "HELICOPTER LAUNCH") return "Search helicopter launched: " + Short(message);
if (kind == "HELICOPTER RETURN") return "Helicopter returning to base: " + Short(message);
if (kind == "AIR DROP AUTHORIZED") return "HQ approved a paratrooper drop: " + Short(message);
if (kind == "AIR DROP WITHHELD") return "HQ withheld the paratrooper drop: " + Short(message);
kind.ToLower(); kind.Replace("_", " ");
return kind + ": " + Short(message);
}
static string Entry(int sequence, string clock, string kind, string message, int messageId)
{
string line = "[" + clock + "] " + Description(kind, message);
line += " (event " + sequence.ToString();
if (messageId > 0) line += ", message " + messageId.ToString();
return line + ")";
}
static string Menu(string line, bool details)
{
int split = line.IndexOf("\n#");
if (split >= 0 && !details) line = line.Substring(0, split);
line.Replace("#", "No. ");
return line;
}
}
@@ -1,4 +1,4 @@
enum ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT, MORTAR }
enum ERR_LocationKind { PRISON, RADIO, EXTRACTION, OUTPOST, DEPOT, MORTAR, HOSPITAL }
[ComponentEditorProps(category: "Reintroduced", description: "Server-owned location with replicated discovery state")]
class ERR_LocationComponentClass : ScriptComponentClass {}
@@ -88,6 +88,16 @@ modded class ERR_AdminMenu
}
return true;
}
bool ERR_ProbeFactionSavedRandom()
{
if (!m_FactionFieldsInitialized) return false;
foreach (string role : ERR_FactionChoices.Roles())
{
SCR_ComboBoxComponent combo = SCR_ComboBoxComponent.GetComboBoxComponent("FactionSelect_" + role, GetRootWidget());
if (!combo || combo.GetCurrentIndex() != 0 || m_FactionSaved.Get(role) != "random") return false;
}
return true;
}
void ERR_ProbeFactionApply() { OnFactionButton(SCR_ButtonTextComponent.GetButtonText("FactionsApply", GetRootWidget())); }
bool ERR_ProbeHQ() { return m_HQ; }
bool ERR_ProbeIntel() { return m_Intel; }
@@ -115,6 +125,35 @@ modded class ERR_AdminMenu
OnModButton(SCR_ButtonTextComponent.GetButtonText(name, row));
}
int ERR_ProbeLogCursor() { return m_LogCursor; }
bool ERR_ProbeJournalDetails()
{
TextWidget text = TextWidget.Cast(GetRootWidget().FindAnyWidget("JournalText"));
SCR_ButtonTextComponent button = SCR_ButtonTextComponent.GetButtonText("JournalDetails", GetRootWidget());
if (!text || !button || m_LogDetails || text.GetText().Contains("[session=")) return false;
string summary = text.GetText(); int cursor = m_LogCursor; int entries = m_LogLines.Count();
OnButton(button);
if (!m_LogDetails || !text.GetText().Contains("[session=") || !text.GetText().Contains("No. ")) return false;
OnButton(button);
return !m_LogDetails && text.GetText() == summary && cursor == m_LogCursor && entries == m_LogLines.Count();
}
bool ERR_ProbeDescriptions()
{
foreach (string key : ERR_AdminMenuFields.Keys())
{
ERR_ProbeFieldPage(key);
TextWidget help = TextWidget.Cast(GetRootWidget().FindAnyWidget("Help_" + key));
if (!help || help.GetText().IsEmpty()) return false;
GetRootWidget().Update();
float width, height, textWidth, textHeight;
help.GetScreenSize(width, height); help.GetTextSize(textWidth, textHeight);
// GetTextSize uses layout units; screen bounds include the workspace DPI scale.
width = GetGame().GetWorkspace().DPIUnscale(width);
height = GetGame().GetWorkspace().DPIUnscale(height);
PrintFormat("ERR ADMIN HELP BOUNDS: key=%1 widget=%2,%3 text=%4,%5", key, width, height, textWidth, textHeight);
if (textWidth > width + 2 || textHeight > height + 2) { Print("ERR ADMIN HELP OVERFLOW: " + key); return false; }
}
ERR_ProbePage(0); return true;
}
bool ERR_ProbeJournalVisible()
{
TextWidget text = TextWidget.Cast(GetRootWidget().FindAnyWidget("JournalText"));
@@ -129,7 +168,7 @@ modded class ERR_AdminMenu
scroll.GetSliderPos(sliderX, sliderY);
Print(string.Format("ERR ADMIN JOURNAL FOLLOW: slider=%1,%2 overflow=%3", sliderX, sliderY, height - viewHeight));
if (height > viewHeight && sliderY < 0.98) return false;
return m_Page == 2 && text.GetText().Contains("No. ") && width > viewWidth * 0.5 && height > 2 && x < viewX + viewWidth && x + width > viewX && y < viewY + viewHeight && y + height > viewY;
return m_Page == 2 && text.GetText().Contains("(event ") && width > viewWidth * 0.5 && height > 2 && x < viewX + viewWidth && x + width > viewX && y < viewY + viewHeight && y + height > viewY;
}
}
@@ -175,7 +214,7 @@ class ERR_NativeAdminMenuProbe
m_Deadline = now + 90000;
m_RhsSaved = m_Mode.ContentSettings().Enabled("rhs");
GetGame().OpenPauseMenu(false, true);
if (!PauseMenuUI.ERR_ProbeModsEntry()) return Fail("Esc menu missing Escapists Reintroduced entry");
if (!PauseMenuUI.ERR_ProbeModsEntry()) return Fail("Esc menu missing Co10 Escape Reintroduced entry");
PauseMenuUI.ERR_ProbeOpenMods();
Pass("native Esc menu entry opens mission administration");
m_Stage = 1; return 0;
@@ -187,6 +226,8 @@ class ERR_NativeAdminMenuProbe
array<string> capabilities = {}; m_Mode.Admin().Capabilities(capabilities);
if (menu.ERR_ProbeHQ() != capabilities.Contains("supports=hq") || menu.ERR_ProbeIntel() != capabilities.Contains("supports=intel")) return Fail("capability snapshot mismatch");
Pass("real owner RPC opens menu and returns current settings/capabilities");
if (!menu.ERR_ProbeDescriptions()) return Fail("setting descriptions missing or clipped");
Pass("all thirteen setting descriptions fit their native text widgets");
System.MakeScreenshot("$logs:err-admin-open");
if (!menu.ERR_ProbeNavigation(0)) return Fail("Esc entry did not open Mission page with shared navigation");
menu.ERR_ProbePage(3);
@@ -259,21 +300,35 @@ class ERR_NativeAdminMenuProbe
m_FactionActive.Insert(m_Mode.FactionSelection().Active(role));
}
if (!menu.ERR_ProbeFactionRandomAll()) return Fail("faction selectors or Random all failed");
Pass("three faction selectors and Random all create distinct-role drafts");
Pass("Random all submits persistent random mode for all three roles");
m_Wait = now + 3000; m_Stage = 20; return 0;
}
if (m_Stage == 20)
{
if (!menu || !menu.ERR_ProbeFactionRandomAll()) return Fail("snapshot lost faction drafts");
menu.ERR_ProbeFactionApply();
m_Wait = now + 1500; m_Stage = 21; return 0;
if (!menu || !menu.ERR_ProbeFactionSavedRandom()) return Fail("Random all did not save without Apply");
ERR_FactionSelection reloaded = new ERR_FactionSelection();
foreach (string savedRole : ERR_FactionChoices.Roles())
if (reloaded.Saved(savedRole) != "random") return Fail("Random mode did not survive settings reload");
menu.Close(); m_Wait = now + 500; m_Stage = 23; return 0;
}
if (m_Stage == 23)
{
ERR_AdminMenu.Open(); m_Wait = now + 1500; m_Stage = 24; return 0;
}
if (m_Stage == 24)
{
if (!menu || !menu.ERR_ProbeReady()) return 0;
menu.ERR_ProbePage(4);
if (!menu.ERR_ProbeFactionSavedRandom()) return Fail("Random mode lost after reopening menu");
Pass("Random all survives settings reload, fresh snapshots and menu reopen without Apply");
m_Stage = 21; return 0;
}
if (m_Stage == 21)
{
array<string> roles = ERR_FactionChoices.Roles();
for (int i = 0; i < roles.Count(); i++)
if (m_Mode.FactionSelection().Saved(roles[i]) != "random" || m_Mode.FactionSelection().Active(roles[i]) != m_FactionActive[i]) return Fail("faction Apply failed or changed current round");
Pass("faction Apply persists all three through authenticated owner RPC without changing active round");
if (m_Mode.FactionSelection().Saved(roles[i]) != "random" || m_Mode.FactionSelection().Active(roles[i]) != m_FactionActive[i]) return Fail("Random all save failed or changed current round");
Pass("Random all persists through authenticated owner RPC without changing active round");
m_Controller.ERR_AdminSubmit("factions set " + m_FactionSaved[0] + " " + m_FactionSaved[1] + " " + m_FactionSaved[2], true);
m_Wait = now + 1500; m_Stage = 22; return 0;
}
@@ -290,6 +345,8 @@ class ERR_NativeAdminMenuProbe
{
if (menu.ERR_ProbeHQ() && menu.ERR_ProbeLogCursor() < 0) return Fail("journal owner RPC did not return");
if (menu.ERR_ProbeHQ() && !menu.ERR_ProbeJournalVisible()) return Fail("received journal text is empty, collapsed or outside its viewport");
if (menu.ERR_ProbeHQ() && !menu.ERR_ProbeJournalDetails()) return Fail("readable/technical journal toggle lost entries or text");
Pass("journal switches between readable summaries and complete technical records without losing cursor/history");
System.MakeScreenshot("$logs:err-admin-journal");
Pass("HQ capability and journal page remain within current mission services");
menu.Close();
@@ -16,6 +16,20 @@ modded class ERR_ObjectiveDefence
}
modded class ERR_HunterService
{
bool ERR_ProbeOpening(ERR_GameMode mode)
{
if (!m_Opening || !m_Opening.Ready() || m_Hunters.Count() < 3) return false;
for (int i = 0; i < 3; i++)
{
ERR_HunterVehicle unit = m_Hunters[i];
if (!unit.Registered || !unit.CrewValid(mode) || unit.Members.Count() != 2) return false;
bool manpack;
foreach (IEntity actor : unit.Members) if (ERR_EnemyRadio.Capability(actor) == 2) manpack = true;
if (!manpack) return false;
for (int j = i + 1; j < 3; j++) if (!ERR_HunterOpeningRules.Spread(m_Opening.Center, m_Opening.Positions[i], m_Opening.Positions[j])) return false;
}
return true;
}
ERR_HunterVehicle ERR_ProbeFirst()
{
if (m_Hunters.IsEmpty()) return null;
@@ -46,6 +60,7 @@ modded class ERR_GameMode
protected int m_ERRAggressionStage;
protected int m_ERRAggressionDeadline;
protected vector m_ERRHunterStart;
protected float m_ERRHunterWait;
protected bool m_ERRHunterOrder;
protected override void EOnInit(IEntity owner)
{
@@ -74,9 +89,7 @@ modded class ERR_GameMode
{
if (!ERR_AggressionCheck(UnlockPrison(player), "native prison release")) return;
player.SetOrigin(ERR_Actors.Ground(Sites().Find("everon-medical-1").Location.Position() + "300 0 0"));
for (int i = 0; i < 3; i++) hq.ERR_ProbeBody(i, now);
hq.ERR_ProbeBody(2, now);
if (!ERR_AggressionCheck(hq.Watch().IdentifiedBodies() == 3 && hq.Watch().Aggression() == 1 && hq.Watch().HunterTarget() == 1, "three distinct delivered bodies unlock one UAZ; duplicate ignored")) return;
if (!ERR_AggressionCheck(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;
for (int j = 0; j < Sites().Count(); j++)
{
@@ -90,6 +103,11 @@ modded class ERR_GameMode
if (!hunter || !hunter.Registered) return;
if (m_ERRAggressionStage == 1)
{
if (!hq.Hunters().ERR_ProbeOpening(this)) return;
if (!ERR_AggressionCheck(hq.Hunters().Deployed() == 3, "three native seated UAZs on widely separated roads, each carrying a manpack")) return;
for (int bodySlot = 0; bodySlot < 3; bodySlot++) hq.ERR_ProbeBody(bodySlot, now);
hq.ERR_ProbeBody(2, now);
if (!ERR_AggressionCheck(hq.Watch().IdentifiedBodies() == 3 && hq.Watch().Aggression() == 1 && hq.Watch().HunterTarget() == 4, "three identified bodies add one UAZ beyond opening three; duplicate ignored")) return;
if (!ERR_AggressionCheck(hunter.CrewValid(this) && hunter.Members.Count() == 2, "physical UAZ with native seated driver and gunner")) return;
m_ERRHunterStart = hunter.Entity.GetOrigin(); m_ERRAggressionStage = 2;
}
@@ -105,11 +123,40 @@ modded class ERR_GameMode
if (!hq.ERR_ProbeMedicalDefence()) return;
if (!ERR_AggressionCheck(true, "nearby medical stash gained complete dedicated squad and defend waypoint at terrain height")) return;
if (!ERR_AggressionCheck(true, "HQ radio delivered road order; UAZ drove over 30m; maximum scoped crew skill active")) return;
if (!ERR_AggressionCheck(hq.Hunters().Deployed() == 1, "bounded allowance does not spawn duplicate UAZs")) return;
if (!ERR_AggressionCheck(hq.Hunters().Deployed() >= 3 && hq.Hunters().Deployed() <= 4, "opening and aggression respect cumulative allowance")) return;
SCR_CharacterDamageManagerComponent.Cast(hunter.Members[1].FindComponent(SCR_CharacterDamageManagerComponent)).Kill(Instigator.CreateInstigator(player));
m_ERRHunterWait = now + 3000; m_ERRAggressionStage = 3; return;
}
if (m_ERRAggressionStage == 3)
{
if (now < m_ERRHunterWait) return;
if (!ERR_AggressionCheck(!ERR_Actors.Alive(hunter.Members[1]) && !hunter.Released && hunter.CrewValid(this), "gunner casualty preserves the original seated driver's patrol")) return;
CharacterControllerComponent.Cast(hunter.Driver.FindComponent(CharacterControllerComponent)).SetUnconscious(true);
m_ERRHunterWait = now + 3000; m_ERRAggressionStage = 4; return;
}
if (m_ERRAggressionStage == 4)
{
if (now < m_ERRHunterWait) return;
if (!ERR_AggressionCheck(!ERR_Actors.CanFight(hunter.Driver) && !hunter.Released, "temporarily unconscious driver does not discard the vehicle")) return;
CharacterControllerComponent.Cast(hunter.Driver.FindComponent(CharacterControllerComponent)).SetUnconscious(false);
m_ERRAggressionStage = 5; return;
}
if (m_ERRAggressionStage == 5)
{
if (!hunter.CrewValid(this)) return;
CompartmentAccessComponent access = CompartmentAccessComponent.Cast(hunter.Driver.FindComponent(CompartmentAccessComponent));
if (!ERR_AggressionCheck(access && access.GetOutVehicle(EGetOutType.TELEPORT, 0, ECloseDoorAfterActions.LEAVE_OPEN, true), "fixture dismounts the original driver through native compartment API")) return;
m_ERRHunterWait = now + 2000; m_ERRAggressionStage = 6; return;
}
if (m_ERRAggressionStage == 6)
{
if (now < m_ERRHunterWait || !hunter.CrewValid(this)) return;
if (!ERR_AggressionCheck(!hunter.Released && hunter.Members.Count() == 2 && !ERR_Actors.Alive(hunter.Members[1]), "native AI reboards original driver without replacing the lost gunner")) return;
hq.Hunters().Stop();
if (!ERR_AggressionCheck(hunter.Released && !hunter.Waypoint && hunter.Entity && hunter.Members.Count() == 2, "stop removes orders and preserves physical vehicle/crew")) return;
foreach (IEntity survivor : hunter.Members)
{
if (!ERR_Actors.Alive(survivor)) continue;
SCR_AICombatComponent survivingCombat = SCR_AICombatComponent.Cast(survivor.FindComponent(SCR_AICombatComponent));
if (!ERR_AggressionCheck(survivingCombat && survivingCombat.ERR_EnhancedEnemy(), "released driving task preserves surviving crew AI profile")) return;
}
@@ -54,7 +54,7 @@ modded class ERR_GameMode
string enabled;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errBaseSmoke", enabled) || enabled != "1") return;
string nativeEndCase;
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "aggression" || nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing" || nativeEndCase == "generated-sites" || nativeEndCase == "mortar-station" || nativeEndCase == "opening-tools" || nativeEndCase == "airfield-markers" || nativeEndCase == "air-logistics" || nativeEndCase == "air-assault" || nativeEndCase == "air-recon")) return;
if (System.GetCLIParam("errSmokeOutcome", nativeEndCase) && (nativeEndCase == "unconscious-camera" || nativeEndCase == "town-garrisons" || nativeEndCase == "site-garrisons" || nativeEndCase == "radio-interaction" || nativeEndCase == "enemy-loadout" || nativeEndCase == "aggression" || nativeEndCase == "native-end-active" || nativeEndCase == "native-end-preparing" || nativeEndCase == "generated-sites" || nativeEndCase == "mortar-station" || nativeEndCase == "opening-tools" || nativeEndCase == "airfield-markers" || nativeEndCase == "air-logistics" || nativeEndCase == "air-assault" || nativeEndCase == "air-recon")) return;
ERR_NativeObjectiveProbe.BeginRound();
ERR_NativeProgressionIdentityProbe.BeginRound();
string outcome;
@@ -267,9 +267,9 @@ modded class ERR_GameMode
}
if (m_ERRExtractionCase == "recovery")
{
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 502 && EndReason().Contains("administrator abandoned"), "explicit authorized abandonment uses normal defeat result")) return;
if (!ERR_BaseCheck(current == ERR_Phase.DEFEAT && m_ERRBaseStep == 502 && EndReason().Contains("All prisoners are unconscious"), "stalled all-unconscious team reaches automatic mission defeat")) return;
if (!ERR_BaseCheck(!RecoveryStatus().IsEmpty() && !AbandonRound(), "terminal event observes active warning and rejects repeated abandonment")) return;
Print("ERR SMOKE COMPLETE: recovery status and explicit administrator abandonment");
Print("ERR SMOKE COMPLETE: player casualty protection, native healing and automatic all-unconscious defeat");
return;
}
if (m_ERRExtractionCase == "objective-ui")
@@ -445,9 +445,7 @@ modded class ERR_GameMode
if (result == 1)
{
m_ERRBaseStep = 502;
array<string> replies = {};
Admin().Execute(this, true, "mission abandon", replies);
ERR_BaseCheck(Phase() == ERR_Phase.DEFEAT && replies.Count() == 1 && replies[0].Contains("Round abandoned") && RecoveryStatus().IsEmpty() && Recovery().Conscious() == 0 && Recovery().Incapacitated() == 0, "administrator command returns accepted outcome after recovery cleanup");
ERR_BaseCheck(Active() && ERR_Actors.Alive(m_ERRBasePlayer), "fixture leaves a living casualty for the production failure timer");
}
return;
}
@@ -811,12 +809,12 @@ modded class ERR_GameMode
if (!ERR_NativeObjectiveProbe.Observe(this)) return;
CharacterControllerComponent.Cast(m_ERRBasePlayer.FindComponent(CharacterControllerComponent)).SetUnconscious(true);
m_ERRBaseStep = 310;
m_ERRBaseWait = now + 35000;
m_ERRBaseWait = now + 5000;
}
else if (m_ERRBaseStep == 310)
{
CharacterControllerComponent controller = CharacterControllerComponent.Cast(m_ERRBasePlayer.FindComponent(CharacterControllerComponent));
if (!ERR_BaseCheck(controller.IsUnconscious() && ERR_Actors.Alive(m_ERRBasePlayer) && Players().HasSurvivor() && Phase() == ERR_Phase.RADIO_DEFENCE, "unconscious survivor does not trigger defeat after the full grace period")) return;
if (!ERR_BaseCheck(controller.IsUnconscious() && ERR_Actors.Alive(m_ERRBasePlayer) && Players().HasSurvivor() && Phase() == ERR_Phase.RADIO_DEFENCE, "unconscious survivor remains revivable during the failure grace period")) return;
if (!ERR_BaseCheck(!DefenceOccupied() && DefenceSecondsRemaining() == 30, "unconscious radio defender cannot advance transmission")) return;
if (!ERR_BaseCheck(RadioFeedback() == ERR_RadioFeedback.INTERRUPTED && ERR_ObjectiveText.Description(this, false).Contains("Progress has reset"), "radio guide reports interruption after unconsciousness")) return;
SCR_PlayerController playerController = SCR_PlayerController.Cast(GetGame().GetPlayerController());
@@ -0,0 +1,85 @@
#ifdef WORKBENCH
// Real native inventory fixture; deliberately not registered with the ordinary equipment service.
class ERR_ProbeBrokenLauncherGrant : ERR_EnemyLauncherGrant
{
override protected ResourceName Preferred(ERR_GameMode mode, SCR_AIGroup group)
{
return "{B05A816C0BF50802}Prefabs/Weapons/Explosives/Mine_PMN4/Mine_PMN4.et";
}
}
modded class ERR_GameMode
{
protected int m_ERRLoadoutDeadline;
protected int m_ERRLoadoutStage;
protected SCR_AIGroup m_ERRLoadoutGroup;
protected ref ERR_EnemyLauncherGrant m_ERRNativeAA = new ERR_EnemyLauncherGrant();
protected ref ERR_ProbeBrokenLauncherGrant m_ERRFallbackAA = new ERR_ProbeBrokenLauncherGrant();
protected ref ERR_EnemyMineGrant m_ERRNativeMines = new ERR_EnemyMineGrant();
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "enemy-loadout") return;
m_ERRLoadoutDeadline = System.GetTickCount() + 240000;
GetGame().GetCallqueue().CallLater(ERR_LoadoutTick, 1000, true);
}
protected bool ERR_LoadoutCheck(bool ok, string text)
{
if (ok) { Print("ERR SMOKE PASS: enemy-loadout " + text); return true; }
Print("ERR SMOKE FAIL: enemy-loadout " + text, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_LoadoutTick); return false;
}
protected bool ERR_LoadoutWeapon(IEntity actor, ResourceName prefab)
{
BaseWeaponManagerComponent manager = BaseWeaponManagerComponent.Cast(actor.FindComponent(BaseWeaponManagerComponent));
array<IEntity> weapons = {}; if (manager) manager.GetWeaponsList(weapons);
IEntity weapon = ERR_EnemyLoadoutCatalog.Find(prefab, weapons, null);
return weapon && ERR_EnemyLoadoutCatalog.Loaded(weapon);
}
protected void ERR_LoadoutTick()
{
if (System.GetTickCount() >= m_ERRLoadoutDeadline || Phase() == ERR_Phase.DEFEAT) { ERR_LoadoutCheck(false, "native equipment deadline"); return; }
if (Phase() < ERR_Phase.PRISON || !GetGame().GetPlayerController() || !GetGame().GetPlayerController().GetControlledEntity()) return;
if (m_ERRLoadoutStage == 0)
{
if (!ERR_LoadoutCheck(Settings().Set("antiAirChancePercent", 100) && Settings().Set("antiPersonnelMineChancePercent", 100), "percentage settings saved")) return;
m_ERRLoadoutGroup = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", ERR_Actors.Ground(Sites().Find("everon-prison-1").Group.GetOrigin() + "10 0 10")));
if (!ERR_LoadoutCheck(m_ERRLoadoutGroup != null, "native test group")) return;
Own(m_ERRLoadoutGroup); m_ERRLoadoutGroup.SetFaction(EnemyFaction());
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;
}
array<AIAgent> agents = {}; m_ERRLoadoutGroup.GetAgents(agents);
if (agents.Count() != 2 || !agents[0].GetControlledEntity() || !agents[1].GetControlledEntity()) return;
IEntity first = agents[0].GetControlledEntity(); IEntity second = agents[1].GetControlledEntity();
foreach (AIAgent agent : agents) agent.DeactivateAI();
if (m_ERRLoadoutStage == 1)
{
m_ERRNativeAA.Check(this, m_ERRLoadoutGroup, first);
m_ERRFallbackAA.Check(this, m_ERRLoadoutGroup, second);
m_ERRNativeMines.Check(this, m_ERRLoadoutGroup, first);
ResourceName preferred = ERR_EnemyLoadoutCatalog.Launcher(this, m_ERRLoadoutGroup.GetFaction());
if (!ERR_LoadoutWeapon(first, preferred) || !ERR_LoadoutWeapon(second, ERR_EnemyLoadoutCatalog.FIM)) return;
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(first.FindComponent(InventoryStorageManagerComponent));
array<IEntity> items = {}; inventory.GetItems(items); int mines;
foreach (IEntity item : items) if (item && item.FindComponent(SCR_AntiPersonnelMineCollisionHandlerComponent)) mines++;
if (mines != 2) return;
ERR_LoadoutCheck(true, "preferred=" + preferred + "; fallback FIM physically equipped and loaded; two carried AP mines");
ERR_AdminMenu.Open(); m_ERRLoadoutStage = 2; return;
}
ERR_AdminMenu menu = ERR_AdminMenu.GetOpenMenu(); if (!menu || !menu.ERR_ProbeReady()) return;
if (m_ERRLoadoutStage == 2)
{
menu.ERR_ProbePage(1);
if (!ERR_LoadoutCheck(menu.ERR_ProbeNavigation(1), "HQ menu navigation")) return;
Print("ERR ADMIN CAPTURE PAGE: 1"); m_ERRLoadoutStage = 3; return;
}
if (m_ERRLoadoutStage++ < 8) 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");
}
}
#endif
@@ -178,6 +178,17 @@ class ERR_NativeRadioDefenceProbe
if (m_Step == 0)
{
if (!Check(m_Mode.Phase() == ERR_Phase.RADIO_SEARCH && m_Radio.Knowledge() == 2 && ERR_Actors.Player(m_Player), "identified radio and real controlled prisoner available")) return -1;
ActionsManagerComponent manager = ActionsManagerComponent.Cast(m_Radio.GetOwner().FindComponent(ActionsManagerComponent));
if (!Check(manager != null, "radio has native action manager")) return -1;
UserActionContext context = manager.GetContext("ERR_Radio");
vector contextTransform[4];
if (!Check(context && context.GetTransformationWorld(contextTransform), "radio context has a valid world transform")) return -1;
PrintFormat("ERR RADIO CONTEXT: radio=%1 context=%2 radius=%3", m_Radio.Position(), contextTransform[3], context.GetRadius());
if (!Check(vector.Distance(contextTransform[3], m_Radio.Position()) < 0.6, "radio action context follows the generated radio rather than world origin")) return -1;
array<BaseUserAction> actions = {}; context.GetActionsList(actions);
ERR_TuneRadioAction tune;
foreach (BaseUserAction action : actions) if (ERR_TuneRadioAction.Cast(action)) tune = ERR_TuneRadioAction.Cast(action);
if (!Check(tune && tune.CanBeShownScript(m_Player) && tune.CanBePerformedScript(m_Player), "native radio context contains the visible permitted extraction action")) return -1;
m_OriginalDuration = m_Mode.Settings().Get("radioDefenceSeconds");
m_Player.GetWorldTransform(m_Original);
CharacterControllerComponent controller = CharacterControllerComponent.Cast(m_Player.FindComponent(CharacterControllerComponent));
@@ -0,0 +1,68 @@
#ifdef WORKBENCH
// Uses the local native client's nearby-context collection. It does not inject
// a context or an action into the interaction handler.
modded class ERR_GameMode
{
protected int m_ERRRadioInteractionStage;
protected int m_ERRRadioInteractionDeadline;
protected float m_ERRRadioInteractionWait;
protected ERR_LocationComponent m_ERRInteractionRadio;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "radio-interaction") return;
m_ERRRadioInteractionDeadline = System.GetTickCount() + 150000;
GetGame().GetCallqueue().CallLater(ERR_RadioInteractionTick, 500, true);
}
protected bool ERR_RadioInteractionCheck(bool ok, string detail)
{
if (ok) { Print("ERR SMOKE PASS: radio interaction " + detail); return true; }
Print("ERR SMOKE FAIL: radio interaction " + detail, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_RadioInteractionTick); return false;
}
protected void ERR_RadioInteractionTick()
{
if (System.GetTickCount() > m_ERRRadioInteractionDeadline) { ERR_RadioInteractionCheck(false, "native nearby-context deadline"); return; }
if (!Active() || GetWorld().GetWorldTime() < m_ERRRadioInteractionWait) return;
PlayerController controller = GetGame().GetPlayerController();
if (!controller || !controller.GetControlledEntity()) return;
IEntity player = controller.GetControlledEntity();
if (m_ERRRadioInteractionStage == 0)
{
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
if (!ERR_RadioInteractionCheck(UnlockPrison(player), "normal prison escape")) return;
m_ERRInteractionRadio = Sites().Find("everon-radio-1").Location;
m_ERRInteractionRadio.Discover(0, 100);
vector transform[4]; player.GetWorldTransform(transform);
transform[3] = ERR_Actors.Ground(m_ERRInteractionRadio.Position() + ERR_CampTerrain.Rotate("0 0 -1.5", Sites().Find("everon-radio-1").Definition.Heading));
ChimeraCharacter.Cast(player).Teleport(transform);
CharacterControllerComponent character = CharacterControllerComponent.Cast(player.FindComponent(CharacterControllerComponent));
character.SetHeadingAngle(Sites().Find("everon-radio-1").Definition.Heading * Math.DEG2RAD, false);
character.SetFreeLook(true, false, false);
character.GetAimingComponent().SetAimingRotation("0 -0.3 0");
m_ERRRadioInteractionWait = GetWorld().GetWorldTime() + 3000;
m_ERRRadioInteractionStage = 1; return;
}
ActionsManagerComponent manager = ActionsManagerComponent.Cast(m_ERRInteractionRadio.GetOwner().FindComponent(ActionsManagerComponent));
UserActionContext context;
if (manager) context = manager.GetContext("ERR_Radio");
vector world[4];
if (!ERR_RadioInteractionCheck(context && context.GetTransformationWorld(world), "native context has authored position")) return;
if (!ERR_RadioInteractionCheck(vector.Distance(world[3], m_ERRInteractionRadio.Position()) < 0.6, "context follows randomly placed radio")) return;
array<BaseUserAction> actions = {}; context.GetActionsList(actions);
ERR_TuneRadioAction tune;
foreach (BaseUserAction action : actions) if (ERR_TuneRadioAction.Cast(action)) tune = ERR_TuneRadioAction.Cast(action);
if (!ERR_RadioInteractionCheck(tune && tune.CanBeShown(player) && tune.CanBePerformed(player), "native action is showable and permitted")) return;
SCR_InteractionHandlerComponent handler = SCR_InteractionHandlerComponent.Cast(controller.FindComponent(SCR_InteractionHandlerComponent));
if (!ERR_RadioInteractionCheck(handler != null, "real client interaction handler exists")) return;
array<UserActionContext> nearby = {}; handler.GetNearbyAvailableContextList(nearby);
PrintFormat("ERR RADIO INTERACTION: player=%1 context=%2 radius=%3 nearby=%4 selected=%5 nearbyEnabled=%6 camera=%7", player.GetOrigin(), world[3], context.GetRadius(), nearby.Count(), handler.GetCurrentContext() == context, handler.GetNearbyCollectionEnabled(), GetGame().GetCameraManager().CurrentCamera().Type().ToString());
if (!ERR_RadioInteractionCheck(nearby.Contains(context) || handler.GetCurrentContext() == context, "native client collection discovers the radio in its furnished tent")) return;
tune.PerformAction(m_ERRInteractionRadio.GetOwner(), player);
if (!ERR_RadioInteractionCheck(Phase() == ERR_Phase.RADIO_DEFENCE, "discovered native action starts transmission")) return;
GetGame().GetCallqueue().Remove(ERR_RadioInteractionTick);
Print("ERR SMOKE COMPLETE: native radio context collection and extraction action");
}
}
#endif
@@ -1,39 +1,38 @@
#ifdef WORKBENCH
// Explicit disposable-player fixture. Real damage, healing and the live defeat timer.
class ERR_NativeRecoveryProbe
{
protected ERR_GameMode m_Mode;
protected IEntity m_Player;
protected SCR_CharacterDamageManagerComponent m_Damage;
protected CharacterControllerComponent m_Controller;
protected float m_Wait;
protected float m_RecoveryDeadline;
protected bool m_Stopped;
protected bool m_ChangedConsciousness;
protected bool m_InitiallyUnconscious;
protected float m_HealingDeadline;
protected int m_Step;
protected int m_Changes;
protected bool m_Stopped;
void ERR_NativeRecoveryProbe(ERR_GameMode mode, IEntity player)
{
m_Mode = mode; m_Player = player;
m_Damage = SCR_CharacterDamageManagerComponent.Cast(player.FindComponent(SCR_CharacterDamageManagerComponent));
m_Controller = CharacterControllerComponent.Cast(player.FindComponent(CharacterControllerComponent));
if (m_Controller) m_InitiallyUnconscious = m_Controller.IsUnconscious();
mode.Recovery().OnChanged().Insert(Changed);
mode.OnStop().Insert(Stop);
}
protected void Changed(string message) { m_Changes++; }
void Stop()
{
if (m_Stopped) return;
m_Stopped = true;
if (m_Mode) m_Mode.OnStop().Remove(Stop);
if (m_Mode && m_Mode.Recovery()) m_Mode.Recovery().OnChanged().Remove(Changed);
if (m_ChangedConsciousness && m_Controller && ERR_Actors.Alive(m_Player)) m_Controller.SetUnconscious(m_InitiallyUnconscious);
if (m_Damage) m_Damage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false);
}
protected bool Check(bool ok, string detail)
{
if (ok) { Print("ERR SMOKE PASS: recovery " + detail); return true; }
Stop();
Print("ERR SMOKE FAIL: recovery " + detail, LogLevel.ERROR);
return false;
Print("ERR SMOKE FAIL: recovery " + detail, LogLevel.ERROR); Stop(); return false;
}
protected void HaltPassiveRecovery()
{
array<ref SCR_PersistentDamageEffect> effects = m_Damage.GetAllPersistentEffectsOfType(SCR_PassiveHitZoneRegenDamageEffect);
foreach (SCR_PersistentDamageEffect effect : effects) m_Damage.TerminateDamageEffect(effect);
}
int Tick(float now)
{
@@ -41,54 +40,50 @@ class ERR_NativeRecoveryProbe
if (now < m_Wait) return 0;
if (m_Step == 0)
{
m_Mode.Recovery().Tick();
if (!Check(m_Controller && m_Mode.Recovery().Conscious() == 1 && m_Mode.RecoveryStatus().IsEmpty(), "real conscious prisoner is available without recovery warning")) return -1;
array<string> replies = {};
m_Mode.Admin().Execute(m_Mode, false, "mission abandon", replies);
if (!Check(replies.Count() == 1 && replies[0].Contains("Administrator") && m_Mode.Phase() == ERR_Phase.PRISON && m_Mode.EndReason().IsEmpty(), "unauthorized abandonment leaves round and result intact")) return -1;
m_Mode.Admin().Execute(m_Mode, true, "mission abandon now", replies);
if (!Check(m_Mode.Phase() == ERR_Phase.PRISON && m_Mode.EndReason().IsEmpty(), "malformed abandonment does not end a round")) return -1;
m_ChangedConsciousness = true;
m_Controller.SetUnconscious(true);
m_Step = 1; m_Wait = now + 1000;
return 0;
if (!Check(m_Damage && m_Controller && m_Damage.ERR_IsRevivablePlayer(), "real admitted prisoner has player-only casualty protection")) return -1;
m_Damage.EnableDamageHandling(true);
// Each exceeds full native health, including blood loss and a head injury.
m_Damage.GetDefaultHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Damage.GetBloodHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Damage.GetHeadHitZone().HandleDamage(100000, EDamageType.TRUE, null);
m_Wait = now + 1000; m_Step = 1; return 0;
}
if (m_Step == 1)
{
m_Mode.Recovery().Tick();
if (!Check(ERR_Actors.Alive(m_Player) && !ERR_Actors.CanFight(m_Player) && m_Mode.Recovery().Incapacitated() == 1 && m_Mode.Recovery().Conscious() == 0, "native unconscious prisoner is counted as incapacitated, not dead")) return -1;
if (!Check(m_Mode.RecoveryStatus().Contains("No conscious prisoner") && m_Mode.Status().Contains("mission abandon") && ERR_ObjectiveText.Description(m_Mode).Contains(m_Mode.RecoveryStatus()), "server status and native objective copy explain the recovery state")) return -1;
if (!Check(m_Changes == 1, "recovery state publishes exactly one change")) return -1;
m_Mode.Recovery().Tick();
if (!Check(m_Changes == 1, "unchanged sampling emits no duplicate recovery event")) return -1;
m_Step = 2; m_Wait = now + 35000;
return 0;
if (!Check(ERR_Actors.Alive(m_Player) && m_Controller.IsUnconscious() && m_Damage.GetState() != EDamageState.DESTROYED && m_Damage.GetBloodHitZone().GetDamageState() != EDamageState.DESTROYED, "otherwise lethal health, head and blood damage leave the same living unconscious body")) return -1;
// Reset fixture injuries, then exercise treatment of an isolated knockout.
m_Damage.FullHeal(); m_Damage.ForceUnconsciousness(); HaltPassiveRecovery();
m_Damage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true);
m_Wait = now + 35000; m_Step = 2; return 0;
}
if (m_Step == 2)
{
if (!Check(m_Mode.Phase() == ERR_Phase.PRISON && ERR_Actors.Alive(m_Player) && !ERR_Actors.CanFight(m_Player) && m_Mode.Players().HasSurvivor(), "all-incapacitated player survives beyond ordinary defeat grace")) return -1;
m_Controller.SetUnconscious(false);
m_RecoveryDeadline = now + 12000;
m_Step = 3; m_Wait = now + 1000;
return 0;
if (!Check(m_Mode.Active() && m_Controller.IsUnconscious() && m_Damage.ERR_TreatmentInProgress(), "active native treatment keeps an all-unconscious round alive past 30 seconds")) return -1;
m_Damage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false);
SCR_PassiveHitZoneRegenDamageEffect healing = new SCR_PassiveHitZoneRegenDamageEffect();
healing.SetAffectedHitZone(m_Damage.GetResilienceHitZone());
healing.SetDamageType(EDamageType.HEALING); healing.SetDPS(-100);
healing.SetMaxDuration(10); healing.SkipRegenDelay(true);
m_Damage.AddDamageEffect(healing);
if (!Check(m_Damage.ERR_TreatmentInProgress(), "native resilience-healing effect is recognized as ongoing treatment")) return -1;
m_HealingDeadline = now + 12000;
m_Wait = now + 1000; m_Step = 3; return 0;
}
if (m_Step == 3)
{
if (!ERR_Actors.CanFight(m_Player) && now < m_RecoveryDeadline) return 0;
PrintFormat("ERR RECOVERY HEAL: blood=%1 resilience=%2 state=%3 unconscious=%4", m_Damage.GetBloodHitZone().GetHealthScaled(), m_Damage.GetResilienceHitZone().GetHealthScaled(), m_Controller.GetLifeState(), m_Controller.IsUnconscious());
if (!ERR_Actors.CanFight(m_Player) && now < m_HealingDeadline) return 0;
if (!Check(ERR_Actors.CanFight(m_Player) && m_Mode.Active(), "native resilience healing restores consciousness on the original body")) return -1;
m_Mode.Recovery().Tick();
if (!Check(ERR_Actors.CanFight(m_Player) && m_Mode.RecoveryStatus().IsEmpty() && m_Mode.Recovery().Conscious() == 1 && m_Changes == 2, "native recovery clears the warning once")) return -1;
int id = GetGame().GetPlayerManager().GetPlayerIdFromControlledEntity(m_Player);
ERR_PlayerLife life = m_Mode.Players().Get(id);
if (!Check(life && life.UsedLife && life.Actor == m_Player, "recovery preserves original actor and spent life")) return -1;
m_Controller.SetUnconscious(true);
m_Step = 4; m_Wait = now + 1000;
return 0;
if (!Check(!m_Mode.Recovery().DefeatDue(now + 31000), "successful recovery resets the defeat timer")) return -1;
m_Damage.ForceUnconsciousness(); HaltPassiveRecovery();
m_Wait = now + 1000; m_Step = 4; return 0;
}
if (m_Step == 4)
{
m_Mode.Recovery().Tick();
if (!Check(!m_Mode.RecoveryStatus().IsEmpty() && m_Changes == 3, "second incapacitation restores warning before abandonment")) return -1;
return 1;
if (!Check(ERR_Actors.Alive(m_Player) && m_Controller.IsUnconscious() && m_Mode.RecoveryStatus().Contains("30 uninterrupted seconds"), "stalled all-unconscious team starts a clear failure grace period")) return -1;
return 1; // Parent now waits for the production timer, never forces defeat.
}
return 0;
}
@@ -0,0 +1,101 @@
#ifdef WORKBENCH
modded class ERR_HQ
{
bool ERR_ProbeSiteRoles(ERR_SiteRuntime site)
{
bool fixedGuard, mobile;
foreach (ERR_HQUnit unit : m_Units)
{
if (unit.Group == site.Group) fixedGuard = unit.ReservedForSupport && unit.HomeGuard && !unit.Eligible(m_Mode) && !unit.PendingOrder && !unit.Assignment;
if (site.Responders && unit.Group == site.Responders.Group) mobile = !unit.ReservedForSupport && !unit.HomeGuard && unit.Eligible(m_Mode) && unit.HomeId == site.Definition.Id;
}
return fixedGuard && mobile;
}
}
modded class ERR_GameMode
{
protected int m_ERRSiteStage, m_ERRSiteDeadline;
protected float m_ERRSiteWait;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "site-garrisons") return;
m_ERRSiteDeadline = System.GetTickCount() + 300000;
GetGame().GetCallqueue().CallLater(ERR_SiteGarrisonTick, 1000, true);
}
protected bool ERR_SiteCheck(bool ok, string message)
{
if (ok) { Print("ERR SMOKE PASS: sites " + message); return true; }
Print("ERR SMOKE FAIL: sites " + message, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_SiteGarrisonTick); return false;
}
protected void ERR_SiteGarrisonTick()
{
if (System.GetTickCount() >= m_ERRSiteDeadline || !PreparationError().IsEmpty()) { ERR_SiteCheck(false, "startup deadline/preparation: " + PreparationError()); return; }
if (!Active() || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity();
if (!player || GetWorld().GetWorldTime() < m_ERRSiteWait) return;
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
ERR_HQ hq = ERR_HQ.Get(); if (!hq) return;
if (m_ERRSiteStage == 0)
{
int hospitals, camps, defenders, responders;
for (int i = 0; i < Sites().Count(); i++)
{
ERR_SiteRuntime site = Sites().At(i);
if (site.Definition.ResponseCount == 0) continue;
if (!ERR_SiteCheck(site.Definition.PlacementResolved && site.Props.Count() >= 4, site.Definition.Id + " randomized composition and scenery created")) return;
if (!ERR_SiteCheck(site.Group && site.Responders && site.Responders.Group && site.Group != site.Responders.Group && ForceLiving(site.Group) == 4 && ForceLiving(site.Responders.Group) == 2, site.Definition.Id + " distinct native 4 + 2 rosters")) return;
if (!ERR_SiteCheck(hq.ERR_ProbeSiteRoles(site), site.Definition.Id + " permanent defenders reserved, responders HQ eligible")) return;
if (!ERR_SiteCheck(Forces().FindGroup(site.Group) && Forces().FindGroup(site.Responders.Group), site.Definition.Id + " both finite forces registered")) return;
InventoryStorageManagerComponent storage = InventoryStorageManagerComponent.Cast(site.Container.FindComponent(InventoryStorageManagerComponent));
array<IEntity> items = {}; if (storage) storage.GetItems(items);
int expected; foreach (ERR_SiteStock stock : site.Definition.Stock) expected += stock.Count;
if (hq.Logistics().Cache() == site.Container)
{
array<IEntity> courierStock = {}; hq.Logistics().InitialStock(courierStock);
expected += courierStock.Count();
if (!ERR_SiteCheck(hq.Logistics().Group() == site.Responders.Group, "mortar courier uses mobile team, never the permanent garrison")) return;
}
int topLevel;
foreach (IEntity stored : items)
{
InventoryItemComponent item = InventoryItemComponent.Cast(stored.FindComponent(InventoryItemComponent));
if (item && item.GetParentSlot() && item.GetParentSlot().GetStorage() && item.GetParentSlot().GetStorage().GetOwner() == site.Container) topLevel++;
}
// GetItems also enumerates native magazines/attachments inside supplied
// rifles. Only items directly stored in this cache belong to its stock list.
if (!ERR_SiteCheck(topLevel == expected, string.Format("%1 finite supplies inserted before admission: expected=%2 direct=%3 including nested=%4", site.Definition.Id, expected, topLevel, items.Count()))) return;
if (!ERR_SiteCheck(site.Location.Knowledge() == 0 && site.Location.ERR_ProbeGeneratedMarker(false), site.Definition.Id + " undiscovered marker hidden")) return;
site.Location.Discover(99, 0);
if (!ERR_SiteCheck(site.Location.Knowledge() == 1 && site.Location.ERR_ProbeGeneratedMarker(true), site.Definition.Id + " partial clue displays approximate marker")) return;
site.Location.Discover(99, 0);
if (!ERR_SiteCheck(site.Location.Knowledge() == 2 && site.Location.ERR_ProbeGeneratedMarker(true), site.Definition.Id + " exact marker at generated position")) return;
if (site.Definition.Kind == ERR_LocationKind.HOSPITAL)
{
hospitals++;
if (!ERR_SiteCheck(ERR_DiscoveryMarker.Icon(site.Definition.Kind, site.Definition.Id) == "medical", "hospital uses medical map icon")) return;
}
else camps++;
defenders += ForceLiving(site.Group); responders += ForceLiving(site.Responders.Group);
for (int j = 0; j < i; j++)
{
ERR_SiteRuntime previous = Sites().At(j);
if (previous.Definition.ResponseCount > 0 && !ERR_SiteCheck(vector.DistanceXZ(previous.Location.Position(), site.Location.Position()) >= 600, "field sites separated by at least 600 metres")) return;
}
}
if (!ERR_SiteCheck(hospitals == 3 && camps == 5 && defenders == 32 && responders == 16, "three hospitals, five camps, 32 fixed defenders and 16 mobile soldiers")) return;
if (!ERR_SiteCheck(UnlockPrison(player), "normal prison release")) return;
m_ERRSiteStage = 1; m_ERRSiteWait = GetWorld().GetWorldTime() + 10000; return;
}
for (int index = 0; index < Sites().Count(); index++)
{
ERR_SiteRuntime garrison = Sites().At(index);
if (garrison.Responders && !ERR_SiteCheck(hq.ERR_ProbeSiteRoles(garrison), garrison.Definition.Id + " role separation survives live HQ ticks")) return;
}
GetGame().GetCallqueue().Remove(ERR_SiteGarrisonTick);
Print("ERR SMOKE COMPLETE: randomized hospitals/camps, native rosters, finite stock, discovery and HQ role separation");
}
}
#endif
@@ -101,7 +101,7 @@ class ERR_NativeTimeProbe
bool savedSpeed = mode.Settings().Get("timeMultiplier") == 4 && replies[0].Contains("next round");
bool idempotent = mode.Time().Start(mode) && Math.AbsFloat(manager.GetTimeOfTheDay() - currentHours) < 0.01 && manager.GetDayDuration() == currentDuration && mode.Time().StartMinutes() == appliedStart && mode.Time().Multiplier() == appliedSpeed;
mode.Admin().Execute(mode, true, "mission time show", replies);
bool shown = replies.Count() == 2 && replies[0].Contains("Clock") && replies[1] == "Next round: 06:15, 4x progression.";
bool shown = replies.Count() == 2 && replies[0].Contains("Clock") && replies[1] == "Next round: 06:15, 4 progression.";
bool restoredTime = mode.Settings().Set("startTimeMinutes", originalTime);
bool restoredSpeed = mode.Settings().Set("timeMultiplier", originalSpeed);
if (!Check(savedTime && savedSpeed && restoredTime && restoredSpeed, "authorized settings persist and test restores originals")) return false;
@@ -3,7 +3,29 @@ enum ERR_ObserverReason
NONE,
SPENT_LIFE,
LATE_EXTRACTION,
SPAWN_UNAVAILABLE
SPAWN_UNAVAILABLE,
UNCONSCIOUS
}
class ERR_ObserverRules
{
static int Cycle(bool enabled, bool left, bool right, bool previousLeft, bool previousRight)
{
if (!enabled || left == right) return 0;
if (left && !previousLeft) return -1;
if (right && !previousRight) return 1;
return 0;
}
static bool Casualty(bool active, bool currentBody, bool alive, bool conscious)
{
return active && currentBody && alive && !conscious;
}
static bool View(int reason, bool active, bool alive, bool conscious)
{
if (!active || reason == ERR_ObserverReason.NONE) return false;
if (reason == ERR_ObserverReason.UNCONSCIOUS) return alive && !conscious;
return !alive;
}
}
// Presentation only. Existing server admission and observer selection own state.
@@ -19,9 +41,14 @@ class ERR_ObserverGuidance
message = "Extraction has started. New prisoners can join next round.";
else if (reason == ERR_ObserverReason.SPAWN_UNAVAILABLE)
message = "Your prisoner could not be spawned. You are spectating until the next round.";
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 survivor is currently available to follow. The view will follow a survivor when one becomes available.";
return message + " Use /esc spectate next or /esc spectate previous to change who you follow.";
return message + " Press Left / Right arrow to change teammate (or /esc spectate next / /esc spectate previous). Use camera look controls to orbit and zoom controls to change distance.";
}
static bool Refresh(bool shown, int previousReason, int reason, bool previousAvailable, bool survivorAvailable)
{
@@ -33,11 +60,22 @@ class ERR_ObserverGuidance
[BaseContainerProps(), SCR_BaseManualCameraComponentTitle()]
class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
{
protected IEntity m_Target;
protected IEntity m_Vehicle;
protected float m_Yaw;
protected float m_Pitch = 20;
protected float m_Distance = 5;
protected bool m_LeftDown, m_RightDown;
override bool EOnCameraInit() { return true; }
override void EOnCameraFrame(SCR_ManualCameraParam param)
{
SCR_PlayerController controller = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!controller) return;
bool left = (Debug.KeyState(KeyCode.KC_LEFT) & 32768) != 0;
bool right = (Debug.KeyState(KeyCode.KC_RIGHT) & 32768) != 0;
int cycle = ERR_ObserverRules.Cycle(param.isManualInputEnabled, left, right, m_LeftDown, m_RightDown);
m_LeftDown = left; m_RightDown = right;
if (cycle != 0) controller.ERR_CycleObserver(cycle);
IEntity target = controller.ERR_ObservedEntity();
vector aim = controller.ERR_ObserverAnchor() + "0 1.5 0";
vector position = aim + "0 4 -6";
@@ -52,15 +90,32 @@ class ERR_FollowSurvivorCameraComponent : SCR_BaseManualCameraComponent
vector lower, upper;
vehicle.GetWorldBounds(lower, upper);
aim = (lower + upper) * 0.5;
position = aim + "0 5 0" - transform[2] * 10;
exclude = vehicle;
}
else
{
target.GetWorldTransform(transform);
aim = transform[3] + "0 1.5 0";
position = aim + "0 1.5 0" - transform[2] * 4;
}
if (target != m_Target || vehicle != m_Vehicle)
{
vector angles = transform[2].VectorToAngles();
m_Yaw = angles[0] + 180; m_Pitch = 20; m_Distance = 5;
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;
}
TraceParam trace = new TraceParam();
trace.Start = aim;
@@ -106,7 +161,7 @@ modded class SCR_PlayerController
void ERR_SetObserver(ERR_ObserverReason reason)
{
if (!Replication.IsServer()) return;
if (reason != ERR_ObserverReason.NONE && ERR_Actors.Alive(GetControlledEntity())) return;
if (reason != ERR_ObserverReason.NONE && ERR_Actors.Alive(GetControlledEntity()) && reason != ERR_ObserverReason.UNCONSCIOUS) return;
m_ERRObserverReason = reason;
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && reason != ERR_ObserverReason.NONE) m_ERRObserverAnchor = mode.PrisonPosition();
@@ -132,9 +187,17 @@ modded class SCR_PlayerController
}
void ERR_UpdateObserverServer(int direction = 0)
{
if (!Replication.IsServer() || m_ERRObserverReason == ERR_ObserverReason.NONE) return;
if (!Replication.IsServer()) return;
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !mode.Active() || ERR_Actors.Alive(GetControlledEntity()))
IEntity body = GetControlledEntity();
bool casualty = mode && ERR_ObserverRules.Casualty(mode.Active(), mode.Players() && mode.Players().CurrentActor(body) != null, ERR_Actors.Alive(body), ERR_Actors.CanFight(body));
if (casualty && m_ERRObserverReason != ERR_ObserverReason.UNCONSCIOUS)
{
m_ERRObserverReason = ERR_ObserverReason.UNCONSCIOUS;
m_ERRObserverAnchor = body.GetOrigin();
}
if (m_ERRObserverReason == ERR_ObserverReason.NONE) return;
if (!mode || !mode.Active() || (ERR_Actors.Alive(body) && !casualty) || (m_ERRObserverReason == ERR_ObserverReason.UNCONSCIOUS && !casualty))
{
m_ERRObserverReason = ERR_ObserverReason.NONE;
ERR_ReleaseObserved();
@@ -145,7 +208,7 @@ modded class SCR_PlayerController
ERR_Actors.Players(survivors);
Faction friendly = mode.PlayerFaction();
for (int i = survivors.Count() - 1; i >= 0; i--)
if (!friendly || !mode.Players() || !mode.Players().CurrentActor(survivors[i]) || SCR_FactionManager.SGetFaction(survivors[i]) != friendly) survivors.RemoveOrdered(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);
int index = survivors.Find(m_ERRServerObserved);
if (index < 0) index = 0;
else index += direction;
@@ -175,6 +238,7 @@ modded class SCR_PlayerController
ERR_StreamObserved(vehicle, false);
}
}
else if (casualty) m_ERRObserverAnchor = body.GetOrigin();
Replication.BumpMe();
}
void ERR_CycleObserver(int direction)
@@ -202,7 +266,7 @@ modded class SCR_PlayerController
protected void ERR_UpdateObserverClient()
{
ERR_GameMode mode = ERR_GameMode.Get();
bool observe = mode && mode.Active() && m_ERRObserverReason != ERR_ObserverReason.NONE && !ERR_Actors.Alive(GetControlledEntity());
bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity()));
if (!observe)
{
ERR_CloseObserverCamera();
@@ -0,0 +1,79 @@
// Player-only casualty protection. Damage still wounds; healing remains native.
class ERR_PlayerMedicalRules
{
static float AllowedDamage(float damage, float health, float maximum, float destroyedThreshold)
{
if (damage <= 0) return damage;
float floor = maximum * Math.Clamp(destroyedThreshold + 0.01, 0.01, 1);
return Math.Min(damage, Math.Max(0, health - floor));
}
}
modded class SCR_CharacterDamageManagerComponent
{
protected bool m_ERRRevivablePlayer;
protected bool m_ERRKnockoutPending;
void ERR_EnableCasualtyProtection()
{
if (!Replication.IsServer()) return;
m_ERRRevivablePlayer = true;
SetPermitUnconsciousness(true, true);
}
bool ERR_IsRevivablePlayer() { return m_ERRRevivablePlayer; }
// Queue outside hitzone callbacks: changing another hitzone in them can re-enter
// the native damage pipeline. The lethal damage itself is clamped synchronously.
void ERR_RequestKnockout()
{
if (!Replication.IsServer() || !m_ERRRevivablePlayer || m_ERRKnockoutPending) return;
CharacterControllerComponent controller = CharacterControllerComponent.Cast(GetOwner().FindComponent(CharacterControllerComponent));
if (controller && controller.IsUnconscious()) return;
m_ERRKnockoutPending = true;
GetGame().GetCallqueue().CallLater(ERR_ApplyKnockout, 0, false);
}
protected void ERR_ApplyKnockout()
{
m_ERRKnockoutPending = false;
if (!m_ERRRevivablePlayer || !ERR_Actors.Alive(GetOwner())) return;
ForceUnconsciousness();
ERR_HQ.Event("PLAYER CASUALTY", "Lethal injury prevented; prisoner remains alive and can receive medical treatment.");
}
override void Kill(notnull Instigator instigator)
{
if (!m_ERRRevivablePlayer) { super.Kill(instigator); return; }
ERR_RequestKnockout();
}
override protected bool IsIndefinitelyUnconscious(bool onlyPlayers = true)
{
if (m_ERRRevivablePlayer) return false;
return super.IsIndefinitelyUnconscious(onlyPlayers);
}
bool ERR_TreatmentInProgress()
{
if (m_aBeingHealedGroup && !m_aBeingHealedGroup.IsEmpty()) return true;
array<ref SCR_PersistentDamageEffect> effects = GetAllPersistentEffectsOfType(SCR_DotDamageEffect, true);
foreach (SCR_PersistentDamageEffect effect : effects)
{
DotDamageEffect dot = DotDamageEffect.Cast(effect);
// Includes saline and ACE epinephrine. Passive regeneration is checked by
// observed progress, so a stalled regeneration effect cannot block defeat.
if (dot && dot.GetDamageType() == EDamageType.HEALING && dot.GetDPS() < 0) return true;
}
return false;
}
}
modded class SCR_HitZone
{
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;
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));
if (allowed < damage) manager.ERR_RequestKnockout();
return allowed;
}
}
@@ -1,4 +1,4 @@
// Observation only: unconsciousness never consumes a life or ends a round here.
// Observe recovery, retaining native treatment and the same living casualty.
class ERR_RecoveryRules
{
// 0 absent/dead, 1 reserved or awaiting handoff, 2 conscious, 3 incapacitated.
@@ -13,6 +13,20 @@ class ERR_RecoveryRules
{
return conscious == 0 && incapacitated > 0;
}
static bool Stalled(int conscious, int incapacitated, int reservedRescuers, int recovering, bool connected)
{
return connected && conscious == 0 && incapacitated > 0 && reservedRescuers == 0 && recovering == 0;
}
static bool DefeatDue(float since, float now)
{
return since >= 0 && now >= since && now - since >= 30000;
}
}
class ERR_RecoverySample
{
float Blood;
float Resilience;
}
class ERR_RecoveryService
@@ -22,6 +36,8 @@ class ERR_RecoveryService
protected int m_Conscious;
protected int m_Incapacitated;
protected int m_Reserved;
protected float m_StalledSince = -1;
protected ref map<IEntity, ref ERR_RecoverySample> m_Samples = new map<IEntity, ref ERR_RecoverySample>();
protected string m_Message;
protected ref ScriptInvoker m_OnChanged = new ScriptInvoker();
void ERR_RecoveryService(ERR_GameMode mode) { m_Mode = mode; }
@@ -30,11 +46,13 @@ class ERR_RecoveryService
int Incapacitated() { return m_Incapacitated; }
int Reserved() { return m_Reserved; }
string Message() { return m_Message; }
bool DefeatDue(float now) { return ERR_RecoveryRules.DefeatDue(m_StalledSince, now); }
void Tick()
{
if (!Replication.IsServer() || m_Stopped || !m_Mode || !m_Mode.Active()) return;
int conscious, incapacitated, reserved;
int conscious, incapacitated, reserved, reservedRescuers, recovering;
map<IEntity, ref ERR_RecoverySample> nextSamples = new map<IEntity, ref ERR_RecoverySample>();
array<ref ERR_PlayerLife> lives = {};
m_Mode.Players().Participants(lives);
foreach (ERR_PlayerLife life : lives)
@@ -42,19 +60,40 @@ class ERR_RecoveryService
IEntity controlled = null;
if (life.Connected) controlled = GetGame().GetPlayerManager().GetPlayerControlledEntity(life.ConnectionId);
int kind = ERR_RecoveryRules.SurvivorKind(ERR_Actors.Alive(life.Actor), life.Connected, controlled == life.Actor, ERR_Actors.CanFight(life.Actor));
if (kind == 1) reserved++;
if (kind == 1)
{
reserved++;
if (ERR_Actors.CanFight(life.Actor)) reservedRescuers++;
else incapacitated++;
}
else if (kind == 2) conscious++;
else if (kind == 3) incapacitated++;
if (kind == 0 || kind == 2) continue;
SCR_CharacterDamageManagerComponent medical = SCR_CharacterDamageManagerComponent.Cast(life.Actor.FindComponent(SCR_CharacterDamageManagerComponent));
if (!medical) continue;
ERR_RecoverySample sample = new ERR_RecoverySample();
if (medical.GetBloodHitZone()) sample.Blood = medical.GetBloodHitZone().GetHealth();
if (medical.GetResilienceHitZone()) sample.Resilience = medical.GetResilienceHitZone().GetHealth();
ERR_RecoverySample previous = m_Samples.Get(life.Actor);
bool improving = previous && (sample.Blood > previous.Blood + 0.001 || sample.Resilience > previous.Resilience + 0.001);
if (improving || medical.ERR_TreatmentInProgress()) recovering++;
nextSamples.Insert(life.Actor, sample);
}
m_Samples = nextSamples;
m_Conscious = conscious;
m_Incapacitated = incapacitated;
m_Reserved = reserved;
float now = GetGame().GetWorld().GetWorldTime();
bool stalled = ERR_RecoveryRules.Stalled(conscious, incapacitated, reservedRescuers, recovering, GetGame().GetPlayerManager().GetPlayerCount() > 0);
if (!stalled) m_StalledSince = -1;
else if (m_StalledSince < 0 || now < m_StalledSince) m_StalledSince = now;
string message;
if (ERR_RecoveryRules.NeedsRescuer(conscious, incapacitated))
{
message = "No conscious prisoner is currently available to help. Natural recovery and treatment can still continue.";
if (reserved > 0) message += " A surviving prisoner's reconnection may provide help.";
message += " An administrator can abandon this round with /esc mission abandon.";
message = "No conscious prisoner is currently available to help.";
if (recovering > 0) message += " Treatment or recovery is progressing; 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.";
}
Publish(message);
}
@@ -69,6 +108,7 @@ class ERR_RecoveryService
if (!Replication.IsServer() || m_Stopped) return;
m_Stopped = true;
m_Conscious = 0; m_Incapacitated = 0; m_Reserved = 0;
m_StalledSince = -1; m_Samples.Clear();
Publish("");
}
}
@@ -30,7 +30,7 @@ class ERR_ScenarioComponent : ScriptComponent
m_Started = true;
#ifdef WORKBENCH
string historicalFixture;
if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && historicalFixture != "generated-sites" && historicalFixture != "opening-tools" && historicalFixture != "airfield-markers" && historicalFixture != "air-recon" && historicalFixture != "air-logistics" && historicalFixture != "air-assault")
if (System.GetCLIParam("errSmokeOutcome", historicalFixture) && 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,7 +38,7 @@ class ERR_ScenarioComponent : ScriptComponent
#ifdef WORKBENCH
// Existing deterministic probes intentionally use authored coordinates.
string fixture;
if (System.GetCLIParam("errSmokeOutcome", fixture) && fixture != "generated-sites" && fixture != "airfield-markers" && fixture != "air-recon" && fixture != "air-logistics" && fixture != "air-assault")
if (System.GetCLIParam("errSmokeOutcome", fixture) && 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;
#endif
m_Generated = new ERR_GeneratedSites();
@@ -9,15 +9,16 @@ class ERR_Settings
static array<string> Keys()
{
return {"reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "stingerChanceOneIn", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
return {"keepWeaponOnUnconscious", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
}
static bool Valid(string key, int value)
{
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 == "stingerChanceOneIn") return value >= 0 && value <= 10000;
if (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;
@@ -50,7 +51,7 @@ class ERR_Settings
// Saved modes are distinct from validated concrete gameplay values.
static bool Randomizable(string key)
{
return key == "heliTimerMinutes" || key == "convoyCount" || key == "reportIntervalSeconds" || key == "supportSquadLimit" || key == "responseRadiusMetres" || key == "aiIntelChancePercent" || key == "intelRevealChancePercent" || key == "radioDefenceSeconds" || key == "startTimeMinutes" || key == "timeMultiplier" || key == "startDateCode" || key == "weatherProfile";
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";
}
static bool ValidSelection(string key, int value)
{
@@ -72,7 +73,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 == "aiIntelChancePercent" || key == "intelRevealChancePercent") maximum = 100;
else if (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; }
@@ -96,10 +97,12 @@ class ERR_Settings
static int AddedDefault(string key)
{
if (key == "keepWeaponOnUnconscious") return 1;
if (key == "heliTimerMinutes") return 15;
if (key == "convoyCount") return 3;
if (key == "automaticConvoyEnabled") return 1;
if (key == "stingerChanceOneIn") return 15;
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;
@@ -109,6 +112,7 @@ class ERR_Settings
void ERR_Settings(string path = PATH)
{
m_Path = path;
m_Values.Set("keepWeaponOnUnconscious", AddedDefault("keepWeaponOnUnconscious"));
m_Values.Set("reportIntervalSeconds", 10);
m_Values.Set("supportSquadLimit", 4);
m_Values.Set("responseRadiusMetres", 2000);
@@ -119,7 +123,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("stingerChanceOneIn", AddedDefault("stingerChanceOneIn"));
m_Values.Set("antiAirChancePercent", AddedDefault("antiAirChancePercent"));
m_Values.Set("antiPersonnelMineChancePercent", AddedDefault("antiPersonnelMineChancePercent"));
m_Values.Set("automaticConvoyEnabled", AddedDefault("automaticConvoyEnabled"));
m_Values.Set("convoyCount", AddedDefault("convoyCount"));
m_Values.Set("heliTimerMinutes", AddedDefault("heliTimerMinutes"));
@@ -142,7 +147,18 @@ class ERR_Settings
values = new map<string, int>();
foreach (string key : Keys())
{
// Only newly introduced keys may be absent in an older valid file.
// Migrate the former one-in-N setting to the nearest integer percent.
if (key == "antiAirChancePercent" && !json.DoesKeyExist(key) && json.DoesKeyExist("stingerChanceOneIn"))
{
float oldNumber;
if (!json.ReadValue("stingerChanceOneIn", oldNumber)) return false;
int oldChance = Math.Round(oldNumber);
if (oldNumber != oldChance || oldChance < 0 || oldChance > 10000) return false;
int percent = 0;
if (oldChance > 0) percent = Math.Round(100.0 / oldChance);
values.Set(key, percent); continue;
}
// Only newly introduced keys may be absent in an older valid file.
if (!json.DoesKeyExist(key) && AddedDefault(key) >= 0)
{
values.Set(key, AddedDefault(key));
@@ -57,7 +57,7 @@ class ERR_SiteDefinition
ResourceName Prefab;
[Attribute("0 0 0", UIWidgets.Coords)]
vector Position;
[Attribute("0", UIWidgets.CheckBox, "Choose dry clear terrain once per round (prison, radio or mortar)")]
[Attribute("0", UIWidgets.CheckBox, "Choose dry clear terrain once per round (prison, radio, mortar, hospital, depot or camp)")]
bool RandomClear;
[Attribute("0", UIWidgets.EditBox, "Composition heading in degrees")]
float Heading;
@@ -75,6 +75,12 @@ class ERR_SiteDefinition
[Attribute()] ref array<ref ERR_SitePatrolPoint> Patrol;
[Attribute("0", UIWidgets.CheckBox, "Populate a finite squad from the faction catalog") ]
bool Guarded;
[Attribute("0", UIWidgets.EditBox, "Permanent defenders (0 uses the full faction group; otherwise 1-8)")]
int GuardCount;
[Attribute("0", UIWidgets.EditBox, "Separate mobile soldiers HQ may send hunting (0-8); defenders remain at home")]
int ResponseCount;
[Attribute("-12 0 0", UIWidgets.Coords, "Mobile patrol assembly offset")]
vector ResponseOffset;
[Attribute("20 0 0", UIWidgets.Coords)]
vector GuardOffset;
[Attribute("", UIWidgets.ResourceNamePicker, "Finite inventory container", "et")]
@@ -117,10 +123,14 @@ class ERR_ScenarioDefinition
if (!site || site.Id.IsEmpty() || ids.Contains(site.Id) || site.Prefab.IsEmpty()) return false;
if (site.RequireAirfield && site.Kind != ERR_LocationKind.EXTRACTION) return false;
if (site.Kind == ERR_LocationKind.RADIO && !site.Guarded && site.Guards.IsEmpty()) return false;
if (site.RandomClear && site.Kind != ERR_LocationKind.PRISON && site.Kind != ERR_LocationKind.RADIO && site.Kind != ERR_LocationKind.MORTAR) return false;
if (site.RandomClear && site.Kind != ERR_LocationKind.PRISON && site.Kind != ERR_LocationKind.RADIO && site.Kind != ERR_LocationKind.MORTAR && site.Kind != ERR_LocationKind.HOSPITAL && site.Kind != ERR_LocationKind.OUTPOST && site.Kind != ERR_LocationKind.DEPOT) return false;
if (site.Label.IsEmpty() || site.Label.Length() > 64) return false;
if (!ERR_SiteDiscoveryRules.ValidRadius(site.Kind, site.DiscoverRadius)) return false;
bool optionalSite = site.Kind == ERR_LocationKind.DEPOT || site.Kind == ERR_LocationKind.OUTPOST;
bool optionalSite = site.Kind == ERR_LocationKind.DEPOT || site.Kind == ERR_LocationKind.OUTPOST || site.Kind == ERR_LocationKind.HOSPITAL;
if (site.GuardCount < 0 || site.GuardCount > 8 || site.ResponseCount < 0 || site.ResponseCount > 8) return false;
if ((site.GuardCount > 0 || site.ResponseCount > 0) && (!optionalSite || !site.Guarded)) return false;
if (site.ResponseOffset.Length() > 40 || Math.AbsFloat(site.ResponseOffset[1]) > 0.01) return false;
if (site.ResponseCount > 0) groups++;
if (!optionalSite && site.RevealOnEscape) return false;
if (site.Transport)
{
@@ -137,7 +147,7 @@ class ERR_ScenarioDefinition
foreach (ERR_SiteProp prop : site.Props)
if (!prop || prop.Prefab.IsEmpty() || prop.Offset.Length() > 40) return false;
}
if (site.Kind < ERR_LocationKind.PRISON || site.Kind > ERR_LocationKind.MORTAR) return false;
if (site.Kind < ERR_LocationKind.PRISON || site.Kind > ERR_LocationKind.HOSPITAL) return false;
ids.Insert(site.Id);
if (site.Kind == ERR_LocationKind.PRISON) prisons++;
if (site.Kind == ERR_LocationKind.RADIO) radios++;
@@ -181,6 +191,6 @@ class ERR_ScenarioDefinition
sources++;
}
}
return prisons == 1 && radios == 1 && extractions == 1 && groups <= 8 && items <= 256 && props <= 64 && transports <= 4 && sources >= MinimumRadioSources && maps >= 1;
return prisons == 1 && radios == 1 && extractions == 1 && groups <= 24 && items <= 512 && props <= 128 && transports <= 4 && sources >= MinimumRadioSources && maps >= 1;
}
}
@@ -7,7 +7,7 @@ class ERR_SiteDiscoveryRules
{
if (radius < 0 || radius > 60) return false;
if (kind == ERR_LocationKind.RADIO) return radius <= RadioRadiusLimit();
if (kind == ERR_LocationKind.DEPOT || kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.MORTAR) return true;
if (kind == ERR_LocationKind.DEPOT || kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.MORTAR || kind == ERR_LocationKind.HOSPITAL) return true;
return (kind == ERR_LocationKind.PRISON || kind == ERR_LocationKind.EXTRACTION) && radius == 0;
}
static bool InRange(float radius, float horizontalDistance, float verticalDistance)
@@ -13,6 +13,7 @@ class ERR_SiteRuntime
ERR_LocationComponent Location;
IEntity Container;
SCR_AIGroup Group;
ref ERR_SiteRuntime Responders;
string ExpectedGuardFaction;
int InitialGuardCount;
ref ERR_PrisonPatrol PrisonPatrol;
@@ -87,7 +88,7 @@ class ERR_SiteService
{
if (!group) return null;
foreach (ERR_SiteRuntime site : m_Sites)
if (site.Group == group) return site;
if (site.Group == group || (site.Responders && site.Responders.Group == group)) return site;
return null;
}
ERR_SiteRuntime Find(string id)
@@ -109,6 +110,17 @@ class ERR_SiteService
PrintFormat("Reintroduced [sites]: cache clearance position=%1 clear=%2", ground, !m_Obstructed);
return !m_Obstructed;
}
protected bool GeneratedCacheClearance(vector position)
{
// Match the physical terrain survey. An entity's broad bounding box may
// overlap an empty volume near a tree or ruin without blocking the cache.
vector ground = ERR_Actors.Ground(position);
TraceBox trace = new TraceBox();
trace.Start = ground + "0 0.12 0"; trace.End = ground + "0 2 0";
trace.Mins = "-1.3 0 -1.3"; trace.Maxs = "1.3 0.1 1.3";
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
return GetGame().GetWorld().TraceMove(trace, null) >= 1;
}
protected bool BackpackClearance(vector position)
{
vector floor = ERR_Actors.Ground(position);
@@ -293,6 +305,7 @@ class ERR_SiteService
vector containerPosition = position + ERR_CampTerrain.Rotate(spec.ContainerOffset, spec.Heading);
bool clearContainer;
if (spec.Kind == ERR_LocationKind.PRISON) clearContainer = BackpackClearance(containerPosition);
else if (spec.PlacementResolved) clearContainer = GeneratedCacheClearance(containerPosition);
else clearContainer = CacheClearance(containerPosition);
if (!clearContainer) return Fail("obstructed cache position: " + spec.Id);
site.Container = Spawn(spec.Container, position + ERR_CampTerrain.Rotate(spec.ContainerOffset, spec.Heading));
@@ -334,11 +347,13 @@ class ERR_SiteService
vector propPosition = ERR_Actors.Ground(position + ERR_CampTerrain.Rotate(prop.Offset, spec.Heading));
// Radio furniture intentionally surrounds/supports its own elevated radio.
// Generated camp terrain was checked before any of these objects existed.
if (spec.Kind != ERR_LocationKind.RADIO && !CacheClearance(propPosition)) return Fail("obstructed scenery position: " + spec.Id);
if (!spec.PlacementResolved && spec.Kind != ERR_LocationKind.RADIO && !CacheClearance(propPosition)) return Fail("obstructed scenery position: " + spec.Id);
propPosition[1] = propPosition[1] + prop.Offset[1];
IEntity scenery = ERR_Actors.Spawn(prop.Prefab, propPosition, prop.Angles + Vector(spec.Heading, 0, 0));
if (!scenery) return Fail("scenery prefab: " + spec.Id);
m_Entities.Insert(scenery); site.Props.Insert(scenery);
SCR_AIWorld aiWorld = SCR_AIWorld.Cast(GetGame().GetAIWorld());
if (aiWorld) aiWorld.RequestNavmeshRebuildEntity(scenery);
}
}
if (!CreateTransport(site, position)) return false;
@@ -363,6 +378,12 @@ class ERR_SiteService
if (!ERR_PrisonPatrol.Configure(site.Group, groupPrefab, m_Mode.EnemyFaction())) return Fail("prison patrol roster: " + spec.Id);
site.PrisonPatrol = new ERR_PrisonPatrol();
}
else if (spec.GuardCount > 0)
{
site.Group = SCR_AIGroup.Cast(Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", guardPosition));
Faction faction = GetGame().GetFactionManager().GetFactionByKey(site.ExpectedGuardFaction);
if (!ERR_SiteSquads.Configure(site.Group, groupPrefab, faction, spec.GuardCount)) return Fail("site defender roster: " + spec.Id);
}
else site.Group = SCR_AIGroup.Cast(Spawn(groupPrefab, guardPosition));
if (!site.Group) return Fail("guard group resource: " + spec.Id);
if (!site.Group.m_aUnitPrefabSlots || site.Group.m_aUnitPrefabSlots.IsEmpty() || !site.Group.GetSpawnImmediately()) return Fail("guard profile must request an initial physical squad: " + spec.Id);
@@ -370,6 +391,7 @@ class ERR_SiteService
foreach (ResourceName member : site.Group.m_aUnitPrefabSlots)
if (member.IsEmpty()) return Fail("guard profile has an empty member resource: " + spec.Id);
if (!CreateGuardOrders(site, guardPosition)) return Fail("guard initial orders: " + spec.Id);
if (!CreateResponders(site, groupPrefab)) return Fail("site mobile roster: " + spec.Id);
}
PrintFormat("Reintroduced [sites]: authored %1 at %2", spec.Id, position);
}
@@ -452,7 +474,9 @@ class ERR_SiteService
if (!GetGame()) return;
ChimeraAIWorld world = ChimeraAIWorld.Cast(GetGame().GetAIWorld());
if (!world) return;
foreach (ERR_SiteRuntime site : m_Sites)
array<ERR_SiteRuntime> pendingGroups = {};
foreach (ERR_SiteRuntime owner : m_Sites) { pendingGroups.Insert(owner); if (owner.Responders) pendingGroups.Insert(owner.Responders); }
foreach (ERR_SiteRuntime site : pendingGroups)
{
if (!site.Group || site.InitialGuardCount <= 0) continue;
// Retire this instance's initial spawn intent as well as its current queue
@@ -482,6 +506,12 @@ class ERR_SiteService
ERR_PrepareResult guards = InitialGuards(site);
if (guards == ERR_PrepareResult.FAILED) return m_State;
if (guards == ERR_PrepareResult.PENDING) pendingGuards++;
if (site.Responders)
{
ERR_PrepareResult mobile = InitialGuards(site.Responders);
if (mobile == ERR_PrepareResult.FAILED) return m_State;
if (mobile == ERR_PrepareResult.PENDING) pendingGuards++;
}
}
string firstRejected;
foreach (ERR_StockPlacement placement : m_Placements)
@@ -528,6 +558,7 @@ class ERR_SiteService
}
m_Mode.Forces().Register(site.Definition.Id, kind, site.Group.GetOrigin(), m_Mode.ForceLiving(site.Group), route, site.Group, GetGame().GetWorld().GetWorldTime());
m_Mode.RegisterGroup(site.Group);
ActivateResponders(site);
}
}
}
@@ -588,4 +619,32 @@ class ERR_SiteService
if (m_State == ERR_PrepareResult.PENDING) CancelInitialGuardRequests();
StopPatrols();
}
protected bool CreateResponders(ERR_SiteRuntime site, ResourceName profile)
{
ERR_SiteDefinition spec = site.Definition;
if (spec.ResponseCount == 0) return true;
ERR_SiteRuntime mobile = new ERR_SiteRuntime();
site.Responders = mobile;
mobile.Definition = spec; mobile.Location = site.Location;
mobile.ExpectedGuardFaction = site.ExpectedGuardFaction;
vector position = ERR_Actors.Ground(spec.Position + ERR_CampTerrain.Rotate(spec.ResponseOffset, spec.Heading));
mobile.Group = SCR_AIGroup.Cast(Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", position));
mobile.InitialGuardCount = spec.ResponseCount;
if (!ERR_SiteSquads.Configure(mobile.Group, profile, site.Group.GetFaction(), spec.ResponseCount)) return false;
// Local guard duty resumes when an HQ search expires. HQ inserts its delivered
// search ahead of this waypoint; only this mobile group is eligible to leave.
AIWaypoint home = AIWaypoint.Cast(Spawn(ERR_Resources.DefendWaypoint(), position));
if (!home) return false;
home.SetCompletionRadius(25); mobile.Group.AddWaypoint(home);
return true;
}
protected void ActivateResponders(ERR_SiteRuntime site)
{
if (!site.Responders || !site.Responders.Group) return;
SCR_AIGroup group = site.Responders.Group;
m_Mode.Forces().Register(site.Definition.Id + "-responders", ERR_ForceKind.PATROL, group.GetOrigin(), m_Mode.ForceLiving(group), null, group, GetGame().GetWorld().GetWorldTime());
m_Mode.RegisterGroup(group);
ERR_HQ.Event("SITE GARRISON", string.Format("%1 (%2) at %3: %4 permanent defenders stay at the site; %5 mobile soldiers may receive HQ hunting orders; faction=%6", site.Definition.Label, site.Definition.Id, site.Location.Position(), m_Mode.ForceLiving(site.Group), m_Mode.ForceLiving(group), site.ExpectedGuardFaction));
}
}
@@ -0,0 +1,29 @@
// Finite role-separated site rosters. A mobile team never borrows the permanent
// defenders, and a defeated team is not recreated by this owner.
class ERR_SiteSquads
{
static array<ResourceName> Roster(array<ResourceName> source, int count)
{
array<ResourceName> result = {};
if (!source || source.IsEmpty() || count < 1 || count > 8) return result;
foreach (ResourceName member : source) if (member.IsEmpty()) return result;
for (int index = 0; index < count; index++) result.Insert(source[index % source.Count()]);
return result;
}
static bool Configure(SCR_AIGroup group, ResourceName profile, Faction faction, int count)
{
if (!Replication.IsServer() || !group || !faction || group.GetAgentsCount() != 0) return false;
Resource resource = Resource.Load(profile);
if (!resource || !resource.IsValid()) return false;
IEntitySource source = resource.GetResource().ToEntitySource();
array<ResourceName> roster = {};
if (!source || !source.Get("m_aUnitPrefabSlots", roster)) return false;
group.m_aUnitPrefabSlots = Roster(roster, count);
if (group.m_aUnitPrefabSlots.Count() != count) return false;
group.SetFaction(faction);
group.SetSpawnImmediately(true);
group.SetPermanentLOD(0); group.ActivateAI();
group.RequestSpawn(count);
return true;
}
}
@@ -0,0 +1,123 @@
#ifdef WORKBENCH
// 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;
void ERR_ProbeCameraTarget(IEntity target) { m_ERRCameraProbeTarget = target; }
override IEntity ERR_ObservedEntity()
{
string outcome;
if (m_ERRCameraProbeTarget && System.GetCLIParam("errSmokeOutcome", outcome) && outcome == "unconscious-camera") return m_ERRCameraProbeTarget;
return super.ERR_ObservedEntity();
}
bool ERR_ProbeCameraActive()
{
return m_ERRObserverCamera && GetGame().GetCameraManager().CurrentCamera() == m_ERRObserverCamera;
}
}
modded class ERR_GameMode
{
protected int m_ERRCameraStage = -2;
protected int m_ERRCameraDeadline;
protected IEntity m_ERRCameraWeapon;
protected float m_ERRCameraWait;
protected IEntity m_ERRCameraBody, m_ERRCameraTarget;
protected SCR_CharacterDamageManagerComponent m_ERRCameraDamage;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "unconscious-camera") return;
m_ERRCameraDeadline = System.GetTickCount() + 180000;
GetGame().GetCallqueue().CallLater(ERR_CameraProbeTick, 500, true);
}
protected bool ERR_CameraCheck(bool ok, string message)
{
if (ok) { Print("ERR SMOKE PASS: unconscious camera " + message); return true; }
Print("ERR SMOKE FAIL: unconscious camera " + message, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_CameraProbeTick); return false;
}
protected void ERR_CameraKnockout()
{
m_ERRCameraDamage.ForceUnconsciousness();
array<ref SCR_PersistentDamageEffect> effects = m_ERRCameraDamage.GetAllPersistentEffectsOfType(SCR_PassiveHitZoneRegenDamageEffect);
foreach (SCR_PersistentDamageEffect effect : effects) m_ERRCameraDamage.TerminateDamageEffect(effect);
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, true);
}
protected void ERR_CameraProbeTick()
{
if (System.GetTickCount() >= m_ERRCameraDeadline || !PreparationError().IsEmpty()) { ERR_CameraCheck(false, "deadline/preparation " + PreparationError()); return; }
if (!Active()) return;
SCR_PlayerController pc = SCR_PlayerController.Cast(GetGame().GetPlayerController());
if (!pc || !pc.GetControlledEntity()) return;
float now = GetWorld().GetWorldTime();
if (now < m_ERRCameraWait) return;
if (m_ERRCameraStage == -2)
{
if (!ERR_CameraCheck(Settings().Get("keepWeaponOnUnconscious") == 1, "weapon retention defaults to enabled")) return;
m_ERRCameraBody = pc.GetControlledEntity();
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(m_ERRCameraBody.FindComponent(InventoryStorageManagerComponent));
m_ERRCameraWeapon = ERR_Actors.Spawn("{FA5C25BF66A53DCF}Prefabs/Weapons/Rifles/AK74/Rifle_AK74.et", m_ERRCameraBody.GetOrigin()); Own(m_ERRCameraWeapon);
if (!ERR_CameraCheck(m_ERRCameraWeapon && inventory && inventory.TryInsertItem(m_ERRCameraWeapon, EStoragePurpose.PURPOSE_WEAPON_PROXY), "one physical rifle granted to the player")) return;
m_ERRCameraStage = -1; m_ERRCameraWait = now + 1500; return;
}
SCR_CharacterControllerComponent character = SCR_CharacterControllerComponent.Cast(m_ERRCameraBody.FindComponent(SCR_CharacterControllerComponent));
InventoryStorageManagerComponent playerInventory = character.GetInventoryStorageManager();
if (m_ERRCameraStage == -1)
{
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(m_ERRCameraWeapon.FindComponent(BaseWeaponComponent));
if (!ERR_CameraCheck(playerInventory.Contains(m_ERRCameraWeapon) && character.SelectWeapon(weapon), "rifle selected with native equip animation")) return;
m_ERRCameraStage = 0; m_ERRCameraWait = now + 2000; return;
}
if (m_ERRCameraStage == 0)
{
if (!ERR_CameraCheck(character.GetWeaponManagerComponent().GetCurrentWeapon() && character.GetWeaponManagerComponent().GetCurrentWeapon().GetOwner() == m_ERRCameraWeapon, "original rifle actually in hand before knockout")) return;
m_ERRCameraBody = pc.GetControlledEntity();
m_ERRCameraDamage = SCR_CharacterDamageManagerComponent.Cast(m_ERRCameraBody.FindComponent(SCR_CharacterDamageManagerComponent));
if (!ERR_CameraCheck(m_ERRCameraDamage && m_ERRCameraDamage.ERR_IsRevivablePlayer(), "admitted player retains treatable body")) return;
ERR_CameraKnockout(); m_ERRCameraStage = 1; m_ERRCameraWait = now + 3000; return;
}
if (m_ERRCameraStage == 1)
{
if (!ERR_CameraCheck(pc.ERR_ObserverState() == ERR_ObserverReason.UNCONSCIOUS && pc.ERR_ProbeCameraActive(), "native knockout automatically opens the manual third-person camera")) return;
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;
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;
pc.ERR_ProbeCameraTarget(m_ERRCameraTarget);
m_ERRCameraStage = 2; m_ERRCameraWait = now + 1500; return;
}
if (m_ERRCameraStage == 2)
{
vector camera = GetGame().GetCameraManager().CurrentCamera().GetOrigin();
if (!ERR_CameraCheck(vector.Distance(camera, m_ERRCameraTarget.GetOrigin()) < 13 && vector.Distance(camera, m_ERRCameraBody.GetOrigin()) > 15, "camera follows target in 3D without moving the casualty")) return;
m_ERRCameraTarget.SetOrigin(ERR_Actors.Ground(m_ERRCameraTarget.GetOrigin() + "15 0 0"));
m_ERRCameraStage = 3; m_ERRCameraWait = now + 1500; return;
}
if (m_ERRCameraStage == 3)
{
if (!ERR_CameraCheck(vector.Distance(GetGame().GetCameraManager().CurrentCamera().GetOrigin(), m_ERRCameraTarget.GetOrigin()) < 13, "camera tracks a moving target")) return;
pc.ERR_ProbeCameraTarget(null); ERR_Actors.DeleteSpawned(m_ERRCameraTarget); m_ERRCameraTarget = null;
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false);
m_ERRCameraDamage.FullHeal(); m_ERRCameraStage = 4; m_ERRCameraWait = now + 4000; return;
}
if (m_ERRCameraStage == 4)
{
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(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;
ERR_CameraKnockout(); m_ERRCameraStage = 5; m_ERRCameraWait = now + 3000; return;
}
if (!ERR_CameraCheck(pc.ERR_ProbeCameraActive(), "second knockout reopens camera without stale target")) return;
if (!ERR_CameraCheck(!playerInventory.Contains(m_ERRCameraWeapon), "disabled retention leaves normal native weapon drop on ground")) return;
m_ERRCameraDamage.SetGroupIsBeingHealed(ECharacterHitZoneGroup.RIGHTARM, false);
m_ERRCameraDamage.FullHeal();
GetGame().GetCallqueue().Remove(ERR_CameraProbeTick);
Print("ERR SMOKE COMPLETE: unconscious spectator camera, physical tracking, retained treatment body, recovery and repeated knockout; target stand-in, not multiplayer proof");
}
}
#endif
@@ -0,0 +1,112 @@
class ERR_UnconsciousWeaponRules
{
static bool Firearm(int kind)
{
return kind == EWeaponType.WT_RIFLE || kind == EWeaponType.WT_SNIPERRIFLE || kind == EWeaponType.WT_MACHINEGUN
|| kind == EWeaponType.WT_HANDGUN || kind == EWeaponType.WT_GRENADELAUNCHER || kind == EWeaponType.WT_ROCKETLAUNCHER;
}
}
// Retain the actual item, including its magazine/attachments. Never spawn a
// replacement and never take an item that another inventory has acquired.
modded class SCR_CharacterControllerComponent
{
protected IEntity m_ERRRetainedWeapon;
protected ref ERR_EnemyEquipmentCallback m_ERRWeaponInsertion;
protected int m_ERRWeaponInsertAttempts, m_ERRWeaponEquipAttempts;
protected bool m_ERRWeaponWasDropped;
protected bool ERR_WeaponOwnerCurrent()
{
ERR_GameMode mode = ERR_GameMode.Get();
return Replication.IsServer() && mode && mode.Active() && mode.Players() && mode.Players().CurrentActor(GetOwner()) && ERR_Actors.Alive(GetOwner());
}
protected void ERR_RememberWeapon(IEntity weapon, bool dropped = false)
{
if (m_ERRRetainedWeapon || !weapon || !ERR_WeaponOwnerCurrent() || ERR_GameMode.Get().Settings().Get("keepWeaponOnUnconscious") != 1) return;
BaseWeaponComponent component = BaseWeaponComponent.Cast(weapon.FindComponent(BaseWeaponComponent));
if (!component || !ERR_UnconsciousWeaponRules.Firearm(component.GetWeaponType())) return;
m_ERRRetainedWeapon = weapon; m_ERRWeaponWasDropped = dropped;
m_ERRWeaponInsertAttempts = 0; m_ERRWeaponEquipAttempts = 0; m_ERRWeaponInsertion = null;
GetGame().GetCallqueue().CallLater(ERR_KeepWeaponTick, 100, true);
}
bool ERR_RetainsWeapon(IEntity weapon)
{
return weapon && weapon == m_ERRRetainedWeapon && ERR_WeaponOwnerCurrent() && IsUnconscious();
}
override void OnLifeStateChanged(ECharacterLifeState previousLifeState, ECharacterLifeState newLifeState, bool isJIP)
{
if (newLifeState == ECharacterLifeState.INCAPACITATED && GetWeaponManagerComponent())
{
BaseWeaponComponent weapon = GetWeaponManagerComponent().GetCurrentWeapon();
if (weapon) ERR_RememberWeapon(weapon.GetOwner());
}
super.OnLifeStateChanged(previousLifeState, newLifeState, isJIP);
}
override protected void OnWeaponDropped(IEntity pWeaponEntity, WeaponSlotComponent pWeaponSlot)
{
super.OnWeaponDropped(pWeaponEntity, pWeaponSlot);
if (!IsUnconscious() && GetLifeState() != ECharacterLifeState.INCAPACITATED) return;
ERR_RememberWeapon(pWeaponEntity, true);
if (pWeaponEntity == m_ERRRetainedWeapon) m_ERRWeaponWasDropped = true;
}
protected void ERR_ForgetWeapon()
{
GetGame().GetCallqueue().Remove(ERR_KeepWeaponTick);
m_ERRRetainedWeapon = null; m_ERRWeaponInsertion = null;
}
protected void ERR_KeepWeaponTick()
{
if (!m_ERRRetainedWeapon || !ERR_WeaponOwnerCurrent()) { ERR_ForgetWeapon(); return; }
InventoryStorageManagerComponent inventory = GetInventoryStorageManager();
if (!inventory) { ERR_ForgetWeapon(); return; }
if (!inventory.Contains(m_ERRRetainedWeapon))
{
if (m_ERRWeaponInsertion)
{
if (!m_ERRWeaponInsertion.Finished) return;
// One accepted asynchronous insertion is never submitted a second time.
if (!m_ERRWeaponInsertion.Succeeded) ERR_HQ.Event("PLAYER WEAPON", "Weapon retention insertion failed; original gun remains available on the ground.");
ERR_ForgetWeapon(); return;
}
InventoryItemComponent item = InventoryItemComponent.Cast(m_ERRRetainedWeapon.FindComponent(InventoryItemComponent));
if (!m_ERRWeaponWasDropped || !item || item.GetParentSlot() || vector.Distance(GetOwner().GetOrigin(), m_ERRRetainedWeapon.GetOrigin()) > 8 || ++m_ERRWeaponInsertAttempts > 10)
{ ERR_ForgetWeapon(); return; }
ref ERR_EnemyEquipmentCallback operation = new ERR_EnemyEquipmentCallback();
if (inventory.TryInsertItem(m_ERRRetainedWeapon, EStoragePurpose.PURPOSE_WEAPON_PROXY, operation)) m_ERRWeaponInsertion = operation;
return;
}
if (IsUnconscious()) return;
if (++m_ERRWeaponEquipAttempts > 30 || CompartmentAccessComponent.GetVehicleIn(GetOwner())) { ERR_ForgetWeapon(); return; }
BaseWeaponComponent current = GetWeaponManagerComponent().GetCurrentWeapon();
if (current && current.GetOwner() == m_ERRRetainedWeapon) { ERR_ForgetWeapon(); return; }
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(m_ERRRetainedWeapon.FindComponent(BaseWeaponComponent));
if (!weapon || !SelectWeapon(weapon)) return;
RplComponent rpl = RplComponent.Cast(m_ERRRetainedWeapon.FindComponent(RplComponent));
if (rpl) Rpc(ERR_EquipRetainedWeapon, rpl.Id());
}
[RplRpc(RplChannel.Reliable, RplRcver.Owner)]
protected void ERR_EquipRetainedWeapon(RplId id)
{
if (GetOwner() != SCR_PlayerController.GetLocalControlledEntity() || !ERR_Actors.CanFight(GetOwner())) return;
RplComponent rpl = RplComponent.Cast(Replication.FindItem(id));
if (!rpl || !GetInventoryStorageManager() || !GetInventoryStorageManager().Contains(rpl.GetEntity())) return;
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(rpl.GetEntity().FindComponent(BaseWeaponComponent));
if (weapon) SelectWeapon(weapon);
}
void ~SCR_CharacterControllerComponent()
{
if (GetGame()) GetGame().GetCallqueue().Remove(ERR_KeepWeaponTick);
}
}
modded class SCR_CharacterInventoryStorageComponent
{
override bool OnOverrideCanRemoveItem() { return true; }
override bool CanRemoveItem(IEntity item)
{
SCR_CharacterControllerComponent controller = SCR_CharacterControllerComponent.Cast(GetOwner().FindComponent(SCR_CharacterControllerComponent));
if (controller && controller.ERR_RetainsWeapon(item)) return false;
return super.CanRemoveItem(item);
}
}
@@ -32,6 +32,7 @@ class ERR_AirAssaultTeam
}
class ERR_AirDropGeometry
{
static string LastFailure;
static float Along(vector position, vector center, vector direction)
{ vector offset = position - center;
offset[1] = 0;
@@ -47,17 +48,19 @@ class ERR_AirDropGeometry
}
static bool LandingLine(vector center, vector direction)
{
LastFailure = "clear";
for (int step = -4; step <= 5; step++)
{
vector point = ERR_Actors.Ground(center + direction * (step * 50));
if (point[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point) || !ERR_HQRoadIntercept.Standable(point)) return false;
if (point[1] < 2 || ChimeraWorldUtils.TryGetWaterSurfaceSimple(GetGame().GetWorld(), point)) { LastFailure = "water/shore"; return false; }
if (!ERR_HQRoadIntercept.Standable(point)) { LastFailure = "landing ground not standable"; return false; }
TraceBox trace = new TraceBox();
trace.Start = point + "0 3 0";
trace.End = point + "0 190 0";
trace.Mins = "-4 -1 -4";
trace.Maxs = "4 1 4";
trace.Flags = TraceFlags.ENTS | TraceFlags.WORLD;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) return false;
if (GetGame().GetWorld().TraceMove(trace, null) < 1) { LastFailure = "obstructed descent column"; return false; }
}
return true;
}
@@ -254,16 +257,28 @@ class ERR_AirAssaultLoad
radial[1] = 0;
float heading = Math.Atan2(radial[0], radial[2]);
bool found;
for (int attempt = 0; attempt < 12; attempt++)
int surveyed;
map<string, int> rejected = new map<string, int>();
// Several distances and both runway directions provide alternatives around
// woodland while retaining every landing/descent safety check.
for (int attempt = 0; attempt < 72; attempt++)
{
float angle = heading + attempt * Math.PI / 6;
int bearing = attempt % 12;
int ring = Math.Floor(attempt / 24.0);
float radius = 300 + ring * 200;
float angle = heading + bearing * Math.PI / 6;
radial = Vector(Math.Sin(angle), 0, Math.Cos(angle));
m_Center = ERR_Actors.Ground(order.Target + radial * 450);
m_Center = ERR_Actors.Ground(order.Target + radial * radius);
m_Direction = Vector(-radial[2], 0, radial[0]);
if (ERR_AirDropGeometry.LandingLine(m_Center, m_Direction)) { found = true;
break;
}
if ((attempt % 24) >= 12) m_Direction = radial;
surveyed++;
if (ERR_AirDropGeometry.LandingLine(m_Center, m_Direction)) { found = true; break; }
int count; rejected.Find(ERR_AirDropGeometry.LastFailure, count);
rejected.Set(ERR_AirDropGeometry.LastFailure, count + 1);
}
string summary;
foreach (string reason, int failures : rejected) summary += reason + "=" + failures.ToString() + "; ";
ERR_HQ.Event("AIR DROP SURVEY", string.Format("target=%1 checked=%2 accepted=%3 center=%4; %5", order.Target, surveyed, found, m_Center, summary));
if (!found) return false;
m_RunStart = m_Center - m_Direction * 350;
m_RunEnd = m_Center + m_Direction * 450;
@@ -1,5 +1,12 @@
class ERR_BodyRecoveryJob
{
int Id;
int Repaths;
int Reassignments;
bool Approaching;
float ProgressAt;
float BestDistance = 1e20;
ref array<SCR_AIGroup> BlockedTeams = {};
IEntity Body;
vector Position;
SCR_AIGroup Team;
@@ -38,6 +45,7 @@ class ERR_BodyRecovery
{
if (m_Stopped || !report || !report.Target || m_ReportedBodies.Contains(report.Target) || m_Jobs.Count() >= 128 || !m_Mode.Evidence().Find(report.Target)) return;
ERR_BodyRecoveryJob job = new ERR_BodyRecoveryJob();
job.Id = m_Jobs.Count() + 1;
job.Body = report.Target;
job.Position = report.Position;
m_ReportedBodies.Insert(report.Target);
@@ -49,6 +57,7 @@ class ERR_BodyRecovery
foreach (ERR_BodyRecoveryJob previous : m_Jobs)
{
if (previous == job || previous.State != 4 || !previous.Team || previous.Team.GetPlayerCount(true) > 0 || previous.Team.GetFaction() != m_Mode.EnemyFaction() || m_Mode.ForceLiving(previous.Team) == 0) continue;
if (job.BlockedTeams.Contains(previous.Team)) continue;
job.Team = previous.Team;
previous.Team = null;
job.State = 1;
@@ -178,6 +187,7 @@ class ERR_BodyRecovery
}
if (job.State == 2)
{
if (!TrackProgress(job, now)) continue;
SCR_AIGroupUtilityComponent utility = job.Team.GetGroupUtilityComponent();
if (utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty()) { job.SearchAt = -1;
continue;
@@ -193,7 +203,7 @@ class ERR_BodyRecovery
float nearest = 1e20;
foreach (AIAgent nearbyAgent : agents)
if (nearbyAgent && nearbyAgent.GetControlledEntity()) nearest = Math.Min(nearest, vector.Distance(nearbyAgent.GetControlledEntity().GetOrigin(), job.Body.GetOrigin()));
ERR_HQ.Event("BODY SEARCH PROGRESS", "nearest searcher=" + nearest.ToString() + "m; physical inspection required");
ERR_HQ.Event("BODY SEARCH PROGRESS", string.Format("job=%1 team=%2 body=%3 nearest searcher=%4m; replans=%5; physical inspection required", job.Id, job.Team.GetOrigin(), job.Body.GetOrigin(), nearest, job.Repaths));
}
if (!searcher) { job.SearchAt = -1;
continue;
@@ -218,6 +228,51 @@ class ERR_BodyRecovery
}
}
}
protected bool TrackProgress(ERR_BodyRecoveryJob job, float now)
{
array<AIAgent> agents = {}; job.Team.GetAgents(agents);
float nearest = 1e20;
foreach (AIAgent agent : agents)
if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) nearest = Math.Min(nearest, vector.Distance(agent.GetControlledEntity().GetOrigin(), job.Body.GetOrigin()));
SCR_AIGroupUtilityComponent utility = job.Team.GetGroupUtilityComponent();
bool combat = utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty();
if (job.ProgressAt == 0 || nearest < job.BestDistance - 10 || combat || nearest <= 3)
{ job.ProgressAt = now; job.BestDistance = nearest; }
if (job.Approaching && job.Waypoint && job.Waypoint.IsWithinCompletionRadius(job.Team))
{
RemoveWaypoint(job); job.Approaching = false;
job.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), job.Position));
if (job.Waypoint) { job.Waypoint.SetCompletionRadius(1.5); job.Team.AddWaypointAt(job.Waypoint, 0); }
}
if (now - job.ProgressAt < 120000) return true;
job.ProgressAt = now;
if (job.Repaths >= 3)
{
ERR_HQ.Event("BODY SEARCH STALLED", string.Format("job=%1 team=%2 body=%3 distance=%4m; three native replans exhausted", job.Id, job.Team.GetOrigin(), job.Body.GetOrigin(), nearest));
if (job.Reassignments >= 2) { Finish(job, false); return false; }
RemoveWaypoint(job); m_HQ.ReleaseRecovery(job.Team);
job.BlockedTeams.Insert(job.Team); job.Team = null;
job.Reassignments++; job.Repaths = 0; job.State = 0; job.Approaching = false;
job.BestDistance = 1e20; job.RetryAt = now + 30000; job.SearchAt = -1;
return false;
}
job.Repaths++;
vector target = job.Position;
// Vary the final approach instead of teleporting a team or pretending the
// body was searched. The original body remains the only inspection target.
for (int attempt = 0; attempt < 8; attempt++)
{
float angle = (job.Repaths * 90 + attempt * 45) * Math.DEG2RAD;
vector approach = ERR_Actors.Ground(target + Vector(Math.Sin(angle), 0, Math.Cos(angle)) * 60);
if (!ERR_HQRoadIntercept.Standable(approach)) continue;
target = approach; job.Approaching = true; break;
}
RemoveWaypoint(job);
job.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), target));
if (job.Waypoint) { job.Waypoint.SetCompletionRadius(8); job.Team.AddWaypointAt(job.Waypoint, 0); }
ERR_HQ.Event("BODY SEARCH REPATH", string.Format("job=%1 attempt=%2 body=%3 approach=%4 distance=%5m; no progress for 120 seconds", job.Id, job.Repaths, job.Body.GetOrigin(), target, nearest));
return true;
}
void Stop() { m_Stopped = true;
foreach (ERR_BodyRecoveryJob job : m_Jobs) { RemoveWaypoint(job);
job.Order = null;
@@ -267,7 +322,6 @@ modded class ERR_HQ
if (assignedTask && assignedTask.SecuritySite == id && GetGame().GetWorld().GetWorldTime() < assignedTask.ExpiresAt) secured = true;
}
if (secured) continue;
ERR_HQ.Event("INTEL COMPROMISE", "missing issued document concerns " + id + "; defensive reinforcement requested");
ERR_HQUnit best;
float distance = 1e20;
foreach (ERR_HQUnit unit : m_Units)
@@ -279,9 +333,10 @@ modded class ERR_HQ
distance = candidate;
}
}
if (!best) continue;
if (!best) { ERR_HQ.Event("SITE SECURITY WAIT", "site=" + id + ": no eligible idle infantry with a fresh status report within 2500m; no dispatch"); continue; }
ERR_HQRoute route = Downlink(best.Group, "SITE SECURITY", "secure potentially compromised site " + id, 0);
if (!route) continue;
if (!route) { ERR_HQ.Event("SITE SECURITY WAIT", "site=" + id + ": selected group has no radio route; no dispatch"); continue; }
ERR_HQ.Event("SITE SECURITY QUEUED", "site=" + id + "; group=" + best.Id.ToString() + "; distance=" + distance.ToString() + "m; awaiting radio delivery");
ERR_HQTask task = new ERR_HQTask();
task.SecuritySite = id;
task.Role = ERR_HQPursuitRole.SEARCH;
@@ -2,13 +2,17 @@
// HQ supplies the physical groups. No independent spawn/order timers exist here.
class ERR_EnemyEquipmentRules
{
static bool Eligible(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
static bool Owned(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
{
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !mode || mode != ERR_GameMode.Get() || !mode.Active()) return false;
if (!group || !actor || !ERR_Actors.CanFight(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != group) return false;
if (!group || !actor || !ERR_Actors.Alive(actor) || ERR_Actors.Player(actor) || ERR_Actors.Group(actor) != group) return false;
SCR_AIGroupUtilityComponent utility = group.GetGroupUtilityComponent();
return utility && utility.IsMilitary() && group.GetPlayerCount(true) == 0 && mode.IsMissionEnemy(group.GetFaction()) && !ERR_EnemyGunnerSkill.CapturedGroup(mode, group);
}
static bool Eligible(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
{
return Owned(mode, group, actor) && ERR_Actors.CanFight(actor);
}
static EAISkill Floor(EAISkill skill)
{
return EAISkill.CYLON;
@@ -70,66 +74,23 @@ class ERR_EnemyEquipmentCallback : ScriptedInventoryOperationCallback
class ERR_EnemyEquipmentEntry
{
static const ResourceName LAUNCHER = "{3DF204EEE0534EF2}Prefabs/Weapons/Launchers/FIM92_Stinger/Launcher_FIM92_Stinger.et";
static const int MAX_CHECKS = 5;
IEntity Actor;
SCR_AIGroup Group;
SCR_AICombatComponent Combat;
ref ERR_EnemyOptics Optics;
int Checks;
ref ERR_EnemyLauncherGrant Launcher = new ERR_EnemyLauncherGrant();
ref ERR_EnemyMineGrant Mines = new ERR_EnemyMineGrant();
int SkillChecks;
bool Rolled;
bool Selected;
bool Submitted;
bool Complete;
bool Released;
ref ERR_EnemyEquipmentCallback Operation;
void Check(ERR_GameMode mode)
{
Launcher.Check(mode, Group, Actor);
Mines.Check(mode, Group, Actor);
}
protected bool Finish(string result)
{
Complete = true;
if (Selected) ERR_HQ.Event("STINGER", result);
return true;
}
bool Check(ERR_GameMode mode)
{
if (Complete) return true;
if (!ERR_EnemyEquipmentRules.Eligible(mode, Group, Actor)) return Finish("startup equipment cancelled: actor no longer eligible");
Checks++;
if (!Rolled)
{
int oneIn = mode.Settings().Get("stingerChanceOneIn");
Rolled = true;
if (oneIn > 0) Selected = Math.RandomInt(0, oneIn) == 0;
}
if (!Selected) return Finish("not selected");
BaseWeaponManagerComponent weapons = BaseWeaponManagerComponent.Cast(Actor.FindComponent(BaseWeaponManagerComponent));
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(Actor.FindComponent(InventoryStorageManagerComponent));
if (!weapons || !inventory)
{
if (Checks >= MAX_CHECKS) return Finish("inventory unavailable after bounded startup checks");
return false;
}
array<IEntity> equipped = {};
weapons.GetWeaponsList(equipped);
foreach (IEntity item : equipped)
{
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == LAUNCHER)
return Finish("physical FIM-92 observed in weapon slot; no replacement will be issued");
}
// A successful callback may precede a changed slot; dropping/looting that item
// before observation must not cause a second grant. Failure permits a bounded retry.
if (Submitted && Operation && Operation.Finished && Operation.Succeeded) return Finish("native insertion completed; equipment will not be replenished");
if (Checks >= MAX_CHECKS) return Finish("startup deadline reached; unresolved insertion quarantined for this actor");
if (Submitted && Operation && !Operation.Finished) return false;
Operation = new ERR_EnemyEquipmentCallback();
Submitted = true;
if (!inventory.TrySpawnPrefabToStorage(LAUNCHER, null, -1, EStoragePurpose.PURPOSE_WEAPON_PROXY, Operation)) Submitted = false;
return false;
}
void Stop(ERR_GameMode mode)
{
Complete = true;
Launcher.Stop(); Mines.Stop();
Released = true;
if (Optics) Optics.Stop();
if (Combat) Combat.ERR_ReleaseEnemySkill(mode);
@@ -205,7 +166,10 @@ class ERR_EnemyEquipment
foreach (ERR_EnemyEquipmentEntry entry : m_Entries)
{
if (entry.Released) continue;
if (!ERR_EnemyEquipmentRules.Eligible(m_Mode, entry.Group, entry.Actor)) { entry.Stop(m_Mode); continue; }
if (!ERR_EnemyEquipmentRules.Owned(m_Mode, entry.Group, entry.Actor)) { entry.Stop(m_Mode); continue; }
// Incapacitation suspends work on the existing entry; it must not permanently
// remove the skill binding or recreate completed equipment grants on recovery.
if (!ERR_EnemyEquipmentRules.Eligible(m_Mode, entry.Group, entry.Actor)) continue;
// Combat may arrive after the group's initial membership notification.
if (!entry.Combat && entry.SkillChecks < 5)
{
@@ -0,0 +1,192 @@
// Finite mission-owned inventory grants. No client spawns, rerolls or replenishment.
class ERR_EnemyLoadoutRules
{
static bool Selected(int percent, int roll) { return percent >= 0 && percent <= 100 && roll >= 0 && roll < 100 && roll < percent; }
static bool AntiAirName(ResourceName prefab)
{
string path = prefab; path.ToLower();
return path.Contains("/launchers/") && !path.Contains("_base.et")
&& (path.Contains("stinger") || path.Contains("fim92") || path.Contains("igla") || path.Contains("strela") || path.Contains("manpad"));
}
}
class ERR_EnemyLoadoutCatalog
{
static const ResourceName FIM = "{3DF204EEE0534EF2}Prefabs/Weapons/Launchers/FIM92_Stinger/Launcher_FIM92_Stinger.et";
static const ResourceName IGLA = "{962C400354E4BD6C}Prefabs/Weapons/Launchers/9K38_IGLA/Launcher_9K38_IGLA.et";
static const ResourceName MINE = "{B05A816C0BF50802}Prefabs/Weapons/Explosives/Mine_PMN4/Mine_PMN4.et";
protected static ERR_GameMode s_Mode;
protected static ref map<string, ResourceName> s_Launchers = new map<string, ResourceName>();
static bool Available(ResourceName prefab)
{
if (prefab.IsEmpty()) return false;
Resource resource = Resource.Load(prefab);
return resource && resource.IsValid();
}
static ResourceName Launcher(ERR_GameMode mode, Faction faction)
{
if (!faction) return FIM;
if (s_Mode != mode) { s_Mode = mode; s_Launchers.Clear(); }
string key = faction.GetFactionKey();
if (s_Launchers.Contains(key)) return s_Launchers.Get(key);
ResourceName preferred;
ERR_ContentDefinition definition = ERR_ContentRegistry.Definition();
if (definition)
foreach (ERR_ContentOption option : definition.Options)
if (option.NativeFactionKey == key && !option.AntiAirLauncher.IsEmpty()) preferred = option.AntiAirLauncher;
if (preferred.IsEmpty())
{
SCR_Faction nativeFaction = SCR_Faction.Cast(faction);
SCR_EntityCatalog catalog;
if (nativeFaction) catalog = nativeFaction.GetFactionEntityCatalogOfType(EEntityCatalogType.ITEM, false);
array<SCR_EntityCatalogEntry> items = {};
if (catalog) catalog.GetEntityList(items);
int limit = Math.Min(items.Count(), 4096);
for (int i = 0; i < limit; i++)
{
if (!items[i] || !items[i].IsEnabled()) continue;
ResourceName candidate = items[i].GetPrefab();
if (ERR_EnemyLoadoutRules.AntiAirName(candidate) && Available(candidate)) { preferred = candidate; break; }
}
}
// WCS supplies these native faction AA soldiers, even if the item catalog is absent.
if (preferred.IsEmpty() && (key == "USSR" || key == "FIA")) preferred = IGLA;
if (!Available(preferred)) preferred = FIM;
s_Launchers.Set(key, preferred);
ERR_HQ.Event("AA PROFILE", "faction=" + key + "; preferred=" + preferred + "; fallback=" + FIM);
return preferred;
}
static bool Loaded(IEntity item)
{
if (!item) return false;
BaseWeaponComponent weapon = BaseWeaponComponent.Cast(item.FindComponent(BaseWeaponComponent));
if (!weapon || weapon.GetWeaponType() != EWeaponType.WT_ROCKETLAUNCHER) return false;
array<BaseMuzzleComponent> muzzles = {}; weapon.GetMuzzlesList(muzzles);
if (muzzles.IsEmpty() || muzzles.Count() > 8) return false;
foreach (BaseMuzzleComponent muzzle : muzzles)
{
if (!muzzle) continue;
for (int barrel = 0; barrel < Math.Min(muzzle.GetBarrelsCount(), 8); barrel++) if (muzzle.IsBarrelChambered(barrel)) return true;
BaseMagazineComponent magazine = muzzle.GetMagazine();
if (magazine && magazine.GetAmmoCount() > 0) return true;
}
return false;
}
static IEntity Find(ResourceName prefab, array<IEntity> items, array<IEntity> excluded)
{
foreach (IEntity item : items)
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == prefab && (!excluded || !excluded.Contains(item))) return item;
return null;
}
}
class ERR_EnemyLauncherGrant
{
protected bool m_Rolled;
protected bool m_Finished;
protected bool m_Fallback;
protected bool m_Removing;
protected int m_Checks;
protected ResourceName m_Prefab;
protected IEntity m_Issued;
protected ref array<IEntity> m_Before = {};
protected ref ERR_EnemyEquipmentCallback m_Operation;
protected ResourceName Preferred(ERR_GameMode mode, SCR_AIGroup group) { return ERR_EnemyLoadoutCatalog.Launcher(mode, group.GetFaction()); }
protected void Finish(IEntity actor, string reason)
{
m_Finished = true;
ERR_HQ.Event("AA EQUIPMENT", string.Format("actor=%1 position=%2 prefab=%3 %4", actor, actor.GetOrigin(), m_Prefab, reason));
}
protected void Fallback(IEntity actor, string reason)
{
if (m_Fallback || m_Prefab == ERR_EnemyLoadoutCatalog.FIM) { Finish(actor, "FIM-92 unavailable/unusable: " + reason); return; }
ERR_HQ.Event("AA FALLBACK", string.Format("actor=%1 rejected=%2 reason=%3; trying FIM-92", actor, m_Prefab, reason));
m_Fallback = true; m_Removing = false; m_Prefab = ERR_EnemyLoadoutCatalog.FIM; m_Operation = null; m_Issued = null; m_Checks = 0;
}
void Check(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
{
if (m_Finished) return;
if (!ERR_EnemyEquipmentRules.Eligible(mode, group, actor)) { m_Finished = true; return; }
if (!m_Rolled)
{
m_Rolled = true;
if (!ERR_EnemyLoadoutRules.Selected(mode.Settings().Runtime("antiAirChancePercent"), Math.RandomInt(0, 100))) { m_Finished = true; return; }
m_Prefab = Preferred(mode, group);
}
if (++m_Checks > 10) { Finish(actor, "bounded startup expired; unresolved operation quarantined, no duplicate grant"); return; }
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
BaseWeaponManagerComponent manager = BaseWeaponManagerComponent.Cast(actor.FindComponent(BaseWeaponManagerComponent));
if (!inventory || !manager) return;
array<IEntity> items = {}; inventory.GetItems(items);
array<IEntity> weapons = {}; manager.GetWeaponsList(weapons);
if (items.Count() > 512 || weapons.Count() > 16) { Finish(actor, "inventory exceeds inspection budget"); return; }
if (m_Removing)
{
if (!m_Operation || !m_Operation.Finished) return;
if (m_Operation.Succeeded && (!m_Issued || !items.Contains(m_Issued))) Fallback(actor, "own unusable replacement removed");
else Finish(actor, "could not confirm removal of unusable replacement; no duplicate grant");
return;
}
foreach (IEntity existing : weapons)
if (existing && existing.GetPrefabData() && (ERR_EnemyLoadoutRules.AntiAirName(existing.GetPrefabData().GetPrefabName()) || existing.GetPrefabData().GetPrefabName() == m_Prefab) && ERR_EnemyLoadoutCatalog.Loaded(existing))
{ Finish(actor, "loaded anti-air weapon physically equipped; preserve it without replenishment"); return; }
if (m_Operation)
{
if (!m_Operation.Finished) return;
m_Issued = ERR_EnemyLoadoutCatalog.Find(m_Prefab, items, m_Before);
if (!m_Issued)
{
if (!m_Operation.Succeeded) Fallback(actor, "native insertion failed");
else Finish(actor, "completed insertion no longer present; may have been looted, no replacement");
return;
}
// Delete only this newly issued, still-owned unusable item, never an authored weapon.
if (m_Fallback || m_Prefab == ERR_EnemyLoadoutCatalog.FIM) { Finish(actor, "FIM-92 inserted but not loaded/equipped; inspect mod compatibility"); return; }
m_Removing = true; m_Checks = 0; m_Operation = new ERR_EnemyEquipmentCallback();
if (!inventory.TryDeleteItem(m_Issued, m_Operation)) Finish(actor, "unusable item removal refused; fallback withheld");
return;
}
if (!ERR_EnemyLoadoutCatalog.Available(m_Prefab)) { Fallback(actor, "resource unavailable"); return; }
m_Before.Clear(); foreach (IEntity before : items) m_Before.Insert(before);
m_Operation = new ERR_EnemyEquipmentCallback();
if (!inventory.TrySpawnPrefabToStorage(m_Prefab, null, -1, EStoragePurpose.PURPOSE_WEAPON_PROXY, m_Operation)) Fallback(actor, "native weapon slot insertion refused");
}
void Stop() { m_Finished = true; }
}
class ERR_EnemyMineGrant
{
protected bool m_Rolled;
protected bool m_Finished;
protected int m_Checks;
protected int m_Remaining;
protected ref ERR_EnemyEquipmentCallback m_Operation;
void Check(ERR_GameMode mode, SCR_AIGroup group, IEntity actor)
{
if (m_Finished) return;
if (!ERR_EnemyEquipmentRules.Eligible(mode, group, actor)) { m_Finished = true; return; }
if (!m_Rolled)
{
m_Rolled = true;
if (!ERR_EnemyLoadoutRules.Selected(mode.Settings().Runtime("antiPersonnelMineChancePercent"), Math.RandomInt(0, 100))) { m_Finished = true; return; }
m_Remaining = 2;
}
if (++m_Checks > 10) { m_Finished = true; ERR_HQ.Event("MINE EQUIPMENT", "startup deadline; pending native operation will not be duplicated"); return; }
InventoryStorageManagerComponent inventory = InventoryStorageManagerComponent.Cast(actor.FindComponent(InventoryStorageManagerComponent));
if (!inventory) return;
if (m_Operation)
{
if (!m_Operation.Finished) return;
if (!m_Operation.Succeeded) { m_Finished = true; ERR_HQ.Event("MINE EQUIPMENT", "inventory insertion failed; no loose mines spawned"); return; }
m_Operation = null;
if (m_Remaining == 0) { m_Finished = true; ERR_HQ.Event("MINE EQUIPMENT", string.Format("actor=%1 position=%2 finite two-mine allowance issued", actor, actor.GetOrigin())); return; }
}
array<IEntity> items = {}; inventory.GetItems(items);
int present;
foreach (IEntity item : items) if (item && item.FindComponent(SCR_AntiPersonnelMineCollisionHandlerComponent)) present++;
m_Remaining = Math.Min(m_Remaining, 2 - present);
if (m_Remaining <= 0) { m_Finished = true; return; }
m_Remaining--; // An accepted grant consumes its allowance even if later dropped or looted.
m_Operation = new ERR_EnemyEquipmentCallback();
if (!inventory.TrySpawnPrefabToStorage(ERR_EnemyLoadoutCatalog.MINE, null, -1, EStoragePurpose.PURPOSE_DEPOSIT, m_Operation))
{ m_Finished = true; ERR_HQ.Event("MINE EQUIPMENT", "no inventory capacity; finite grant refused"); }
}
void Stop() { m_Finished = true; }
}
+24 -5
View File
@@ -92,6 +92,7 @@ class ERR_HQ : ERR_CommandService
protected ref ERR_EnemyEquipment m_Equipment;
protected ref ERR_HunterService m_Hunters;
protected ref ERR_ObjectiveDefence m_ObjectiveDefence;
protected ref ERR_TownGarrisons m_Towns;
protected ref array<SCR_AICombatComponent> m_SpecialSkills = {};
ERR_HunterService Hunters() { return m_Hunters; }
protected ref ERR_ScoutSystem m_Scouts;
@@ -136,6 +137,7 @@ class ERR_HQ : ERR_CommandService
m_Equipment = new ERR_EnemyEquipment(mode);
m_Hunters = new ERR_HunterService(mode, null);
m_ObjectiveDefence = new ERR_ObjectiveDefence(mode, this);
m_Towns = new ERR_TownGarrisons(mode);
m_Scouts = new ERR_ScoutSystem(mode);
m_Recon = new ERR_AirReconSystem(mode);
m_AirAssault = new ERR_AirAssaultSystem(this, mode);
@@ -232,10 +234,15 @@ class ERR_HQ : ERR_CommandService
{
if (m_Units.IsEmpty() || !m_Mode.Forces()) return false;
foreach (ERR_HQUnit unit : m_Units)
{
ERR_MortarBattery support = m_Mortars.FindGroup(unit.Group);
// Failed optional support is terminal, not an outstanding force admission.
if (support && (support.Stopped() || !support.SpawnFailure.IsEmpty())) continue;
if (unit.Group && !m_Mode.Forces().FindGroup(unit.Group)) return false;
}
array<ref ERR_MortarBattery> batteries = {}; m_Mortars.Batteries(batteries);
foreach (ERR_MortarBattery battery : batteries)
if (battery && !battery.Stopped() && !battery.StartupActivated) return false;
if (battery && !battery.Stopped() && battery.SpawnFailure.IsEmpty() && !battery.StartupActivated) return false;
return true;
}
protected void AutomaticConvoy(float now)
@@ -419,7 +426,10 @@ class ERR_HQ : ERR_CommandService
ERR_HQJournal Journal() { return m_Journal; }
string Status()
{
return string.Format("HQ: %1 groups, %2 delivered contacts, %3 assignments", m_Units.Count(), m_Contacts.Count(), Assigned()) + string.Format("; %1 remembered tracks, %2 swept cells; aggression=%3/30 identified bodies=%4; ", m_Memory.Count(), m_Coverage.Count(), m_Watch.Aggression(), m_Watch.IdentifiedBodies()) + m_Hunters.Status() + "; " + m_Mode.Forces().Status() + "; " + m_Mortars.Status() + "; " + m_Logistics.Status() + "; " + m_Resupply.Status() + "; " + m_Recon.Status();
return string.Format("Enemy HQ: %1 registered groups; %2 received sightings; %3 assigned responses.", m_Units.Count(), m_Contacts.Count(), Assigned())
+ string.Format("\nHQ memory: %1 tracked reports; %2 searched areas.\nAggression: %3 of 30, based on %4 identified bodies (one level per three bodies).", m_Memory.Count(), m_Coverage.Count(), m_Watch.Aggression(), m_Watch.IdentifiedBodies())
+ "\n" + m_Hunters.Status() + "\n" + m_Mode.Forces().Status() + "\n" + m_Mortars.Status()
+ "\n" + m_Logistics.Status() + "\n" + m_Resupply.Status() + "\n" + m_Recon.Status();
}
protected void BindPhysical(ERR_HQUnit unit)
@@ -481,8 +491,8 @@ class ERR_HQ : ERR_CommandService
unit.HomeId = home.Definition.Id;
// Objective security is permanent; it can report contacts but cannot be tasked
// with searches, reinforcements, resupply or courier work away from its site.
unit.ReservedForSupport = ERR_ObjectiveDefence.Objective(home.Definition.Kind);
unit.HomeGuard = !home.Definition.Patrol || home.Definition.Patrol.IsEmpty();
unit.ReservedForSupport = group == home.Group && ERR_ObjectiveDefence.Objective(home.Definition.Kind);
unit.HomeGuard = group == home.Group && (!home.Definition.Patrol || home.Definition.Patrol.IsEmpty());
}
int phase = unit.Id % 5;
unit.NextReport = GetGame().GetWorld().GetWorldTime() + phase * 1000;
@@ -493,7 +503,14 @@ class ERR_HQ : ERR_CommandService
unit.ReservedForSupport = true;
BindSpecialSkills(group);
}
else m_Equipment.RegisterGroup(group, unit.Id, !m_Mortars || !m_Mortars.FindGroup(group));
else
{
int equipmentRadioId = unit.Id;
// Widely separated HQ hunters each need a physical manpack uplink.
if (m_Hunters && m_Hunters.Contains(group)) equipmentRadioId = 1;
if (m_Towns && m_Towns.Contains(group)) equipmentRadioId = 1;
m_Equipment.RegisterGroup(group, equipmentRadioId, !m_Mortars || !m_Mortars.FindGroup(group));
}
BindPhysical(unit);
unit.NextStatus = GetGame().GetWorld().GetWorldTime() + 10000 + phase * 1000;
unit.AmmoReporting.Initialize(unit.Id, GetGame().GetWorld().GetWorldTime());
@@ -1215,6 +1232,7 @@ class ERR_HQ : ERR_CommandService
}
m_Hunters.Tick(now);
m_ObjectiveDefence.Tick(now);
m_Towns.Tick(now);
m_Convoys.Tick(now);
AutomaticConvoy(now);
m_Bodies.TickBudget(now);
@@ -1268,6 +1286,7 @@ class ERR_HQ : ERR_CommandService
override void Stop()
{
m_Hunters.Stop(); m_ObjectiveDefence.Stop();
m_Towns.Stop();
foreach (SCR_AICombatComponent specialSkill : m_SpecialSkills) if (specialSkill) specialSkill.ERR_ReleaseEnemySkill(m_Mode);
m_SpecialSkills.Clear();
foreach (ERR_HQUnit detector : m_Units) if (detector.Detection) detector.Detection.GetOnEnemyDetected().Remove(Detected);
@@ -10,8 +10,9 @@ class ERR_HQAdminProvider : ERR_AdminProvider
if (section != "hq") return "";
if (option == "interval") return "reportIntervalSeconds";
if (option == "squads") return "supportSquadLimit";
if (option == "mines") return "antiPersonnelMineChancePercent";
if (option == "radius") return "responseRadiusMetres";
if (option == "stinger") return "stingerChanceOneIn";
if (option == "aa" || option == "stinger") return "antiAirChancePercent";
return "";
}
override int JournalSequence()
@@ -29,9 +30,9 @@ class ERR_HQAdminProvider : ERR_AdminProvider
// reroll registered soldiers or remove/replenish an issued physical launcher.
protected void StingerSetting(ERR_GameMode mode, array<string> parts, array<string> replies)
{
string key = "stingerChanceOneIn";
string key = "antiAirChancePercent";
string effect = "Only eligible actors which have not rolled yet use this chance; no reroll, removal or replenishment of existing equipment.";
string range = "0 off, 1 every eligible soldier, 2..10000 one in N";
string range = "0..100 percent (0 off, 100 every eligible soldier); faction MANPADS preferred, FIM-92 fallback";
if (parts.Count() == 3 && parts[2] == "show")
{ replies.Insert(key + "=" + mode.Settings().Get(key).ToString() + ". " + effect); return; }
int value;
@@ -124,7 +125,7 @@ class ERR_HQAdminProvider : ERR_AdminProvider
replies.Insert("/esc convoys show|set 0..20|random | hq convoy count show|set N (default three; next round)");
replies.Insert("/esc hq show | hq memory | hq log show|next|set on|off | hq convoy show|start");
replies.Insert("/esc hq interval|squads|radius|stinger show|set N | hq convoy auto show|set 0|1|help");
replies.Insert("Stinger: 0 off, 1 every eligible soldier, 2..10000 one in N; applies only before each actor rolls.");
replies.Insert("Stinger: 0..100 percent (0 off, 100 every eligible soldier); faction MANPADS preferred, FIM-92 fallback; applies only before each actor rolls.");
replies.Insert("Convoy auto: default 1, next round, finite fleet. /esc hq heli show | hq heli timer set 1..1440 | hq heli return");
return true;
}
@@ -41,6 +41,9 @@ class ERR_HQJournal
return;
float seconds = GetGame().GetWorld().GetWorldTime() / 1000;
string line = string.Format("#%1 t=%2s M%3 %4 %5", ++m_Sequence, Math.Round(seconds * 10) / 10.0, messageId, kind, message) + " [session=" + m_Session + "]";
// Capture server-local wall time once, so history keeps the event time
// when a client opens the menu later or resumes a paused journal.
line = ERR_JournalText.Entry(m_Sequence, SCR_DateTimeHelper.GetTimeLocal(), kind, message, messageId) + "\n" + line;
if (m_Recent.Count() >= 300)
m_Recent.Remove(0);
m_Recent.Insert(line);
@@ -84,7 +87,7 @@ class ERR_HQJournal
if (!file)
{
m_RetryAt = now + 30000;
PrintFormat("Escapists AI [HQ log]: cannot open %1; retry in 30s, chat history remains available", Path(), level: LogLevel.ERROR);
PrintFormat("Co10 Escape Reintroduced [AI / HQ log]: cannot open %1; retry in 30s, chat history remains available", Path(), level: LogLevel.ERROR);
return;
}
foreach (string line : m_Pending)
@@ -18,7 +18,7 @@ class ERR_HQStatusReport
class ERR_HQWatchRules
{
static int Aggression(int identifiedBodies) { return Math.Clamp(identifiedBodies / 3, 0, 30); }
static int Hunters(int aggression) { return (Math.Clamp(aggression, 0, 30) + 1) / 2; }
static int Hunters(int aggression) { return 3 + (Math.Clamp(aggression, 0, 30) * 12 + 29) / 30; }
static bool Valid(ERR_HQStatusReport report, float now)
{
if (!report || report.SourceId.IsEmpty() || report.SourceId.Length() > 96 || report.MessageId <= 0)
@@ -0,0 +1,86 @@
// Preserve original hunter actors. Reboarding uses native AI tasks, never new
// crew, vehicle teleports, health restoration, or ammunition replacement.
class ERR_HunterCrewRecovery
{
protected ERR_HunterVehicle m_Unit;
protected ref SCR_AIVehicleBehavior m_Boarding;
protected IEntity m_BoardingActor;
protected float m_NextAttempt;
protected string m_LastReason;
protected bool m_Stopped;
string Reason;
void ERR_HunterCrewRecovery(ERR_HunterVehicle unit) { m_Unit = unit; }
void Stop()
{
m_Stopped = true;
if (m_Boarding) m_Boarding.Complete();
m_Boarding = null; m_BoardingActor = null;
}
bool Lost(ERR_GameMode mode)
{
if (!m_Unit.Group || !m_Unit.Entity) { Reason = "group or vehicle removed"; return true; }
if (!ERR_Actors.Alive(m_Unit.Entity)) { Reason = "vehicle destroyed"; return true; }
if (m_Unit.Group.GetFaction() != mode.EnemyFaction()) { Reason = "crew faction changed"; return true; }
foreach (IEntity member : m_Unit.Members)
if (ERR_Actors.Alive(member) && !ERR_Actors.Player(member) && ERR_Actors.Group(member) == m_Unit.Group) return false;
Reason = "no living original crew remain in the group";
return true;
}
protected void Note(string reason)
{
Reason = reason;
if (reason == m_LastReason) return;
m_LastReason = reason;
ERR_HQ.Event("HUNTER CREW", m_Unit.Source + ": " + reason);
}
void Update(ERR_GameMode mode, float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Unit || m_Unit.Released || Lost(mode)) return;
// Activate before admission: distant native compartment-spawn requests need
// live agents to finish. Do not wait for seated readiness to activate them.
m_Unit.Group.SetPermanentLOD(0); m_Unit.Group.ActivateAI();
array<AIAgent> agents = {}; m_Unit.Group.GetAgents(agents);
foreach (AIAgent agent : agents)
if (agent && ERR_Actors.CanFight(agent.GetControlledEntity())) { agent.SetPermanentLOD(0); agent.ActivateAI(); }
SCR_AIGroupUtilityComponent groupUtility = m_Unit.Group.GetGroupUtilityComponent();
SCR_AIVehicleUsageComponent usage = SCR_AIVehicleUsageComponent.Cast(m_Unit.Entity.FindComponent(SCR_AIVehicleUsageComponent));
if (groupUtility && groupUtility.m_VehicleMgr && usage) groupUtility.m_VehicleMgr.TryAddVehicle(usage);
if (m_Unit.CrewValid(mode))
{
if (m_Boarding) m_Boarding.Complete();
m_Boarding = null; m_BoardingActor = null;
Note("driver seated and conscious; patrol can continue"); return;
}
if (m_Unit.Seats.IsEmpty() || !m_Unit.Seats[0]) { Note("waiting for native driver compartment"); return; }
BaseCompartmentSlot seat = m_Unit.Seats[0];
if (!ERR_Actors.Alive(m_Unit.Driver) || ERR_Actors.Group(m_Unit.Driver) != m_Unit.Group)
{
foreach (IEntity survivor : m_Unit.Members)
if (ERR_Actors.CanFight(survivor) && !ERR_Actors.Player(survivor) && ERR_Actors.Group(survivor) == m_Unit.Group)
{ m_Unit.Driver = survivor; break; }
}
if (!ERR_Actors.CanFight(m_Unit.Driver)) { Note("driver incapacitated; retaining vehicle and destination while awaiting recovery"); return; }
if (seat.GetOccupant() && seat.GetOccupant() != m_Unit.Driver)
{ Note("driver seat occupied; original survivor waiting, no occupant forcibly removed"); return; }
SCR_ChimeraAIAgent driver = SCR_ChimeraAIAgent.Cast(SCR_AIUtils.GetAIAgent(m_Unit.Driver));
if (!driver || !driver.m_UtilityComponent) { Note("waiting for original driver's AI utility"); return; }
array<ref AIActionBase> actions = {}; driver.m_UtilityComponent.GetActions(actions);
if (m_Boarding && m_BoardingActor == m_Unit.Driver && actions.Contains(m_Boarding)) return;
if (now < m_NextAttempt) return;
m_NextAttempt = now + 10000;
if (m_Boarding) m_Boarding.Complete();
m_BoardingActor = m_Unit.Driver;
IEntity aboard = CompartmentAccessComponent.GetVehicleIn(m_Unit.Driver);
if (aboard)
{
m_Boarding = new SCR_AIGetOutVehicle(driver.m_UtilityComponent, null, aboard, priority: 1000);
Note("original survivor leaving another seat before taking the driver seat");
}
else
{
m_Boarding = new SCR_AIGetInVehicle(driver.m_UtilityComponent, null, m_Unit.Entity, seat, EAICompartmentType.Pilot, 1000);
Note("original driver ordered to walk to the vehicle and board");
}
driver.m_UtilityComponent.AddAction(m_Boarding);
}
}
@@ -0,0 +1,80 @@
// Deployment geometry only. Pursuit destinations always come from delivered HQ reports.
class ERR_HunterOpeningRules
{
static bool Spread(vector center, vector first, vector second)
{
vector a = first - center; a[1] = 0;
vector b = second - center; b[1] = 0;
return vector.DistanceXZ(center, first) >= 600 && vector.DistanceXZ(center, second) >= 600
&& vector.Dot(a, b) <= 0; // At least ninety degrees between actual road positions.
}
static bool FreshOrder(float observed, float queued, float now, float nextContact, bool pendingContact)
{
return !pendingContact && observed >= 0 && observed > queued && now >= nextContact;
}
static bool Select(vector center, array<vector> candidates, array<int> selected)
{
selected.Clear();
if (candidates.Count() > 64) return false;
for (int i = 0; i < candidates.Count(); i++)
for (int j = i + 1; j < candidates.Count(); j++)
{
if (!Spread(center, candidates[i], candidates[j])) continue;
for (int k = j + 1; k < candidates.Count(); k++)
if (Spread(center, candidates[i], candidates[k]) && Spread(center, candidates[j], candidates[k]))
{ selected.Insert(i); selected.Insert(j); selected.Insert(k); return true; }
}
return false;
}
}
class ERR_HunterOpeningPlan
{
vector Center;
ref array<vector> Positions = {};
ref array<vector> Angles = {};
protected ref array<vector> m_Candidates = {};
protected ref array<vector> m_Angles = {};
protected int m_Scanned;
protected bool m_Done;
protected float m_Heading;
void ERR_HunterOpeningPlan(vector center) { Center = center; m_Heading = Math.RandomFloat(0, 360); }
bool Ready() { return Positions.Count() == 3; }
bool Done() { return m_Done; }
static bool AwayFromPlayers(vector position, array<IEntity> players)
{
foreach (IEntity player : players) if (player && vector.DistanceXZ(player.GetOrigin(), position) < 400) return false;
return true;
}
void Step(array<IEntity> players)
{
if (m_Done) return;
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
// Eight bounded road/footprint queries per HQ second; four rings, sixteen bearings.
for (int work = 0; work < 8 && m_Scanned < 64; work++)
{
int candidate = m_Scanned++;
int ring = candidate / 16;
int sector = candidate % 16;
float radius = 800 + ring * 450;
float bearing = (m_Heading + sector * 22.5) * Math.DEG2RAD;
vector seed = Center + Vector(Math.Sin(bearing) * radius, 0, Math.Cos(bearing) * radius);
vector position, angles;
if (!ERR_SupplyConvoySystem.ApproachDirected(seed, position, angles, 300)) continue;
if (vector.DistanceXZ(Center, position) < 600 || !AwayFromPlayers(position, players)) continue;
if (!ERR_SupplyConvoyRules.Separated(position, m_Candidates, 100) || !placement.Clear(position, angles[0])) continue;
m_Candidates.Insert(position); m_Angles.Insert(angles);
}
array<int> selected = {};
if (ERR_HunterOpeningRules.Select(Center, m_Candidates, selected))
{
foreach (int index : selected) { Positions.Insert(m_Candidates[index]); Angles.Insert(m_Angles[index]); }
m_Done = true;
ERR_HQ.Event("HUNTER OPENING", "three clear road starts reserved at least 90 degrees apart; center=" + Center.ToString());
}
else if (m_Scanned == 64)
{
m_Done = true;
ERR_HQ.Event("HUNTER OPENING FAILED", "no three safe, widely separated road starts in 64 candidates; unsafe spawns withheld");
}
}
}
@@ -6,9 +6,23 @@ class ERR_HunterVehicle : ERR_ConvoyVehicle
ref ERR_HQRoute Downlink;
vector Destination;
int SourceMessage;
float LastContact = -1;
float NextContactOrder;
ref ERR_HunterCrewRecovery Recovery;
float NextMotionLog;
float ProgressAt;
vector ProgressPosition;
int Repaths;
override bool CrewValid(ERR_GameMode mode)
{
return Group && Entity && ERR_Actors.Alive(Entity) && Group.GetFaction() == mode.EnemyFaction()
&& Members.Contains(Driver) && ERR_Actors.CanFight(Driver) && !ERR_Actors.Player(Driver)
&& ERR_Actors.Group(Driver) == Group && Seats.Count() > 0 && Seats[0].GetOccupant() == Driver;
}
override void Release(ERR_GameMode mode)
{
if (Released) return;
if (Recovery) Recovery.Stop();
if (!mode || !mode.Active() || ERR_ConvoyRules.Captured(Entity, Group)) { super.Release(mode); return; }
Released = true; Downlink = null; ClearOrder();
ERR_ForceRecord record = mode.Forces().FindGroup(Group);
@@ -33,7 +47,13 @@ class ERR_HunterService : ERR_ConvoyService
protected ref array<ref ERR_HunterVehicle> m_Hunters = {};
protected float m_NextHunter;
protected float m_NextSpawn;
protected int m_Target;
protected int m_Target = 3;
protected ref ERR_HunterOpeningPlan m_Opening;
bool Contains(SCR_AIGroup group)
{
foreach (ERR_HunterVehicle unit : m_Hunters) if (unit.Group == group) return true;
return false;
}
int Deployed() { return m_Hunters.Count(); }
int ActiveHunters()
{
@@ -41,12 +61,22 @@ class ERR_HunterService : ERR_ConvoyService
foreach (ERR_HunterVehicle unit : m_Hunters) if (!unit.Released) count++;
return count;
}
override string Status() { return string.Format("aggression UAZs: %1 active, %2/%3 deployed allowance (maximum 15)", ActiveHunters(), Deployed(), m_Target); }
override string Status() { return string.Format("HQ UAZ patrols: %1 active; %2 deployed from a total allowance of %3 (maximum 15). Lost vehicles are not replaced.", ActiveHunters(), Deployed(), m_Target); }
protected bool StartHunter(ERR_HQ hq, float now)
{
if (m_Hunters.Count() >= m_Target || m_Hunters.Count() >= 15 || m_Mode.Forces().Count() >= 250) return false;
array<IEntity> players = {}; ERR_Actors.Players(players);
ERR_ConvoyPlacement placement = new ERR_ConvoyPlacement();
if (m_Hunters.Count() < 3)
{
if (!m_Opening || !m_Opening.Ready()) return false;
int slot = m_Hunters.Count();
vector openingPosition = m_Opening.Positions[slot];
vector openingAngles = m_Opening.Angles[slot];
if (!ERR_HunterOpeningPlan.AwayFromPlayers(openingPosition, players) || !placement.Clear(openingPosition, openingAngles[0]))
{ ERR_HQ.Event("HUNTER WAIT", "reserved opening road occupied; no unsafe spawn"); return false; }
return SpawnHunter(hq, now, openingPosition, openingAngles, "prison-opening");
}
for (int i = 0; i < m_Mode.Sites().Count(); i++)
{
ERR_SiteRuntime site = m_Mode.Sites().At((i + m_Hunters.Count()) % m_Mode.Sites().Count());
@@ -58,25 +88,31 @@ class ERR_HunterService : ERR_ConvoyService
bool nearPlayer;
foreach (IEntity player : players) if (player && vector.DistanceXZ(player.GetOrigin(), position) < 400) nearPlayer = true;
if (nearPlayer) continue;
ERR_HunterVehicle unit = new ERR_HunterVehicle();
unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString();
unit.Deadline = now + 30000; m_Hunters.Insert(unit);
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), position));
unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(0), position, angles));
if (unit.Group) m_Mode.Own(unit.Group);
if (unit.Entity) m_Mode.Own(unit.Entity);
if (!unit.Group || !unit.Entity || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
{ unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native vehicle/crew spawn failed; allowance consumed"); return false; }
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(unit.Entity.FindComponent(FactionAffiliationComponent));
if (affiliation) affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(45);
ERR_HQ.Event("HUNTER SPAWN", string.Format("%1 reason=identified bodies %2 aggression=%3/30 allowance=%4/15 base=%5 position=%6 crew=%7", unit.Source, hq.Watch().IdentifiedBodies(), hq.Watch().Aggression(), m_Target, site.Definition.Id, position, unit.Members.Count()));
return true;
return SpawnHunter(hq, now, position, angles, site.Definition.Id);
}
ERR_HQ.Event("HUNTER WAIT", "deployment deferred: friendly road obstructed, occupied, budget full or players within 400m");
return false;
}
protected bool SpawnHunter(ERR_HQ hq, float now, vector position, vector angles, string source)
{
ERR_HunterVehicle unit = new ERR_HunterVehicle();
unit.Source = "aggression-uaz-" + (m_Hunters.Count() + 1).ToString();
unit.Deadline = now + 120000; m_Hunters.Insert(unit);
unit.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.GroupPrefab(), position));
unit.Entity = Vehicle.Cast(ERR_Actors.Spawn(ERR_ConvoyResources.VehiclePrefab(0), position, angles));
if (unit.Group) m_Mode.Own(unit.Group);
if (unit.Entity) m_Mode.Own(unit.Entity);
if (!unit.Group || !unit.Entity || !ERR_ConvoyRules.EmptyCrewTemplate(unit.Group) || !FillCrew(unit))
{ unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native vehicle/crew spawn failed; allowance consumed"); return false; }
unit.Recovery = new ERR_HunterCrewRecovery(unit);
unit.Recovery.Update(m_Mode, now);
FactionAffiliationComponent affiliation = FactionAffiliationComponent.Cast(unit.Entity.FindComponent(FactionAffiliationComponent));
if (affiliation) affiliation.SetAffiliatedFaction(m_Mode.EnemyFaction());
AICarMovementComponent movement = AICarMovementComponent.Cast(unit.Entity.FindComponent(AICarMovementComponent));
if (movement) movement.SetCruiseSpeed(45);
ERR_HQ.Event("HUNTER SPAWN", string.Format("%1 identified bodies=%2 aggression=%3/30 allowance=%4/15 base=%5 position=%6 crew=%7", unit.Source, hq.Watch().IdentifiedBodies(), hq.Watch().Aggression(), m_Target, source, position, unit.Members.Count()));
return true;
}
protected bool AdmitHunter(ERR_HunterVehicle unit, ERR_HQ hq, float now)
{
if (!unit.CrewValid(m_Mode)) return false;
@@ -97,8 +133,6 @@ class ERR_HunterService : ERR_ConvoyService
}
protected void QueueDestination(ERR_HunterVehicle unit, ERR_HQ hq, float now)
{
if (now < unit.NextOrder || unit.Downlink) return;
unit.NextOrder = now + 30000;
array<ref ERR_HQReport> reports = {}; hq.SupportContacts(reports, now);
ERR_HQBelief best;
foreach (ERR_HQReport report : reports)
@@ -106,12 +140,18 @@ class ERR_HunterService : ERR_ConvoyService
ERR_HQBelief belief = hq.Memory().Get(report.Target);
if (belief && belief.Actionable() && (!best || belief.ObservedAt > best.ObservedAt)) best = belief;
}
bool freshContact;
if (best) freshContact = ERR_HunterOpeningRules.FreshOrder(best.ObservedAt, unit.LastContact, now, unit.NextContactOrder, unit.Downlink && unit.SourceMessage > 0);
// A first/new delivered sighting preempts routine patrol, including an in-flight patrol order.
if (freshContact && unit.Downlink && unit.SourceMessage == 0) unit.Downlink = null;
if (unit.Downlink || (!freshContact && now < unit.NextOrder)) return;
unit.NextOrder = now + 30000;
vector destination, angles;
string reason = "road patrol between friendly objectives";
unit.SourceMessage = 0;
if (best)
{
if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 500)) return;
if (!ERR_SupplyConvoySystem.ApproachDirected(best.PredictedPosition, destination, angles, 250)) return;
unit.SourceMessage = best.MessageId; reason = "intercept last delivered sighting; confidence=" + best.Confidence.ToString();
}
else
@@ -128,6 +168,11 @@ class ERR_HunterService : ERR_ConvoyService
}
unit.Destination = destination;
unit.Downlink = hq.Downlink(unit.Group, "HUNTER", unit.Source + " " + reason + " destination=" + destination.ToString(), unit.SourceMessage);
if (best)
{
unit.NextContactOrder = now + 5000;
if (unit.Downlink) unit.LastContact = best.ObservedAt;
}
}
override void Tick(float now)
{
@@ -135,18 +180,46 @@ class ERR_HunterService : ERR_ConvoyService
m_NextHunter = now + 1000;
ERR_HQ hq = ERR_HQ.Cast(m_Mode.Command()); if (!hq) return;
m_Target = hq.Watch().HunterTarget();
if (now >= m_NextSpawn && Deployed() < m_Target) { m_NextSpawn = now + 30000; StartHunter(hq, now); }
if (!m_Opening)
{
array<IEntity> openingPlayers = {}; ERR_Actors.Players(openingPlayers);
vector center = m_Mode.PrisonPosition(); vector sum; int count;
foreach (IEntity player : openingPlayers) if (player) { sum += player.GetOrigin(); count++; }
if (count > 0) center = sum / count;
m_Opening = new ERR_HunterOpeningPlan(center);
}
if (!m_Opening.Done())
{
array<IEntity> currentPlayers = {}; ERR_Actors.Players(currentPlayers);
m_Opening.Step(currentPlayers);
}
if (now >= m_NextSpawn && Deployed() < m_Target)
{
m_NextSpawn = now + 30000;
if (Deployed() < 3) m_NextSpawn = now + 1000;
StartHunter(hq, now);
}
foreach (ERR_HunterVehicle unit : m_Hunters)
{
if (unit.Released) continue;
if (ERR_ConvoyRules.Captured(unit.Entity, unit.Group)) { unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + " player captured vehicle/crew; orders removed"); continue; }
if (unit.Recovery && unit.Recovery.Lost(m_Mode))
{
string loss = unit.Recovery.Reason;
unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + ": " + loss + "; surviving actors and vehicle retained"); continue;
}
if (unit.Recovery) unit.Recovery.Update(m_Mode, now);
if (!unit.Registered)
{
if (AdmitHunter(unit, hq, now)) continue;
if (now >= unit.Deadline) { InspectReadiness(unit, now); unit.Release(m_Mode); ERR_HQ.Event("HUNTER FAILED", unit.Source + " native readiness timed out"); }
continue;
}
if (!unit.CrewValid(m_Mode)) { unit.Release(m_Mode); ERR_HQ.Event("HUNTER RELEASE", unit.Source + " vehicle or original crew no longer operational; no replacement spawn"); continue; }
// A temporary unconscious/dismounted driver suspends driving without
// throwing away the delivered destination or the original physical crew.
if (!unit.CrewValid(m_Mode)) continue;
ObserveMovement(unit, now);
QueueDestination(unit, hq, now);
if (unit.Downlink)
{
int delivered = unit.Downlink.Advance(now, hq.Journal());
@@ -157,14 +230,42 @@ class ERR_HunterService : ERR_ConvoyService
unit.ClearOrder();
unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), unit.Destination));
if (unit.Waypoint) { unit.Waypoint.SetCompletionRadius(50); unit.Group.AddWaypoint(unit.Waypoint); }
else { ERR_HQ.Event("HUNTER ORDER FAILED", unit.Source + ": move waypoint could not be created"); unit.Downlink = null; continue; }
ERR_HQ.Event("HUNTER ORDER", unit.Source + " radio delivered; destination=" + unit.Destination.ToString(), unit.SourceMessage);
}
unit.Downlink = null;
}
}
QueueDestination(unit, hq, now);
}
}
protected void ObserveMovement(ERR_HunterVehicle unit, float now)
{
vector position = unit.Entity.GetOrigin();
if (unit.ProgressAt == 0 || vector.DistanceXZ(position, unit.ProgressPosition) >= 15)
{ unit.ProgressAt = now; unit.ProgressPosition = position; unit.Repaths = 0; }
SCR_AIGroupUtilityComponent utility = unit.Group.GetGroupUtilityComponent();
bool combat = utility && utility.m_Perception && !utility.m_Perception.m_aTargets.IsEmpty();
bool travelling = unit.Waypoint && !unit.Waypoint.IsWithinCompletionRadius(unit.Group);
if (!travelling || combat) unit.ProgressAt = now;
if (now >= unit.NextMotionLog)
{
unit.NextMotionLog = now + 30000;
float speed;
if (unit.Entity.GetPhysics()) speed = unit.Entity.GetPhysics().GetVelocity().Length();
vector deliveredDestination;
if (unit.Waypoint) deliveredDestination = unit.Waypoint.GetOrigin();
ERR_HQ.Event("HUNTER MOVEMENT", string.Format("%1 position=%2 speed=%3m/s delivered destination=%4 travelling=%5 combat=%6 no progress=%7s replans=%8", unit.Source, position, speed, deliveredDestination, travelling, combat, (now - unit.ProgressAt) / 1000, unit.Repaths));
}
if (!travelling || combat || now - unit.ProgressAt < 60000 || unit.Repaths >= 3) return;
unit.ProgressAt = now; unit.Repaths++;
// Destination can already contain a newer order awaiting radio delivery.
// Retry only the waypoint the crew has actually received.
vector retryDestination = unit.Waypoint.GetOrigin();
unit.ClearOrder();
unit.Waypoint = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.MoveWaypoint(), retryDestination));
if (unit.Waypoint) { unit.Waypoint.SetCompletionRadius(50); unit.Group.AddWaypoint(unit.Waypoint); }
ERR_HQ.Event("HUNTER REPATH", unit.Source + ": no movement for 60 seconds; native route requested again, attempt " + unit.Repaths.ToString());
}
override void Stop()
{
m_Stopped = true;
@@ -23,6 +23,7 @@ class ERR_MortarLogistics
protected ERR_SiteRuntime m_Site;
protected IEntity m_Cache;
protected SCR_AIGroup m_Group;
protected ERR_SiteRuntime m_CrewSite;
protected ERR_MortarBattery m_Battery;
protected IEntity m_OriginalGun;
protected SCR_AIGroup m_OriginalBatteryGroup;
@@ -88,6 +89,15 @@ class ERR_MortarLogistics
foreach (IEntity item : items)
if (item && item.GetPrefabData() && item.GetPrefabData().GetPrefabName() == ERR_MortarBattery.SHELL) shells.Insert(item);
}
static ERR_SiteRuntime CourierSource(ERR_SiteRuntime site)
{
if (!site) return null;
if (site.Responders) return site.Responders;
// Historical sites explicitly authored a roaming patrol. A permanent-only
// garrison is never silently substituted when a mobile team is unavailable.
if (site.PatrolCycle) return site;
return null;
}
bool Prepare()
{
if (!Replication.IsServer() || m_Started || m_Stopped) return false;
@@ -98,8 +108,10 @@ class ERR_MortarLogistics
// Production uses the existing guard outpost; no replacement car/cache is created.
m_Site = scenario.Sites().Find("everon-supply-2");
if (!m_Site) m_Site = scenario.Sites().Find("everon-outpost-1");
if (!m_Site || !m_Site.Group || !m_Site.PatrolCycle || !scenario.Sites().Owns(m_Site.Container)) return false;
m_Cache = m_Site.Container; m_Group = m_Site.Group;
if (!m_Site || !scenario.Sites().Owns(m_Site.Container)) return false;
m_CrewSite = CourierSource(m_Site);
if (!m_CrewSite || !m_CrewSite.Group) { Print("Co10 Escape Reintroduced [mortar supplies]: source needs a mobile team; permanent defenders cannot be borrowed", LogLevel.ERROR); return false; }
m_Cache = m_Site.Container; m_Group = m_CrewSite.Group;
array<ref ERR_MortarBattery> batteries = {}; m_HQ.Mortars().Batteries(batteries);
if (batteries.IsEmpty()) return false;
m_Battery = batteries[0]; m_OriginalGun = m_Battery.Gun; m_OriginalBatteryGroup = m_Battery.Group;
@@ -112,7 +124,7 @@ class ERR_MortarLogistics
if (!agent || !ERR_Actors.CanFight(agent.GetControlledEntity())) return false;
m_OriginalMembers.Insert(agent.GetControlledEntity());
}
if (m_OriginalMembers.Count() != m_Site.InitialGuardCount) return false;
if (m_OriginalMembers.Count() != m_CrewSite.InitialGuardCount) return false;
m_InitialDeadline = GetGame().GetWorld().GetWorldTime() + 15000;
// SiteService owns this existing cache and its contents through rollback/world teardown.
// This bounded startup allocation is never reopened, including partial failure/theft.
@@ -151,7 +163,7 @@ class ERR_MortarLogistics
}
protected bool OriginalGroup()
{
if (!m_Site || m_Site.Group != m_Group || m_Site.Container != m_Cache || !m_Group || !m_Cache || m_Group.GetPlayerCount(true) > 0 || m_Group.GetFaction() != m_Mode.EnemyFaction()) return false;
if (!m_Site || CourierSource(m_Site) != m_CrewSite || !m_CrewSite || m_CrewSite.Group != m_Group || m_Site.Container != m_Cache || !m_Group || !m_Cache || m_Group.GetPlayerCount(true) > 0 || m_Group.GetFaction() != m_Mode.EnemyFaction()) return false;
array<AIAgent> agents = {}; m_Group.GetAgents(agents); bool living;
foreach (AIAgent agent : agents)
{
@@ -30,6 +30,7 @@ class ERR_MortarReadiness
protected ERR_MortarBattery m_Battery;
protected ref array<IEntity> m_Members = {};
protected ref array<ref ERR_MortarInitialItem> m_Operations = {};
protected float m_StartedAt = -1;
protected bool m_Allocated;
protected bool m_Ready;
protected bool m_Stopped;
@@ -106,7 +107,10 @@ class ERR_MortarReadiness
if (!mode || !group || !m_Battery.Gun || group.GetFaction() != mode.EnemyFaction() || group.GetPlayerCount(true) > 0) return Fail("initial support group/gun/faction changed");
DamageManagerComponent damage = DamageManagerComponent.Cast(m_Battery.Gun.FindComponent(DamageManagerComponent));
if (!damage || damage.IsDestroyed()) return Fail("initial support gun missing damage manager or destroyed");
bool expired = now < m_Battery.SpawnedAt || now - m_Battery.SpawnedAt >= 90000;
// HQ is paused in prison. Time before the first eligible initialization poll
// must not consume the finite native expansion/inventory deadline.
if (m_StartedAt < 0) m_StartedAt = now;
bool expired = now < m_StartedAt || now - m_StartedAt >= 90000;
array<AIAgent> agents = {}; group.GetAgents(agents);
array<IEntity> physical = {};
int gunners, operators;
@@ -354,7 +354,11 @@ class ERR_MortarSystem
if (battery.StartupActivated && battery.Operational()) ready++;
shells += battery.CountShells();
}
return string.Format("mortars %1/%2; HE shells %3", ready, m_Batteries.Count(), shells);
string text = string.Format("Mortars: %1 of %2 operational; %3 HE shells in inventory.", ready, m_Batteries.Count(), shells);
ERR_GameMode mode = ERR_GameMode.Get();
if (mode && mode.Phase() <= ERR_Phase.PRISON) text += " Crews activate after the prison gate is unlocked.";
else if (ready < m_Batteries.Count()) text += " Other batteries are still preparing or unavailable; check the journal.";
return text;
}
void Stop()
{
@@ -17,7 +17,7 @@ modded class ERR_GameMode
super.EOnInit(owner);
string outcome;
if (!Replication.IsServer() || SCR_Global.IsEditMode() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "mortar-station") return;
m_ERRStationDeadline = System.GetTickCount() + 180000;
m_ERRStationDeadline = System.GetTickCount() + 300000;
GetGame().GetCallqueue().CallLater(ERR_StationProbeTick, 500, true);
}
protected bool ERR_StationCheck(bool condition, string detail)
@@ -35,6 +35,7 @@ modded class ERR_GameMode
float now = GetWorld().GetWorldTime();
if (m_ERRStationStage == 0)
{
if (now < 110000) return; // Reproduce a real session spending over 90 seconds in prison.
array<ref ERR_MortarBattery> batteries = {}; ERR_MortarSystem.Get().Batteries(batteries);
if (batteries.IsEmpty()) return;
m_ERRStationBattery = batteries[0];
@@ -45,6 +46,12 @@ modded class ERR_GameMode
ERR_MortarStation station = m_ERRStationBattery.ERR_StationProbe();
if (m_ERRStationStage == 1)
{
array<ref ERR_MortarBattery> initial = {}; ERR_MortarSystem.Get().Batteries(initial);
foreach (ERR_MortarBattery battery : initial)
{
if (!ERR_StationCheck(battery.SpawnFailure.IsEmpty(), "delayed prison release preserves mortar initialization deadline")) return;
if (!battery.StartupActivated) return;
}
if (!m_ERRStationBattery.StartupActivated || !station) return;
m_ERRStationGunner = station.Gunner();
if (!m_ERRStationGunner || CompartmentAccessComponent.GetVehicleIn(m_ERRStationGunner) != m_ERRStationBattery.Gun) return;
@@ -35,7 +35,7 @@ class ERR_NativeScoutProbe
if (matches < 1 || matches != post.Magazines) return false;
for (int other = 0; other < index; other++)
if (vector.DistanceXZ(post.Position, scouts.At(other).Position) < 900) return false;
PrintFormat("Escapists [scout probe]: PASS post=%1 height=%2 full magazines=%3 physical manpack, return fire, reserved", index + 1, post.Position[1], matches);
PrintFormat("Co10 Escape Reintroduced [scout probe]: PASS post=%1 height=%2 full magazines=%3 physical manpack, return fire, reserved", index + 1, post.Position[1], matches);
}
ERR_HQReport report = new ERR_HQReport(); report.RoadStatus = "on road";
ERR_HQReport snapshot = report.Snapshot(); report.RoadStatus = "off road";
@@ -47,7 +47,7 @@ class ERR_NativeScoutProbe
if (!after.HasVelocity || after.Velocity != "1 0 0" || after.TravelDirection() != "E") return false;
after.ObservedAt = 42000; ERR_ScoutMotion.Estimate(after, before);
if (after.HasVelocity) return false;
Print("Escapists [scout probe]: PASS observed motion, direction, stale-sighting refusal and immutable road snapshot");
Print("Co10 Escape Reintroduced [scout probe]: PASS observed motion, direction, stale-sighting refusal and immutable road snapshot");
return true;
}
}
@@ -0,0 +1,128 @@
#ifdef WORKBENCH
class ERR_NativeTownService : ERR_TownGarrisons
{
ref array<vector> Blockers = {};
ERR_TownGarrison Selected;
vector Approach;
protected override void Players(out array<vector> positions)
{
super.Players(positions);
foreach (vector blocker : Blockers) positions.Insert(blocker);
}
bool Select(int count)
{
if (m_Towns.IsEmpty())
{
vector low, high; GetGame().GetWorldEntity().GetWorldBounds(low, high);
GetGame().GetWorld().QueryEntitiesByAABB(low - "0 5000 0", high + "0 5000 0", Remember, Settlement, EQueryEntitiesFlags.ALL);
}
foreach (ERR_TownGarrison town : m_Towns)
{
if (town.Finished) continue;
for (int i = 0; i < 24; i++)
{
float angle = i * 15 * Math.DEG2RAD;
vector point = ERR_Actors.Ground(town.Centre + Vector(Math.Cos(angle) * 690, 0, Math.Sin(angle) * 690));
if (!m_Placement.Clear(point, null)) continue;
Selected = town; town.Count = count; Approach = point; return true;
}
}
return false;
}
override void Tick(float now)
{
if (m_Stopped || !Selected || now < m_Next) return;
m_Next = now + 2000;
array<vector> players = {}; Players(players);
Attempt(Selected, players, now);
}
bool BirthsSafe()
{
if (!Selected || !Selected.Admitted || m_Mode.ForceLiving(Selected.Group) != Selected.Count) return false;
array<vector> players = {}; Players(players);
array<AIAgent> agents = {}; Selected.Group.GetAgents(agents);
foreach (AIAgent agent : agents)
if (!agent || !agent.GetControlledEntity() || !ERR_TownSpawnRules.Safe(agent.GetControlledEntity().GetOrigin(), players)) return false;
return true;
}
void ClearSquad()
{
ERR_Actors.DeleteSpawned(Selected.Group); Selected.Group = null;
}
}
modded class ERR_HQ
{
void ERR_ProbeTowns(ERR_TownGarrisons service) { m_Towns.Stop(); m_Towns = service; }
bool ERR_ProbeTownLinked(SCR_AIGroup group)
{
foreach (ERR_HQUnit unit : m_Units)
if (unit.Group == group) return !unit.HomeGuard && !unit.ReservedForSupport && unit.Eligible(m_Mode) && unit.StatusSampler.Bound();
return false;
}
}
modded class ERR_GameMode
{
protected ref ERR_NativeTownService m_ERRTownProbe;
protected int m_ERRTownStage, m_ERRTownDeadline;
protected float m_ERRTownWait;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
string outcome;
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !System.GetCLIParam("errSmokeOutcome", outcome) || outcome != "town-garrisons") return;
m_ERRTownDeadline = System.GetTickCount() + 240000;
GetGame().GetCallqueue().CallLater(ERR_TownProbeTick, 250, true);
}
protected bool ERR_TownCheck(bool ok, string message)
{
if (ok) { Print("ERR SMOKE PASS: towns " + message); return true; }
Print("ERR SMOKE FAIL: towns " + message, LogLevel.ERROR);
GetGame().GetCallqueue().Remove(ERR_TownProbeTick); return false;
}
protected void ERR_TownProbeTick()
{
if (System.GetTickCount() >= m_ERRTownDeadline || !PreparationError().IsEmpty()) { ERR_TownCheck(false, "startup/test deadline: " + PreparationError()); return; }
if (!Active() || !GetGame().GetPlayerController()) return;
IEntity player = GetGame().GetPlayerController().GetControlledEntity();
if (!player) return;
DamageManagerComponent.Cast(player.FindComponent(DamageManagerComponent)).EnableDamageHandling(false);
ERR_HQ hq = ERR_HQ.Get(); if (!hq) return;
float now = GetWorld().GetWorldTime();
if (now < m_ERRTownWait) return;
if (m_ERRTownStage == 0)
{
m_ERRTownProbe = new ERR_NativeTownService(this); hq.ERR_ProbeTowns(m_ERRTownProbe);
if (!ERR_TownCheck(m_ERRTownProbe.Select(3), "found native named settlement with clear approach")) return;
if (!ERR_TownCheck(UnlockPrison(player), "normal prison release")) return;
player.SetOrigin(m_ERRTownProbe.Approach);
// A second teammate inside the town must veto the entire spawn, even
// while the approaching player satisfies the activation trigger.
m_ERRTownProbe.Blockers.Insert(m_ERRTownProbe.Selected.Centre);
m_ERRTownWait = now + 6000; m_ERRTownStage = 1; return;
}
if (m_ERRTownStage == 1)
{
if (!ERR_TownCheck(!m_ERRTownProbe.Selected.Admitted && (!m_ERRTownProbe.Selected.Group || m_ERRTownProbe.Selected.Group.GetAgentsCount() == 0), "nearby second teammate blocks all native character creation")) return;
m_ERRTownProbe.Blockers.Clear(); m_ERRTownStage = 2; return;
}
if (m_ERRTownStage == 2 || m_ERRTownStage == 4)
{
if (!m_ERRTownProbe.Selected.Admitted) return;
if (!ERR_TownCheck(m_ERRTownProbe.BirthsSafe(), string.Format("%1 physical soldiers all at least 700 m from player", m_ERRTownProbe.Selected.Count))) return;
if (!ERR_TownCheck(hq.ERR_ProbeTownLinked(m_ERRTownProbe.Selected.Group), "squad bound to VirtualHQ reporting and available for hunt orders")) return;
if (!ERR_TownCheck(m_ERRTownProbe.Selected.Group.m_aUnitPrefabSlots.IsEmpty(), "no native expansion slots or delayed replacement soldiers")) return;
m_ERRTownProbe.ClearSquad(); m_ERRTownWait = now + 5000; m_ERRTownStage++; return;
}
if (m_ERRTownStage == 3)
{
if (!ERR_TownCheck(!m_ERRTownProbe.Selected.Group && m_ERRTownProbe.Selected.Finished, "eliminated three-man squad is not replaced")) return;
if (!ERR_TownCheck(m_ERRTownProbe.Select(10), "second distinct native town selected")) return;
player.SetOrigin(m_ERRTownProbe.Approach); m_ERRTownStage = 4; return;
}
if (!ERR_TownCheck(!m_ERRTownProbe.Selected.Group && m_ERRTownProbe.Selected.Finished, "eliminated ten-man squad is not replaced")) return;
m_ERRTownProbe.Stop();
GetGame().GetCallqueue().Remove(ERR_TownProbeTick);
Print("ERR SMOKE COMPLETE: native towns, 3 and 10 soldiers, split-player exclusion, HQ integration and no replacement waves");
}
}
#endif
@@ -24,7 +24,7 @@ class ERR_ObjectiveDefence
static bool Objective(int kind)
{
return kind == ERR_LocationKind.RADIO || kind == ERR_LocationKind.DEPOT
|| kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.EXTRACTION;
|| kind == ERR_LocationKind.HOSPITAL || kind == ERR_LocationKind.OUTPOST || kind == ERR_LocationKind.EXTRACTION;
}
protected void Initialize()
{
@@ -40,9 +40,9 @@ class ERR_ReconAirports
m_bComplete = m_vCursor[2] >= m_vMax[2];
if (m_bComplete)
{
PrintFormat("Escapists AI [air recon]: airport search finished; airports=%1", m_Positions.Count());
PrintFormat("Co10 Escape Reintroduced [AI / air recon]: airport search finished; airports=%1", m_Positions.Count());
for (int airportIndex = 0; airportIndex < m_Positions.Count(); airportIndex++)
PrintFormat("Escapists AI [air recon]: airport=%1 centre=%2 runway/tower samples=%3", airportIndex, m_Positions[airportIndex], m_PositionSamples[airportIndex]);
PrintFormat("Co10 Escape Reintroduced [AI / air recon]: airport=%1 centre=%2 runway/tower samples=%3", airportIndex, m_Positions[airportIndex], m_PositionSamples[airportIndex]);
}
}
@@ -74,7 +74,7 @@ class ERR_ReconAirports
}
m_Positions.Insert(entity.GetOrigin());
m_PositionSamples.Insert(1);
PrintFormat("Escapists AI [air recon]: airport structure=%1 position=%2", entity.GetPrefabData().GetPrefabName(), entity.GetOrigin());
PrintFormat("Co10 Escape Reintroduced [AI / air recon]: airport structure=%1 position=%2", entity.GetPrefabData().GetPrefabName(), entity.GetOrigin());
return true;
}
@@ -130,7 +130,7 @@ class ERR_ScoutSystem
if (ERR_ScoutTerrain.Separated(point, m_Posts, m_Definition) && ERR_ScoutTerrain.Survey(point, m_IslandHigh, heading))
{
ERR_ScoutPost post = new ERR_ScoutPost(); post.Position = point; post.Heading = heading; m_Posts.Insert(post);
PrintFormat("Escapists [scout survey]: post=%1 position=%2 heading=%3", m_Posts.Count(), point, heading);
PrintFormat("Co10 Escape Reintroduced [scout survey]: post=%1 position=%2 heading=%3", m_Posts.Count(), point, heading);
}
if (System.GetTickCount() - tick >= 6) return ERR_PrepareResult.PENDING;
}
@@ -218,7 +218,7 @@ class ERR_ScoutSystem
record.Pinned = true;
m_Mode.RegisterGroup(post.Group);
post.Ready = true;
PrintFormat("Escapists [scout ready]: position=%1 backpack magazines=%2", post.Position, post.Magazines);
PrintFormat("Co10 Escape Reintroduced [scout ready]: position=%1 backpack magazines=%2", post.Position, post.Magazines);
return ERR_PrepareResult.READY;
}
ERR_PrepareResult Prepare(ERR_ScenarioDefinition definition)
@@ -0,0 +1,217 @@
// Town activation is only a population trigger, never an observation for HQ.
class ERR_TownSpawnRules
{
static const float DISTANCE = 700;
static const float TOWN_RADIUS = 200;
static bool Safe(vector position, array<vector> players, float minimum = 700)
{
if (!players || players.IsEmpty() || !ERR_ForceRules.FiniteVector(position)) return false;
foreach (vector player : players)
if (!ERR_ForceRules.FiniteVector(player) || vector.DistanceXZ(position, player) < minimum) return false;
return true;
}
static bool Approached(vector centre, array<vector> players)
{
if (!players || !ERR_ForceRules.FiniteVector(centre)) return false;
foreach (vector player : players)
if (ERR_ForceRules.FiniteVector(player) && vector.DistanceXZ(centre, player) <= DISTANCE) return true;
return false;
}
}
class ERR_TownGarrison
{
vector Centre;
string Id;
string Name;
int Count;
bool Finished;
bool Admitted;
float NextLog;
SCR_AIGroup Group;
AIWaypoint Hold;
}
// One finite squad per named settlement per round. Empty native groups have no
// queued slots: each character is created synchronously after a fresh distance
// check, so native expansion cannot introduce late soldiers beside a player.
class ERR_TownGarrisons
{
protected ERR_GameMode m_Mode;
protected ref array<ref ERR_TownGarrison> m_Towns = {};
protected ref ERR_SpawnPlacement m_Placement = new ERR_SpawnPlacement();
protected float m_Next;
protected int m_Cursor;
protected bool m_Stopped;
void ERR_TownGarrisons(ERR_GameMode mode) { m_Mode = mode; }
protected void Players(out array<vector> positions)
{
positions.Clear();
array<int> ids = {}; GetGame().GetPlayerManager().GetPlayers(ids);
foreach (int id : ids)
{
IEntity actor = GetGame().GetPlayerManager().GetPlayerControlledEntity(id);
// Include unconscious and dead controlled bodies: do not spawn beside a
// teammate just because that teammate temporarily cannot fight.
if (actor) positions.Insert(actor.GetOrigin());
}
}
protected bool Settlement(IEntity entity)
{
if (!entity) return false;
MapDescriptorComponent descriptor = MapDescriptorComponent.Cast(entity.FindComponent(MapDescriptorComponent));
if (!descriptor) return false;
int type = descriptor.GetBaseType();
return type == EMapDescriptorType.MDT_NAME_CITY || type == EMapDescriptorType.MDT_NAME_TOWN
|| type == EMapDescriptorType.MDT_NAME_VILLAGE || type == EMapDescriptorType.MDT_NAME_SETTLEMENT;
}
protected bool Remember(IEntity entity)
{
vector centre = entity.GetOrigin();
foreach (ERR_TownGarrison known : m_Towns)
if (vector.DistanceXZ(known.Centre, centre) < 50) return true;
if (m_Towns.Count() >= 256) return false;
ERR_TownGarrison town = new ERR_TownGarrison();
town.Centre = centre; town.Count = Math.RandomIntInclusive(3, 10);
town.Id = "town-" + m_Towns.Count().ToString();
town.Name = entity.GetName(); if (town.Name.IsEmpty()) town.Name = town.Id;
m_Towns.Insert(town);
return true;
}
protected void Discover(array<vector> players)
{
foreach (vector player : players)
GetGame().GetWorld().QueryEntitiesByAABB(player - "700 5000 700", player + "700 5000 700", Remember, Settlement, EQueryEntitiesFlags.ALL);
}
bool Contains(SCR_AIGroup group)
{
if (!group) return false;
foreach (ERR_TownGarrison town : m_Towns) if (town.Group == group && town.Admitted) return true;
return false;
}
protected void Waiting(ERR_TownGarrison town, float now, string reason)
{
if (now < town.NextLog) return;
town.NextLog = now + 60000;
ERR_HQ.Event("TOWN GARRISON", string.Format("%1 at %2: waiting to place %3 soldiers; %4. No nearby soldiers spawned.", town.Name, town.Centre, town.Count, reason));
}
protected bool Plan(ERR_TownGarrison town, array<vector> players, out array<vector> positions)
{
positions.Clear();
AIPathfindingComponent path = AIPathfindingComponent.Cast(town.Group.FindComponent(AIPathfindingComponent));
if (!path) return false;
NavmeshWorldComponent navmesh = path.GetNavmeshComponent();
int first = Math.RandomInt(0, 24);
for (int ring = 0; ring < 6; ring++)
for (int direction = 0; direction < 24; direction++)
{
float angle = ((first + direction) * 15) * Math.DEG2RAD;
float radius = Math.Min(ring * 40, 185);
vector anchor = ERR_Actors.Ground(town.Centre + Vector(Math.Cos(angle) * radius, 0, Math.Sin(angle) * radius));
if (!ERR_TownSpawnRules.Safe(anchor, players, 710)) continue;
if (navmesh && !navmesh.IsTileLoaded(anchor)) { navmesh.LoadTileIn(anchor); continue; }
positions.Clear();
map<int, vector> reserved = new map<int, vector>();
for (int slot = 0; slot < town.Count; slot++)
{
int column = slot % 4;
int row = slot / 4;
vector point = ERR_Actors.Ground(anchor + Vector(column * 3 - 4.5, 0, row * 3 - 3));
vector navigable;
if (!path.GetClosestPositionOnNavmesh(point, "3 3 3", navigable)) break;
point = ERR_Actors.Ground(navigable);
if (vector.DistanceXZ(point, town.Centre) > ERR_TownSpawnRules.TOWN_RADIUS || !ERR_TownSpawnRules.Safe(point, players, 702) || !m_Placement.Clear(point, reserved)) break;
positions.Insert(point); reserved.Set(slot, point);
}
if (positions.Count() == town.Count) return true;
}
positions.Clear(); return false;
}
protected void Attempt(ERR_TownGarrison town, array<vector> players, float now)
{
if (town.Finished || !ERR_TownSpawnRules.Approached(town.Centre, players)) return;
if (m_Mode.Forces().Count() >= 250) { Waiting(town, now, "mission force budget is full"); return; }
if (!town.Group)
{
town.Group = SCR_AIGroup.Cast(ERR_Actors.Spawn("{EC9F1AA42D0B7863}Prefabs/ERR/PrisonPatrol.et", town.Centre));
if (!town.Group) { town.Finished = true; return; }
m_Mode.Own(town.Group);
town.Group.SetFaction(m_Mode.EnemyFaction());
town.Group.SetSpawnImmediately(false); town.Group.SetDeleteWhenEmpty(false);
town.Group.SetPermanentLOD(0);
}
array<vector> positions = {};
if (!Plan(town, players, positions)) { Waiting(town, now, "no clear navigable town position at least 700 m from every player"); return; }
Resource profile = Resource.Load(m_Mode.FactionSelection().Choice("enemy").Group);
array<ResourceName> roster = {};
IEntitySource source;
if (profile && profile.IsValid()) source = profile.GetResource().ToEntitySource();
if (!source || !source.Get("m_aUnitPrefabSlots", roster) || roster.IsEmpty()) { Fail(town, "enemy faction has no infantry roster"); return; }
// Load every resource before the final checks, not part-way through creation.
array<ref Resource> loaded = {};
foreach (ResourceName prefab : roster)
{
Resource resource = Resource.Load(prefab);
if (!resource || !resource.IsValid()) { Fail(town, "enemy soldier prefab could not be loaded"); return; }
loaded.Insert(resource);
}
Players(players);
foreach (vector planned : positions)
if (!ERR_TownSpawnRules.Safe(planned, players)) { Waiting(town, now, "a player approached during preparation"); return; }
town.Finished = true;
float nearest = 1e20;
for (int index = 0; index < positions.Count(); index++)
{
Players(players);
if (!ERR_TownSpawnRules.Safe(positions[index], players)) { Fail(town, "player entered the exclusion area during creation"); return; }
IEntity actor = ERR_Actors.Spawn(roster[index % roster.Count()], positions[index]);
if (!actor) { Fail(town, "native soldier creation failed"); return; }
m_Mode.Own(actor);
if (!ERR_TownSpawnRules.Safe(actor.GetOrigin(), players) || !ERR_Actors.SetFaction(actor, m_Mode.EnemyFaction()) || !town.Group.AddAIEntityToGroup(actor))
{ ERR_Actors.DeleteSpawned(actor); Fail(town, "native soldier placement or group admission failed"); return; }
foreach (vector observer : players) nearest = Math.Min(nearest, vector.DistanceXZ(actor.GetOrigin(), observer));
}
town.Group.SetOrigin(positions[0]);
ERR_ForceRecord record = m_Mode.Forces().Register(town.Id, ERR_ForceKind.GARRISON, town.Centre, town.Count, null, town.Group, now);
if (!record) { Fail(town, "force ledger refused the squad"); return; }
town.Admitted = true;
town.Hold = AIWaypoint.Cast(ERR_Actors.Spawn(ERR_Resources.DefendWaypoint(), positions[0]));
if (town.Hold) { m_Mode.Own(town.Hold); town.Hold.SetCompletionRadius(60); town.Group.AddWaypoint(town.Hold); }
m_Mode.RegisterGroup(town.Group); town.Group.ActivateAI();
ERR_HQ.Event("TOWN GARRISON", string.Format("%1 at %2: spawned %3 enemy soldiers; nearest player at creation %4 m. All report to VirtualHQ and are available for orders. One squad this round; no replacements.", town.Name, town.Centre, town.Count, Math.Round(nearest)));
}
protected void Fail(ERR_TownGarrison town, string reason)
{
town.Finished = true;
ERR_Actors.DeleteSpawned(town.Group); town.Group = null;
ERR_HQ.Event("TOWN GARRISON", town.Name + ": creation cancelled: " + reason + ". No replacement wave.");
}
void Tick(float now)
{
if (m_Stopped || !Replication.IsServer() || !m_Mode.Active() || m_Mode.Phase() <= ERR_Phase.PRISON || now < m_Next) return;
m_Next = now + 2000;
array<vector> players = {}; Players(players);
if (players.IsEmpty()) return;
Discover(players);
// At most one finite squad per update; round-robin avoids a blocked town
// preventing other settlements from activating for separated teammates.
for (int checked = 0; checked < m_Towns.Count(); checked++)
{
m_Cursor = m_Cursor % m_Towns.Count();
ERR_TownGarrison town = m_Towns[m_Cursor++];
if (town.Finished || !ERR_TownSpawnRules.Approached(town.Centre, players)) continue;
Attempt(town, players, now); break;
}
}
void Stop()
{
m_Stopped = true;
foreach (ERR_TownGarrison town : m_Towns)
{
if (town.Hold) { if (town.Group) town.Group.RemoveWaypoint(town.Hold); ERR_Actors.DeleteSpawned(town.Hold); town.Hold = null; }
// Admitted units remain under normal mission/body ownership until teardown.
if (!town.Admitted) { ERR_Actors.DeleteSpawned(town.Group); town.Group = null; }
}
}
}
@@ -85,6 +85,7 @@ class ERR_IntelDiscoveryText
static string Type(int kind, string id)
{
if (kind == ERR_LocationKind.RADIO) return "radio station";
if (kind == ERR_LocationKind.HOSPITAL) return "field hospital";
if (kind == ERR_LocationKind.OUTPOST) return "outpost";
if (kind == ERR_LocationKind.MORTAR) return "mortar site";
if (id.Contains("medical")) return "medical stash";