Add ground starting supplies and mission telemetry; record Workshop 0.0.123
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-28 14:43:43 +01:00
parent 9233320fd1
commit 95d21b06ff
78 changed files with 15375 additions and 81 deletions
@@ -46,7 +46,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 = {"SitesOn", "SitesOff", "PlaneOn", "PlaneOff", "Close", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "SelfEpiOn", "SelfEpiOff", "IntelSummary", "RerollIntel", "RandomDay_startTimeMinutes", "Default_viewDistanceMetres", "RestartEmptyOn", "RestartEmptyOff", "RecommendedPlane", "RecommendedWeapons", "RecommendedDrones"};
array<string> names = {"TelemetryOn", "TelemetryOff","SitesOn", "SitesOff", "PlaneOn", "PlaneOff", "Close", "PauseJournal", "JournalDetails", "LatestJournal", "KeepWeaponOn", "KeepWeaponOff", "SelfEpiOn", "SelfEpiOff", "IntelSummary", "RerollIntel", "RandomDay_startTimeMinutes", "Default_viewDistanceMetres", "RestartEmptyOn", "RestartEmptyOff", "RecommendedPlane", "RecommendedWeapons", "RecommendedDrones"};
foreach (string mainButton : ERR_AdminMenuPages.MainButtons()) names.Insert(mainButton);
foreach (string pageButton : Navigation()) names.Insert(pageButton);
for (int density = 1; density <= 4; density++) names.Insert("GuardEvery_" + density.ToString());
@@ -156,8 +156,10 @@ class ERR_AdminMenu : ChimeraMenuBase
protected int m_SelfEpiState = -1;
protected int m_SitesState = -1;
protected int m_RestartEmptyState = -1;
protected int m_TelemetryState = -1;
protected void UpdateActions()
{
ERR_SelectionStyle.Pair(GetRootWidget(), "TelemetryOn", "TelemetryOff", m_TelemetryState);
ERR_SelectionStyle.Pair(GetRootWidget(), "SitesOn", "SitesOff", m_SitesState);
ERR_SelectionStyle.Pair(GetRootWidget(), "SelfEpiOn", "SelfEpiOff", m_SelfEpiState);
ERR_SelectionStyle.Pair(GetRootWidget(), "KeepWeaponOn", "KeepWeaponOff", m_KeepWeaponState);
@@ -176,6 +178,8 @@ class ERR_AdminMenu : ChimeraMenuBase
else if (name.IndexOf("GuardHQ_") == 0) button.SetEnabled(m_Ready && m_HQ && m_AvailableFields.Contains("objectGuardHQMode"));
else if (name == "RerollIntel" || name == "IntelSummary") button.SetEnabled(m_Ready && m_Intel);
else if (name == "LatestJournal") button.SetEnabled(m_Ready && m_HQ);
else if (name == "TelemetryOn") button.SetEnabled(m_Ready && m_TelemetryState == 0);
else if (name == "TelemetryOff") button.SetEnabled(m_Ready && m_TelemetryState == 1);
else if (name == "RestartEmptyOn") button.SetEnabled(m_Ready && m_RestartEmptyState == 0);
else if (name == "RestartEmptyOff") button.SetEnabled(m_Ready && m_RestartEmptyState == 1);
else if (name == "SitesOn") button.SetEnabled(m_Ready && m_SitesState == 0);
@@ -190,7 +194,7 @@ class ERR_AdminMenu : ChimeraMenuBase
{
if (!authorized) { Close(); return; }
if (!values) return;
m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1; m_SelfEpiState = -1; m_RestartEmptyState = -1;
m_HQ = false; m_Intel = false; m_AvailableFields.Clear(); m_KeepWeaponState = -1; m_SelfEpiState = -1; m_RestartEmptyState = -1; m_TelemetryState = -1;
foreach (string entry : values)
{
if (entry == "supports=hq") { m_HQ = true; continue; }
@@ -228,6 +232,15 @@ class ERR_AdminMenu : ChimeraMenuBase
ERR_SelectionStyle.Pair(GetRootWidget(), "PlaneOn", "PlaneOff", planeState);
m_AvailableFields.Insert(key); continue;
}
if (key == "telemetryStatus") { SetText("TelemetryStatus", entry.Substring(divider + 1, entry.Length() - divider - 1)); continue; }
if (key == "telemetryEnabled")
{
m_TelemetryState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
string telemetryState = "Disabled";
if (m_TelemetryState == 1) telemetryState = "Enabled";
SetText("Saved_telemetryEnabled", "Saved: " + telemetryState);
continue;
}
if (key == "restartOnEmpty")
{
m_RestartEmptyState = entry.Substring(divider + 1, entry.Length() - divider - 1).ToInt();
@@ -392,6 +405,12 @@ class ERR_AdminMenu : ChimeraMenuBase
m_ClickDelay = 0.3; SetText("Feedback", "Saving reconnaissance plane selection for next round...");
m_Controller.ERR_AdminSubmit("mission plane set " + enabled, true); return;
}
if (name == "TelemetryOn" || name == "TelemetryOff")
{
if (m_TelemetryState < 0) return;
string enabled = "0"; if (name == "TelemetryOn") enabled = "1";
m_Controller.ERR_AdminSubmit("mission telemetry set " + enabled, true); return;
}
if (name == "RestartEmptyOn" || name == "RestartEmptyOff")
{
if (m_RestartEmptyState < 0) return;
@@ -25,17 +25,18 @@ enum ERR_AdminPage
SARKA = 21,
DRONES = 22,
RADIO_INTEL = 23,
TELEMETRY = 24,
}
class ERR_AdminMenuPages
{
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage", "SarkaPage", "DronesPage", "RadioIntelPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter", "OpenSarka", "OpenDrones", "OpenRadioIntel"}; }
static array<string> Pages() { return {"SettingsPage", "HQPage", "JournalPage", "ModsPage", "FactionsPage", "WorldPage", "IntelPage", "StatusPage", "TerrainsPage", "ItemsPage", "TTSPage", "ForcesPage", "GuardsPage", "SitesPage", "PlanePage", "RecommendationsPage", "WeightsPage", "AllowedItemsPage", "ItemPresetsPage", "AIPage", "StarterPage", "SarkaPage", "DronesPage", "RadioIntelPage", "TelemetryPage"}; }
static array<string> Navigation() { return {"OpenSettings", "OpenHQ", "OpenJournal", "OpenMods", "OpenFactions", "OpenWorld", "OpenIntel", "OpenStatus", "OpenTerrains", "OpenItems", "OpenTTS", "OpenForces", "OpenGuards", "OpenSites", "OpenPlane", "OpenRecommendations", "OpenWeights", "OpenAllowedItems", "OpenItemPresets", "OpenAI", "OpenStarter", "OpenSarka", "OpenDrones", "OpenRadioIntel", "OpenTelemetry"}; }
static array<string> MainButtons() { return {"Main_Mission", "Main_Forces", "Main_HQ", "Main_Intel", "Main_Items", "Main_Mods", "Main_Audio", "Main_Status"}; }
static array<string> Sidebars() { return {"Sidebar_Mission", "Sidebar_Forces", "Sidebar_HQ", "Sidebar_Intel", "Sidebar_Items", "Sidebar_Mods", "Sidebar_Audio", "Sidebar_Status"}; }
static int Group(int page)
{
array<int> groups = {0, 2, 7, 5, 1, 0, 3, 7, 0, 4, 6, 1, 1, 1, 2, 5, 4, 4, 4, 2, 4, 1, 2, 3};
array<int> groups = {0, 2, 7, 5, 1, 0, 3, 7, 0, 4, 6, 1, 1, 1, 2, 5, 4, 4, 4, 2, 4, 1, 2, 3, 0};
if (page < 0 || page >= groups.Count()) return -1;
return groups[page];
}
@@ -4,6 +4,7 @@ class ERR_CommandArguments
{
static string CoreSetting(string section, string option)
{
if (section == "mission" && option == "telemetry") return "telemetryEnabled";
if (section == "mission" && option == "doctrine") return "aiDoctrine";
if (section == "mission" && option == "aishadow") return "aiShadow";
if (section == "mission" && option == "sarkapoints") return "sarkaAngerPerCar";
@@ -190,6 +191,7 @@ class ERR_AdminService
if (key == "objectGuardCount") return "0..16 soldiers per factory/industrial, military or infrastructure site; default 8, zero off. Next round. HQ participation is configured separately; all report. Towers and chimneys use suitable platforms. Lost guards are not replaced.";
if (key == "hunterSpawnDistanceMetres") return "Opening UAZ and troop-truck patrol placement. Applies next round.";
if (key == "roadblockCount") return "Set 0..30 roadblocks (default 4; 0 disables). Applies next round. Each has four guards; unsuitable sites are skipped.";
if (key == "telemetryEnabled") return "Default on. Sends mission results and player identities to the configured statistics service. Disabling clears unsent events immediately.";
if (key == "restartOnEmpty") return "On (1) restarts after the last disconnect; Off (0) preserves the round for reconnects. Default On. Applies immediately; normal round endings still apply.";
if (key == "emptyRestartHours") return "0 disables idle restarts; 1..168 real hours continuously empty. Default 5. Applies immediately, measured from mission start or the last disconnect. Connected players, including spectators, prevent idle restarts.";
if (key == "civilianTrafficCount") return "Set 0..6 civilian cars (default 3). Zero disables new traffic. Unclaimed cars retire only far from all players and out of sight; player-entered cars are preserved.";
@@ -80,19 +80,24 @@ class ERR_AdminSession
ERR_StartBackpack supplies = GetGame().ERR_StartingSupplies();
bool saved;
if (parts.Count() == 3 && parts[1] == "mode" && (parts[2] == "default" || parts[2] == "custom")) saved = supplies.SetMode(parts[2] == "custom");
else if (parts.Count() == 3 && parts[1] == "remove") saved = supplies.Remove(parts[2]);
else if (parts.Count() == 5 && parts[1] == "set")
else if ((parts.Count() == 3 || parts.Count() == 4) && parts[1] == "remove")
{
string removeDestination = "backpack"; if (parts.Count() == 4) removeDestination = parts[3];
saved = supplies.Remove(parts[2], removeDestination);
}
else if ((parts.Count() == 5 || parts.Count() == 6) && parts[1] == "set")
{
ERR_ServerItemCatalog catalog = GetGame().ERR_ServerItems(); catalog.Start(m_Mode);
int count;
if (catalog.Contains(parts[2], parts[3]) && ERR_CommandArguments.Integer(parts[4], count))
{
ERR_ServerItem item = catalog.Item(parts[3]);
if (item) saved = supplies.Set(item.Prefab, count);
string destination = "backpack"; if (parts.Count() == 6) destination = parts[5];
if (item) saved = supplies.Set(item.Prefab, count, destination);
}
}
if (saved) replies.Insert("Starting backpack saved for next round. Custom contents are used only when Custom is selected.");
else replies.Insert("Not saved. Choose a catalog item and quantity 0-48; maximum 32 item types and 64 items total. Check configuration if read-only.");
if (saved) replies.Insert("Starting supplies saved for next round. Custom contents are used only when Custom is selected.");
else replies.Insert("Not saved. Choose a catalog item and quantity 0-48; maximum 32 entries and 64 items across both destinations. Check configuration if read-only.");
return true;
}
// Per-session paging: another administrator cannot change this menu's selection.
@@ -9,12 +9,12 @@ class ERR_Settings
static array<string> Keys()
{
return {"aiDoctrine", "aiShadow", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "droneReconCount", "droneCooldownSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioIntelSiteCount", "showAllSites", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
return {"telemetryEnabled", "aiDoctrine", "aiShadow", "sarkaAngerPerCar", "motorbikeCount", "planeAltitudeMetres", "planeReplacementSeconds", "planeSectorSeconds", "planeReconEnabled", "droneReconCount", "droneCooldownSeconds", "fieldCampCount", "fieldHospitalCount", "startWeaponCount", "startMagazineCount", "objectGuardCount", "objectGuardEvery", "objectGuardHQMode", "hunterSpawnDistanceMetres", "roadblockCount", "civilianTrafficCount", "startMode", "keepWeaponOnUnconscious", "selfEpinephrine", "restartOnEmpty", "emptyRestartHours", "reportIntervalSeconds", "supportSquadLimit", "responseRadiusMetres", "aiIntelChancePercent", "intelRevealChancePercent", "radioIntelSiteCount", "showAllSites", "radioDefenceSeconds", "startTimeMinutes", "timeMultiplier", "startDateCode", "weatherProfile", "viewDistanceMetres", "antiAirChancePercent", "antiPersonnelMineChancePercent", "automaticEquipmentChancePercent", "automaticConvoyEnabled", "convoyCount", "heliTimerMinutes"};
}
static bool Valid(string key, int value)
{
if (key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1;
if (key == "telemetryEnabled" || key == "aiDoctrine" || key == "aiShadow") return value == 0 || value == 1;
if (key == "radioIntelSiteCount") return value >= 0 && value <= 20;
if (key == "sarkaAngerPerCar") return value >= 0 && value <= 30;
if (key == "motorbikeCount") return value >= 0 && value <= 6;
@@ -143,7 +143,7 @@ class ERR_Settings
if (key == "civilianTrafficCount") return 3;
if (key == "showAllSites") return 0;
if (key == "startMode" || key == "selfEpinephrine") return 0;
if (key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return 1;
if (key == "telemetryEnabled" || key == "keepWeaponOnUnconscious" || key == "restartOnEmpty") return 1;
if (key == "emptyRestartHours") return 5;
if (key == "heliTimerMinutes") return 15;
if (key == "convoyCount") return 3;
@@ -160,6 +160,7 @@ class ERR_Settings
void ERR_Settings(string path = PATH)
{
m_Path = path;
m_Values.Set("telemetryEnabled", AddedDefault("telemetryEnabled"));
m_Values.Set("aiDoctrine", AddedDefault("aiDoctrine"));
m_Values.Set("aiShadow", AddedDefault("aiShadow"));
m_Values.Set("sarkaAngerPerCar", AddedDefault("sarkaAngerPerCar"));
@@ -2,6 +2,8 @@
[BaseContainerProps()]
class ERR_SiteStock
{
// Runtime starting-loadout choice; authored objective stock remains in storage.
bool OnGround;
[Attribute("", UIWidgets.ResourceNamePicker, "Physical inventory item", "et")]
ResourceName Prefab;
[Attribute("1", UIWidgets.EditBox, "Finite initial count")]
@@ -7,6 +7,7 @@ class ERR_StockPlacement
bool Accepted;
bool Optional;
bool Skipped;
bool OnGround;
}
class ERR_SiteRuntime
@@ -473,6 +474,7 @@ class ERR_SiteService
&& site.DepotMines.Count() < 2) site.DepotMines.Insert(item);
}
placement.Optional = customStart && stock.Prefab != ERR_Supplies.Map();
placement.OnGround = customStart && stock.OnGround;
placement.Item = item;
placement.Inventory = inventory;
m_Placements.Insert(placement);
@@ -628,6 +630,75 @@ class ERR_SiteService
}
}
// Optional starting items stay usable when native inventory rejects their
// dimensions/volume/type. Keep the exact entity; do not spawn copies or delete it.
protected void LeaveStartingItem(ERR_StockPlacement placement)
{
InventoryItemComponent item = InventoryItemComponent.Cast(placement.Item.FindComponent(InventoryItemComponent));
// An accepted asynchronous move or an administrator may already own the item.
// Never pull it out of another storage while resolving starter overflow.
if (!item || !item.GetParentSlot())
{
vector origin = placement.Inventory.GetOwner().GetOrigin();
array<vector> offsets = {"1.2 0 0", "0 0 1.2", "-1.2 0 0", "0 0 -1.2", "0.9 0 0.9", "-0.9 0 0.9", "-0.9 0 -0.9", "0.9 0 -0.9"};
foreach (vector offset : offsets)
{
vector point = ERR_Actors.Ground(origin + offset);
if (!ERR_Actors.DryGround(point) || Math.AbsFloat(point[1] - origin[1]) > 0.5 || !BackpackClearance(point)) continue;
// Do not place supplies across a cell wall or inside the crash wreckage.
TraceParam access = new TraceParam(); access.Start = origin + "0 0.6 0"; access.End = point + "0 0.6 0";
access.Flags = TraceFlags.ENTS | TraceFlags.WORLD; access.Exclude = placement.Inventory.GetOwner();
if (GetGame().GetWorld().TraceMove(access, null) < 1) continue;
placement.Item.SetOrigin(point + "0 0.2 0");
Physics physics = placement.Item.GetPhysics();
if (physics && physics.IsDynamic()) physics.SetActive(ActiveState.ACTIVE);
break;
}
}
string resource = "unknown item";
if (placement.Item.GetPrefabData()) resource = placement.Item.GetPrefabData().GetPrefabName();
if (!placement.OnGround) Print("Reintroduced [starting backpack]: inventory rejected item; retained as starting-area loot: " + resource, LogLevel.WARNING);
placement.Skipped = true;
}
protected ERR_PrepareResult PrepareStock()
{
int pending, accepted;
string firstRejected;
foreach (ERR_StockPlacement placement : m_Placements)
{
if (placement.Skipped) continue;
if (!placement.Item || !placement.Inventory)
{
if (placement.Optional) { placement.Skipped = true; Print("Reintroduced [starting backpack]: optional item disappeared before insertion", LogLevel.WARNING); continue; }
Fail("stock disappeared during initialization"); return ERR_PrepareResult.FAILED;
}
if (placement.OnGround) { LeaveStartingItem(placement); continue; }
array<IEntity> items = {};
placement.Inventory.GetItems(items);
if (items.Contains(placement.Item)) continue;
bool expired = GetGame().GetWorld().GetWorldTime() > m_Deadline;
if (placement.Optional && (expired || (!placement.Accepted && !placement.Inventory.CanInsertItem(placement.Item))))
{
LeaveStartingItem(placement); continue;
}
pending++;
if (firstRejected.IsEmpty() && placement.Item.GetPrefabData()) firstRejected = placement.Item.GetPrefabData().GetPrefabName();
if (placement.Accepted) accepted++;
if (!placement.Accepted) placement.Accepted = placement.Inventory.TryInsertItem(placement.Item);
}
if (pending)
{
if (GetGame().GetWorld().GetWorldTime() > m_Deadline)
{
Fail(string.Format("physical stock insertion timed out: pending=%1 accepted=%2 sample=%3", pending, accepted, firstRejected));
return ERR_PrepareResult.FAILED;
}
return ERR_PrepareResult.PENDING;
}
return ERR_PrepareResult.READY;
}
ERR_PrepareResult Prepare()
{
if (!Replication.IsServer() || !m_Begun || m_Stopped) return ERR_PrepareResult.FAILED;
@@ -636,7 +707,7 @@ class ERR_SiteService
ERR_PrepareResult aircraftPlacement = ERR_PrepareResult.READY;
if (m_ExtractionPlacement) aircraftPlacement = m_ExtractionPlacement.Poll(GetGame().GetWorld().GetWorldTime());
if (aircraftPlacement == ERR_PrepareResult.FAILED) { Fail(m_ExtractionPlacement.FailureReason()); return m_State; }
int pending, accepted, pendingGuards;
int pendingGuards;
foreach (ERR_SiteRuntime site : m_Sites)
{
if (site.Definition.Transport && !site.Definition.Transport.Optional && !TransportReady(site))
@@ -654,29 +725,7 @@ class ERR_SiteService
if (mobile == ERR_PrepareResult.PENDING) pendingGuards++;
}
}
string firstRejected;
foreach (ERR_StockPlacement placement : m_Placements)
{
if (placement.Skipped) continue;
if (!placement.Item || !placement.Inventory) { Fail("stock disappeared during initialization"); return m_State; }
array<IEntity> items = {};
placement.Inventory.GetItems(items);
if (items.Contains(placement.Item)) continue;
if (placement.Optional && GetGame().GetWorld().GetWorldTime() > m_Deadline)
{
Print("Reintroduced [starting backpack]: item did not fit; omitted " + placement.Item.GetPrefabData().GetPrefabName(), LogLevel.WARNING);
ERR_Actors.DeleteSpawned(placement.Item); placement.Skipped = true; continue;
}
pending++;
if (firstRejected.IsEmpty() && placement.Item.GetPrefabData()) firstRejected = placement.Item.GetPrefabData().GetPrefabName();
if (placement.Accepted) accepted++;
if (!placement.Accepted) placement.Accepted = placement.Inventory.TryInsertItem(placement.Item);
}
if (pending)
{
if (GetGame().GetWorld().GetWorldTime() > m_Deadline) Fail(string.Format("physical stock insertion timed out: pending=%1 accepted=%2 sample=%3", pending, accepted, firstRejected));
return m_State;
}
if (PrepareStock() != ERR_PrepareResult.READY) return m_State;
if (pendingGuards > 0 || aircraftPlacement == ERR_PrepareResult.PENDING) return m_State;
// All required scenery, stock and physical guards now exist. Survey optional
// parking once against that real population, then bound only vehicle settling.
@@ -11,6 +11,11 @@ class ERR_StartBackpack
if (Read(PATH, loaded) || Read(PATH + ".bak", loaded)) m_Records = loaded;
else if (ERR_ProfileConfig.Exists(PATH) || ERR_ProfileConfig.Exists(PATH + ".bak")) m_ReadOnly = true;
}
static string Destination(array<string> parts)
{
if (parts.Count() == 3) return parts[2];
return "backpack";
}
static bool Valid(array<string> records)
{
if (!records || records.IsEmpty() || records.Count() > 33) return false;
@@ -19,19 +24,21 @@ class ERR_StartBackpack
for (int i = 1; i < records.Count(); i++)
{
array<string> parts = {}; records[i].Split("|", parts, false);
if (parts.Count() != 2 || parts[0].Length() > 512 || !parts[0].EndsWith(".et")) return false;
if ((parts.Count() != 2 && parts.Count() != 3) || parts[0].Length() > 512 || !parts[0].EndsWith(".et")) return false;
string key = ERR_TerrainCatalog.Key(parts[0]); int count;
if (!ERR_ItemPreferences.Guid(key) || seen.Contains(key)) return false;
string destination = Destination(parts);
if (destination != "backpack" && destination != "ground") return false;
if (!ERR_ItemPreferences.Guid(key) || seen.Contains(key + "|" + destination)) return false;
if (!ERR_CommandArguments.Integer(parts[1], count) || count < 1 || count > 48) return false;
total += count; if (total > 64) return false;
seen.Insert(key);
seen.Insert(key + "|" + destination);
}
return true;
}
static bool Parse(JsonLoadContext json, out array<string> records)
{
int version; records = {};
return json.ReadValue("version", version) && version == 1 && json.ReadValue("records", records) && Valid(records);
return json.ReadValue("version", version) && (version == 1 || version == 2) && json.ReadValue("records", records) && Valid(records);
}
protected bool Read(string path, out array<string> records)
{
@@ -42,7 +49,7 @@ class ERR_StartBackpack
{
if (!Replication.IsServer() || m_ReadOnly || !Valid(records)) return false;
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
json.WriteValue("version", 1); json.WriteValue("records", records);
json.WriteValue("version", 2); json.WriteValue("records", records);
if (!ERR_ProfileConfig.Save(PATH, json)) return false;
m_Records = records; return true;
}
@@ -53,26 +60,26 @@ class ERR_StartBackpack
next[0] = "default"; if (custom) next[0] = "custom";
return Save(next);
}
bool Set(ResourceName prefab, int quantity)
bool Set(ResourceName prefab, int quantity, string destination = "backpack")
{
if (quantity < 0 || quantity > 48) return false;
if (quantity < 0 || quantity > 48 || (destination != "backpack" && destination != "ground")) return false;
string key = ERR_TerrainCatalog.Key(prefab);
if (!ERR_ItemPreferences.Guid(key)) return false;
array<string> next = {m_Records[0]};
for (int i = 1; i < m_Records.Count(); i++)
{
array<string> parts = {}; m_Records[i].Split("|", parts, false);
if (ERR_TerrainCatalog.Key(parts[0]) != key) next.Insert(m_Records[i]);
if (ERR_TerrainCatalog.Key(parts[0]) != key || Destination(parts) != destination) next.Insert(m_Records[i]);
}
if (quantity > 0) next.Insert(prefab + "|" + quantity.ToString());
if (quantity > 0) next.Insert(prefab + "|" + quantity.ToString() + "|" + destination);
return Save(next);
}
bool Remove(string key)
bool Remove(string key, string destination = "backpack")
{
for (int i = 1; i < m_Records.Count(); i++)
{
array<string> parts = {}; m_Records[i].Split("|", parts, false);
if (ERR_TerrainCatalog.Key(parts[0]) == key) return Set(parts[0], 0);
if (ERR_TerrainCatalog.Key(parts[0]) == key && Destination(parts) == destination) return Set(parts[0], 0, destination);
}
return false;
}
@@ -81,13 +88,13 @@ class ERR_StartBackpack
for (int i = 1; i < m_Records.Count(); i++)
{
array<string> parts = {}; m_Records[i].Split("|", parts, false);
ERR_SiteStock item = new ERR_SiteStock(); item.Prefab = parts[0]; item.Count = parts[1].ToInt(); stock.Insert(item);
ERR_SiteStock item = new ERR_SiteStock(); item.Prefab = parts[0]; item.Count = parts[1].ToInt(); item.OnGround = Destination(parts) == "ground"; stock.Insert(item);
}
}
void Snapshot(array<string> values)
{
values.Insert("starterMode=" + m_Records[0]);
string state = "Saved for next round. The shared field map is always included. Add matching magazines separately.";
string state = "Saved for next round. The shared field map is always included. Oversized items are left nearby. Add matching magazines separately.";
if (m_ReadOnly) state = "Read-only: starting backpack configuration needs repair.";
values.Insert("starterState=" + state);
for (int i = 1; i < m_Records.Count(); i++)
@@ -96,7 +103,7 @@ class ERR_StartBackpack
string key = ERR_TerrainCatalog.Key(parts[0]);
ERR_ServerItem item = GetGame().ERR_ServerItems().Item(key);
string label = FilePath.StripPath(parts[0]); if (item) label = item.Label;
values.Insert("starterItem=" + key + "|" + ERR_ServerItemCatalog.Clean(label) + "|" + parts[1]);
values.Insert("starterItem=" + key + "|" + ERR_ServerItemCatalog.Clean(label) + "|" + parts[1] + "|" + Destination(parts));
}
}
}
@@ -15,6 +15,9 @@ modded class ERR_AdminMenu
button.m_OnClicked.Insert(OnStarterButton); m_StarterButtons.Insert(button);
}
SCR_EditBoxComponent.GetEditBoxComponent("StarterQuantity", GetRootWidget()).SetValue("1");
SCR_ComboBoxComponent destination = SCR_ComboBoxComponent.GetComboBoxComponent("StarterDestination", GetRootWidget());
destination.ClearAll(); destination.AddItem("Inside backpack"); destination.AddItem("On ground beside backpack");
destination.SetCurrentItem(0, false, false, false);
UpdateActions();
}
override void OnMenuClose()
@@ -69,8 +72,13 @@ modded class ERR_AdminMenu
if (field == "starterState") SetText("StarterState", value);
if (field == "serverItem" && parts.Count() == 6)
{ catalogKeys.Insert(parts[0]); catalogLabels.Insert(parts[1]); catalogSignature += value; }
if (field == "starterItem" && parts.Count() == 3)
{ savedKeys.Insert(parts[0]); savedLabels.Insert(parts[2] + " x " + WidgetManager.Translate(parts[1])); savedSignature += value; total += parts[2].ToInt(); }
if (field == "starterItem" && parts.Count() == 4)
{
string location = "Backpack"; if (parts[3] == "ground") location = "Ground";
savedKeys.Insert(parts[0] + " " + parts[3]);
savedLabels.Insert(location + " | " + parts[2] + " x " + WidgetManager.Translate(parts[1]));
savedSignature += value; total += parts[2].ToInt();
}
}
if (catalogSignature != m_StarterCatalogSignature)
{
@@ -82,7 +90,7 @@ modded class ERR_AdminMenu
if (savedSignature != m_StarterSavedSignature)
{ StarterCombo("StarterSaved", savedKeys, savedLabels, m_StarterSaved); m_StarterSavedSignature = savedSignature; }
string mode = "Default loadout selected"; if (m_StarterMode == 1) mode = "Custom loadout selected";
SetText("StarterSummary", mode + " | Custom: " + savedKeys.Count().ToString() + "/32 types, " + total.ToString() + "/64 items + field map");
SetText("StarterSummary", mode + " | Custom: " + savedKeys.Count().ToString() + "/32 entries, " + total.ToString() + "/64 items + field map");
string page = "Choose a mod, then Show items. Quantity 0 removes an item.";
if (!m_StarterAddon.IsEmpty()) page = "Catalog page " + (m_ShownItemPage + 1).ToString() + "/" + (m_LastItemPage + 1).ToString() + " | Select an item above; enter its total desired quantity (0 removes).";
SetText("StarterCatalogState", page); UpdateActions();
@@ -104,7 +112,9 @@ modded class ERR_AdminMenu
int selected = SCR_ComboBoxComponent.GetComboBoxComponent("StarterCatalogItem", GetRootWidget()).GetCurrentIndex();
int count; string quantity = SCR_EditBoxComponent.GetEditBoxComponent("StarterQuantity", GetRootWidget()).GetValue().Trim();
if (!ERR_CommandArguments.Integer(quantity, count) || count < 0 || count > 48) { ReceiveReply("Enter a quantity from 0 to 48; 0 removes the item."); return; }
if (selected >= 0 && selected < m_StarterCatalog.Count() && !m_StarterAddon.IsEmpty()) SubmitStarter("starter set " + m_StarterAddon + " " + m_StarterCatalog[selected] + " " + count.ToString());
string destination = "backpack";
if (SCR_ComboBoxComponent.GetComboBoxComponent("StarterDestination", GetRootWidget()).GetCurrentIndex() == 1) destination = "ground";
if (selected >= 0 && selected < m_StarterCatalog.Count() && !m_StarterAddon.IsEmpty()) SubmitStarter("starter set " + m_StarterAddon + " " + m_StarterCatalog[selected] + " " + count.ToString() + " " + destination);
return;
}
int index = SCR_ComboBoxComponent.GetComboBoxComponent("StarterMod", GetRootWidget()).GetCurrentIndex();
@@ -0,0 +1,366 @@
// The mission only produces facts. Aggregation and public leaderboards belong to
// the receiver; retries retain event IDs so they cannot count a result twice.
[BaseContainerProps()]
class ERR_TelemetryConfig
{
[Attribute("")] string Endpoint;
static const string RESOURCE = "{E72B130684FA492C}Configs/ERR/Telemetry.conf";
static const string PATH = "$profile:EscapistsTelemetry.json";
string Token;
string ServerName;
static ERR_TelemetryConfig Load()
{
ERR_TelemetryConfig config = ERR_TelemetryConfig.Cast(SCR_BaseContainerTools.CreateInstanceFromPrefab(RESOURCE));
if (!config) config = new ERR_TelemetryConfig();
if (FileIO.FileExists(PATH))
{
JsonLoadContext json = new JsonLoadContext();
if (!json.LoadFromFile(PATH)) { config.Endpoint = ""; return config; }
json.ReadValue("endpoint", config.Endpoint);
json.ReadValue("token", config.Token);
json.ReadValue("serverName", config.ServerName);
}
config.Endpoint.TrimInPlace();
if (!config.Endpoint.StartsWith("https://") || !ERR_SpeechRules.Endpoint(config.Endpoint)) config.Endpoint = "";
if (config.Token.Length() > 256 || config.ServerName.Length() > 120) config.Endpoint = "";
return config;
}
}
class ERR_TelemetryPlayer
{
string id;
string name;
bool authenticated;
bool connected;
bool participated;
bool escaped;
int connectedSeconds;
}
class ERR_TelemetryMod
{
string id;
string name;
string version;
}
class ERR_TelemetryState
{
int schemaVersion = 1;
string serverId;
string endpoint;
string previousMissionId;
string previousOutcome;
int droppedEvents;
ref array<string> pending = {};
}
class ERR_TelemetryRules
{
static bool Acknowledged(int status) { return status >= 200 && status < 300; }
static int RetrySeconds(int failures)
{
return Math.Min(300, 5 * Math.Pow(2, Math.Min(failures, 6)));
}
static string Outcome(int phase, bool emptyRestart, bool preparationFailed)
{
if (preparationFailed) return "preparation_failed";
if (emptyRestart) return "restarted_empty";
if (phase == ERR_Phase.VICTORY) return "victory";
if (phase == ERR_Phase.DEFEAT) return "defeat";
return "interrupted";
}
}
class ERR_Telemetry
{
static const string STATE = "$profile:EscapistsTelemetryState.json";
protected ERR_GameMode m_Mode;
protected ref ERR_TelemetryConfig m_Config;
protected ref ERR_TelemetryState m_State;
protected ref array<ref ERR_TelemetryPlayer> m_Players = {};
protected ref array<ref ERR_TelemetryMod> m_Mods = {};
protected ref RestCallback m_Callback;
protected RestContext m_Context;
protected string m_MissionId;
protected string m_InFlight;
protected int m_Started;
protected int m_LastSample;
protected int m_NextHeartbeat;
protected int m_NextAttempt;
protected int m_Peak;
protected int m_Failures;
protected int m_RequestStarted;
protected int m_Delivered;
protected bool m_Collected;
protected bool m_Partial;
protected bool m_Enabled;
protected bool m_Ended;
protected bool m_Closed;
protected bool m_Dirty;
protected bool m_TruncatedPlayers;
protected bool m_TruncatedMods;
void ERR_Telemetry(ERR_GameMode mode)
{
m_Mode = mode; m_Config = ERR_TelemetryConfig.Load();
m_State = new ERR_TelemetryState();
// A damaged primary can recover from the last fully written generation.
if (!LoadState(STATE) && !LoadState(STATE + ".bak"))
{
if (FileIO.FileExists(STATE) || FileIO.FileExists(STATE + ".bak"))
{
Print("Reintroduced [telemetry]: unreadable queue; telemetry suspended, files preserved", LogLevel.ERROR);
m_Closed = true; return;
}
m_State.serverId = UUID.GenV4();
}
if (m_State.endpoint != m_Config.Endpoint)
{
// Never redirect previously collected player data to a different receiver.
Purge(); m_State.endpoint = m_Config.Endpoint;
}
array<string> addons = {}; GameProject.GetLoadedAddons(addons);
foreach (string addon : addons)
{
if (GameProject.IsVanillaAddon(addon)) continue;
if (m_Mods.Count() >= 128) { m_TruncatedMods = true; break; }
ERR_TelemetryMod mod = new ERR_TelemetryMod();
mod.id = addon; mod.name = GameProject.GetAddonTitle(addon);
BackendApi backend = GetGame().GetBackendApi();
if (backend && backend.GetWorkshop())
{
WorkshopItem item = backend.GetWorkshop().FindItem(addon);
if (item && item.GetActiveRevision()) mod.version = item.GetActiveRevision().GetVersion();
}
m_Mods.Insert(mod);
}
m_MissionId = UUID.GenV4(); m_Started = System.GetTickCount();
Refresh();
GetGame().GetCallqueue().CallLater(Pulse, 5000, true);
}
protected bool ReadState(string path, out ERR_TelemetryState state)
{
string raw;
JsonLoadContext json = new JsonLoadContext();
if (!ERR_ProfileConfig.Text(path, raw) || !json.LoadFromString(raw) || !json.ReadValue("state", state) || !state || state.schemaVersion != 1 || !UUID.IsUUID(state.serverId) || !state.pending || state.pending.Count() > 64) return false;
foreach (string queuedEvent : state.pending)
{
JsonLoadContext queued = new JsonLoadContext(); string eventId, missionId, serverId;
if (queuedEvent.Length() > 65536 || !queued.LoadFromString(queuedEvent) || !queued.ReadValue("eventId", eventId) || !UUID.IsUUID(eventId) || !queued.ReadValue("missionId", missionId) || !UUID.IsUUID(missionId) || !queued.ReadValue("serverId", serverId) || serverId != state.serverId) return false;
}
return true;
}
protected bool LoadState(string path)
{
ERR_TelemetryState state;
if (!ReadState(path, state)) return false;
m_State = state; return true;
}
protected bool Save()
{
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
json.WriteValue("state", m_State);
if (!json.SaveToFile(STATE + ".tmp")) return false;
string written;
if (!ERR_ProfileConfig.Text(STATE + ".tmp", written) || written != json.SaveToString()) return false;
ERR_TelemetryState previous;
if (ReadState(STATE, previous) && !FileIO.CopyFile(STATE, STATE + ".bak")) return false;
if (!FileIO.CopyFile(STATE + ".tmp", STATE)) return false;
string confirmed;
if (!ERR_ProfileConfig.Text(STATE, confirmed) || confirmed != written) return false;
FileIO.DeleteFile(STATE + ".tmp"); m_Dirty = false; return true;
}
protected void Purge()
{
m_State.pending.Clear(); m_State.previousMissionId = ""; m_State.previousOutcome = "";
m_Players.Clear(); m_InFlight = ""; m_Callback = null;
// Do not retain personal data in recovery files after opting out.
FileIO.DeleteFile(STATE); FileIO.DeleteFile(STATE + ".bak"); FileIO.DeleteFile(STATE + ".tmp");
m_Dirty = true;
}
protected bool Current() { return GetGame() && m_Mode && ERR_GameMode.Get() == m_Mode; }
string Status()
{
if (m_Closed) return "Telemetry suspended: queue recovery required.";
if (m_Mode.Settings().Get("telemetryEnabled") == 0) return "Disabled. Unsent records discarded.";
if (m_Config.Endpoint.IsEmpty()) return "Not sending: receiving URL is missing or invalid.";
if (m_Dirty) return "Waiting for profile storage; events have not been sent.";
if (m_Failures > 0) return "Delivery unavailable; pending records will retry.";
return string.Format("Configured. %1 delivered this session; %2 pending.", m_Delivered, m_State.pending.Count());
}
bool Enabled() { return m_Enabled && !m_Closed; }
void Refresh()
{
if (m_Closed || !Current() || !Replication.IsServer() || !m_Mode.Settings()) return;
bool enabled = m_Mode.Settings().Get("telemetryEnabled") == 1 && !m_Config.Endpoint.IsEmpty();
if (!enabled)
{
if (m_Enabled || !m_State.pending.IsEmpty() || !m_State.previousMissionId.IsEmpty())
{
m_Enabled = false; Purge(); Save();
}
return;
}
if (m_Enabled || m_Ended) return;
m_Enabled = true;
int now = System.GetTickCount(); m_LastSample = now; m_NextHeartbeat = now + 300000;
m_Partial = m_Collected || now - m_Started > 1000;
m_Peak = 0; m_TruncatedPlayers = false;
string previous = m_State.previousMissionId;
string outcome = m_State.previousOutcome;
m_State.previousMissionId = m_MissionId; m_State.previousOutcome = "unfinished";
Sample();
if (!m_Collected && !previous.IsEmpty()) Event("mission_restarted", "", previous, outcome);
if (m_Collected) Event("telemetry_resumed");
else Event("mission_started", "", previous, outcome);
m_Collected = true;
}
protected void Sample()
{
if (!Enabled() || m_Ended) return;
int now = System.GetTickCount();
int elapsed = Math.Max(0, (now - m_LastSample) / 1000); m_LastSample = now;
foreach (ERR_TelemetryPlayer known : m_Players)
{
if (known.connected) known.connectedSeconds += elapsed;
known.connected = false;
}
array<int> connections = {}; GetGame().GetPlayerManager().GetPlayers(connections);
m_Peak = Math.Max(m_Peak, connections.Count());
foreach (int connection : connections)
{
UUID account = SCR_PlayerIdentityUtils.GetPlayerIdentityId(connection);
string key = account;
bool authenticated = !account.IsNull();
if (!authenticated) key = m_MissionId + ":connection:" + connection.ToString();
ERR_TelemetryPlayer player;
foreach (ERR_TelemetryPlayer candidate : m_Players) if (candidate.id == key) { player = candidate; break; }
if (!player)
{
if (m_Players.Count() >= 128) { m_TruncatedPlayers = true; continue; }
player = new ERR_TelemetryPlayer(); player.id = key; player.authenticated = authenticated; m_Players.Insert(player);
}
player.name = GetGame().GetPlayerManager().GetPlayerName(connection);
if (player.name.Length() > 128) player.name = player.name.Substring(0, 128);
player.connected = true;
ERR_PlayerLife life = m_Mode.Players().Get(connection);
if (life && life.Connected && life.UsedLife) player.participated = true;
if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Extraction().DidEscape(account);
}
// Include disconnected escapees/participants with their final authoritative result.
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
foreach (ERR_PlayerLife participant : lives)
foreach (ERR_TelemetryPlayer tracked : m_Players)
if (tracked.authenticated && tracked.id == participant.AccountId)
{
tracked.participated = true;
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Extraction().DidEscape(participant.AccountId);
}
}
void RosterChanged(int playerId) { Sample(); }
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (!Enabled() || m_Ended) return;
if (ERR_MissionRules.Terminal(current)) Finish(ERR_TelemetryRules.Outcome(current, false, false));
else Event("phase_changed");
}
void Finish(string outcome)
{
Refresh();
if (!Enabled() || m_Ended) return;
Sample(); m_Ended = true;
m_State.previousOutcome = outcome;
Event("mission_finished", outcome);
}
protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "")
{
if (!Enabled()) return;
if (!m_Ended) Sample();
JsonSaveContext json = new JsonSaveContext(); json.EnableTypeDiscriminator(false);
json.WriteValue("schemaVersion", 1);
json.WriteValue("eventId", UUID.GenV4()); json.WriteValue("missionId", m_MissionId);
json.WriteValue("serverId", m_State.serverId); json.WriteValue("serverName", m_Config.ServerName);
json.WriteValue("type", type); json.WriteValue("occurredAt", System.GetUnixTime());
json.WriteValue("partial", m_Partial);
json.WriteValue("elapsedSeconds", Math.Max(0, (System.GetTickCount() - m_Started) / 1000));
json.WriteValue("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome);
json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome);
json.WriteValue("terrain", GetGame().GetWorldFile());
SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
string scenario; if (header) scenario = header.m_sName;
json.WriteValue("gameVersion", GetGame().GetBuildVersion());
json.WriteValue("scenario", scenario); json.WriteValue("mods", m_Mods);
json.WriteValue("modsTruncated", m_TruncatedMods); json.WriteValue("playersTruncated", m_TruncatedPlayers);
json.WriteValue("connectedPlayers", GetGame().GetPlayerManager().GetPlayerCount());
json.WriteValue("peakPlayers", m_Peak); json.WriteValue("players", m_Players);
json.WriteValue("reason", m_Mode.EndReason()); json.WriteValue("preparationError", m_Mode.PreparationError());
map<string, int> settings = new map<string, int>();
foreach (string key : ERR_Settings.Keys()) settings.Set(key, m_Mode.Settings().Get(key));
json.WriteValue("settings", settings); json.WriteValue("droppedEvents", m_State.droppedEvents);
string payload = json.SaveToString();
if (payload.Length() > 65536) { m_State.droppedEvents++; m_Dirty = true; return; }
// Bound disk and memory during long outages. Prefer retaining result records.
if (m_State.pending.Count() >= 64)
{
int remove = -1;
for (int i = 0; i < m_State.pending.Count(); i++)
{
if (m_State.pending[i] == m_InFlight) continue;
JsonLoadContext queued = new JsonLoadContext(); string queuedType;
if (queued.LoadFromString(m_State.pending[i]) && queued.ReadValue("type", queuedType) && queuedType == "heartbeat") { remove = i; break; }
}
if (remove < 0) remove = 0;
if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1;
m_State.pending.Remove(remove); m_State.droppedEvents++;
}
m_State.pending.Insert(payload); m_Dirty = true;
Save();
}
protected void Pulse()
{
if (m_Closed || ERR_GameMode.Get() != m_Mode) { Close(); return; }
Refresh();
if (!Enabled()) return;
int now = System.GetTickCount();
if (!m_Ended)
{
Sample();
if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); }
}
if (m_Dirty && !Save()) return;
if (m_Callback && now - m_RequestStarted > 30000) Failed(m_Callback);
if (m_Callback || m_State.pending.IsEmpty() || now < m_NextAttempt) return;
m_Context = GetGame().GetRestApi().GetContext(m_Config.Endpoint);
if (!m_Context) { m_NextAttempt = now + 300000; return; }
m_Callback = new RestCallback(); m_Callback.SetOnSuccess(Delivered); m_Callback.SetOnError(Failed);
m_InFlight = m_State.pending[0]; m_RequestStarted = now;
JsonSaveContext request = new JsonSaveContext(); request.EnableTypeDiscriminator(false);
request.WriteValue("token", m_Config.Token);
// Embed the event object as JSON rather than double-encoding a JSON string.
string wrapper = request.SaveToString();
wrapper = wrapper.Substring(0, wrapper.Length() - 1) + ",\"event\":" + m_InFlight + "}";
m_Context.SetHeaders("Content-Type,application/json"); m_Context.SetTimeout(15);
m_Context.POST(m_Callback, "", wrapper);
}
protected void Delivered(RestCallback callback)
{
if (callback != m_Callback || !Enabled()) return;
if (!ERR_TelemetryRules.Acknowledged(callback.GetHttpCode())) { Failed(callback); return; }
m_State.pending.RemoveItem(m_InFlight); m_InFlight = ""; m_Callback = null;
m_Failures = 0; m_Delivered++; m_Dirty = true; Save();
}
protected void Failed(RestCallback callback)
{
if (callback != m_Callback || !Enabled()) return;
m_Callback = null; m_InFlight = ""; m_Failures = Math.Min(100, m_Failures + 1);
m_NextAttempt = System.GetTickCount() + 1000 * ERR_TelemetryRules.RetrySeconds(m_Failures);
if (m_Failures == 1) Print("Reintroduced [telemetry]: delivery failed; queued events will retry", LogLevel.WARNING);
}
void Close()
{
if (m_Closed) return;
if (Current()) Finish("interrupted");
m_Closed = true;
if (GetGame()) GetGame().GetCallqueue().Remove(Pulse);
m_Callback = null; m_Context = null; m_Mode = null;
}
}
@@ -0,0 +1,56 @@
// Keep instrumentation outside mission rules: telemetry cannot decide an outcome
// or delay vanilla shutdown/restarts. Unsent results survive in the profile queue.
modded class ERR_GameMode
{
protected ref ERR_Telemetry m_ERRTelemetry;
protected override void EOnInit(IEntity owner)
{
super.EOnInit(owner);
if (SCR_Global.IsEditMode() || !Replication.IsServer() || !Settings()) return;
#ifdef WORKBENCH
if (!System.IsCLIParam("errTelemetryTest")) return;
#endif
m_ERRTelemetry = new ERR_Telemetry(this);
Admin().Register(new ERR_TelemetryAdminProvider(m_ERRTelemetry));
OnPhaseChanged().Insert(m_ERRTelemetry.PhaseChanged);
Players().OnChanged().Insert(m_ERRTelemetry.RosterChanged);
}
void ERR_RefreshTelemetry() { if (m_ERRTelemetry) m_ERRTelemetry.Refresh(); }
protected override void FailPreparation(string detail)
{
super.FailPreparation(detail);
if (m_ERRTelemetry && !PreparationError().IsEmpty()) m_ERRTelemetry.Finish("preparation_failed");
}
protected override void OnGameModeEnd(SCR_GameModeEndData endData)
{
// Observe the phase BEFORE vanilla maps native interruption/empty restart to defeat.
if (m_ERRTelemetry) m_ERRTelemetry.Finish(ERR_TelemetryRules.Outcome(Phase(), m_EmptyRestartRequested, !PreparationError().IsEmpty()));
super.OnGameModeEnd(endData);
}
override void OnGameEnd()
{
if (m_ERRTelemetry) m_ERRTelemetry.Close();
super.OnGameEnd();
}
void ~ERR_GameMode() { if (m_ERRTelemetry) m_ERRTelemetry.Close(); }
}
modded class ERR_AdminService
{
override void Execute(ERR_GameMode mode, bool authorized, string arguments, array<string> replies)
{
super.Execute(mode, authorized, arguments, replies);
// Settings only change through the existing authenticated, save-before-apply path.
if (authorized && mode) mode.ERR_RefreshTelemetry();
}
}
class ERR_TelemetryAdminProvider : ERR_AdminProvider
{
protected ERR_Telemetry m_Service;
void ERR_TelemetryAdminProvider(ERR_Telemetry service) { m_Service = service; }
override string Id() { return "telemetry"; }
override void Capabilities(array<string> values)
{
if (m_Service) values.Insert("telemetryStatus=" + m_Service.Status());
}
}