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
+3
View File
@@ -0,0 +1,3 @@
ERR_TelemetryConfig {
Endpoint ""
}
@@ -0,0 +1,11 @@
MetaFileClass {
Name "{E72B130684FA492C}Configs/ERR/Telemetry.conf"
Configurations {
CONFResourceClass PC { }
CONFResourceClass HEADLESS : PC { }
CONFResourceClass XBOX_ONE : PC { }
CONFResourceClass XBOX_SERIES : PC { }
CONFResourceClass PS4 : PC { }
CONFResourceClass PS5 : PC { }
}
}
@@ -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());
}
}
+19 -6
View File
@@ -18,6 +18,8 @@ ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_But
ImageWidgetClass { "Is Visible" 0 Name "Selected_OpenWorld" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 98 OffsetTop 98 SizeX 180 OffsetRight -180 SizeY 3 OffsetBottom -101 } Color 0.9 0.66 0.25 1 }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "OpenTerrains" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 108 OffsetTop 108 SizeX 180 OffsetRight -180 SizeY 42 OffsetBottom -150 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Terrains" } } }
ImageWidgetClass { "Is Visible" 0 Name "Selected_OpenTerrains" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 152 OffsetTop 152 SizeX 180 OffsetRight -180 SizeY 3 OffsetBottom -155 } Color 0.9 0.66 0.25 1 }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "OpenTelemetry" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 162 OffsetTop 162 SizeX 180 OffsetRight -180 SizeY 42 OffsetBottom -204 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Statistics" } } }
ImageWidgetClass { "Is Visible" 0 Name "Selected_OpenTelemetry" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 0 OffsetLeft 0 PositionY 206 OffsetTop 206 SizeX 180 OffsetRight -180 SizeY 3 OffsetBottom -209 } Color 0.9 0.66 0.25 1 }
} }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "Main_Forces" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 174 OffsetLeft 174 PositionY 106 OffsetTop 106 SizeX 138 OffsetRight -312 SizeY 42 OffsetBottom -148 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Forces" } } }
ImageWidgetClass { "Is Visible" 0 Name "Selected_Main_Forces" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 174 OffsetLeft 174 PositionY 150 OffsetTop 150 SizeX 138 OffsetRight -312 SizeY 3 OffsetBottom -153 } Color 0.9 0.66 0.25 1 }
@@ -135,6 +137,16 @@ ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_But
TextWidgetClass { Name "Saved_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 424 OffsetTop 424 SizeX 456 OffsetRight -964 SizeY 24 OffsetBottom -448 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "Help_emptyRestartHours" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 508 OffsetLeft 508 PositionY 452 OffsetTop 452 SizeX 456 OffsetRight -964 SizeY 28 OffsetBottom -480 } Color 0.9 0.92 0.94 1 Text "0 off; 1-168 real hours empty. Default 5. Applies now." "Font Size" 14 "Min Font Size" 14 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
} }
FrameWidgetClass { "Is Visible" 0 Name "TelemetryPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1220 SizeY 486 OffsetBottom -646 } {
TextWidgetClass { Name "TelemetryHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 10 OffsetTop 10 SizeX 936 OffsetRight -964 SizeY 32 OffsetBottom -42 } Color 0.9 0.92 0.94 1 Text "Mission statistics" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "TelemetryDescription" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 55 OffsetTop 55 SizeX 936 OffsetRight -964 SizeY 80 OffsetBottom -135 } Color 0.9 0.92 0.94 1 Text "Share mission starts, progress, results, terrain, loaded mods and player counts with the configured statistics service. Enabled by default." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "TelemetryOn" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 150 OffsetTop 150 SizeX 140 OffsetRight -168 SizeY 32 OffsetBottom -182 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Enabled" } } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "TelemetryOff" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 180 OffsetLeft 180 PositionY 150 OffsetTop 150 SizeX 152 OffsetRight -332 SizeY 32 OffsetBottom -182 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Disabled" } } }
TextWidgetClass { Name "Saved_telemetryEnabled" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 200 OffsetTop 200 SizeX 936 OffsetRight -964 SizeY 32 OffsetBottom -232 } Color 0.9 0.92 0.94 1 Text "Waiting for server" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "TelemetryStatus" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 232 OffsetTop 232 SizeX 936 OffsetRight -964 SizeY 26 OffsetBottom -258 } Color 0.9 0.92 0.94 1 Text "Delivery status unavailable" "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "TelemetryPrivacy" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 278 OffsetTop 278 SizeX 936 OffsetRight -964 SizeY 78 OffsetBottom -356 } Color 0.9 0.92 0.94 1 Text "Player names and stable game account IDs are included to count participation and escapes across missions. Connected spectators are distinguished from participants and escapees." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "TelemetryDisableHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 360 OffsetTop 360 SizeX 936 OffsetRight -964 SizeY 80 OffsetBottom -440 } Color 0.9 0.92 0.94 1 Text "Disable to stop collection and sending, and discard unsent records. Already delivered statistics remain with the statistics service." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
} }
FrameWidgetClass { "Is Visible" 0 Name "HQPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1220 SizeY 486 OffsetBottom -646 } {
TextWidgetClass {
Name "HQHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 510 OffsetRight -538 SizeY 50 OffsetBottom -56 }
@@ -1177,7 +1189,7 @@ TextWidgetClass { Name "Help_aiShadow" Slot FrameWidgetSlot { Anchor 0 0 0 0 Pos
TextWidgetClass { Name "AIChangesHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 350 OffsetTop 350 SizeX 936 OffsetRight -964 SizeY 72 OffsetBottom -422 } Color 0.9 0.92 0.94 1 Text "Current is the default. Proposal logging is for comparing plans before enabling coordinated orders. Neither setting changes the running round." "Font Size" 18 "Min Font Size" 18 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
} }
FrameWidgetClass { "Is Visible" 0 Name "StarterPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1220 SizeY 486 OffsetBottom -646 } {
TextWidgetClass { Name "StarterHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 5 OffsetTop 5 SizeX 350 OffsetRight -378 SizeY 30 OffsetBottom -35 } Color 0.9 0.92 0.94 1 Text "Shared starting backpack" "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "StarterHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 5 OffsetTop 5 SizeX 350 OffsetRight -378 SizeY 30 OffsetBottom -35 } Color 0.9 0.92 0.94 1 Text "Shared starting supplies" "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "StarterDefault" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 410 OffsetLeft 410 PositionY 0 OffsetTop 0 SizeX 260 OffsetRight -670 SizeY 40 OffsetBottom -40 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Default loadout" } } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "StarterCustom" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 690 OffsetLeft 690 PositionY 0 OffsetTop 0 SizeX 274 OffsetRight -964 SizeY 40 OffsetBottom -40 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Custom loadout" } } }
TextWidgetClass { Name "StarterState" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 46 OffsetTop 46 SizeX 936 OffsetRight -964 SizeY 44 OffsetBottom -90 } Color 0.9 0.92 0.94 1 Text "Waiting for saved loadout..." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
@@ -1190,11 +1202,12 @@ TextWidgetClass { Name "StarterQuantityLabel" Slot FrameWidgetSlot { Anchor 0 0
ButtonWidgetClass : "{DA43C77A0D1E59F8}UI/layouts/ERR/AdminValue.layout" { Name "StarterQuantity" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 676 OffsetLeft 676 PositionY 144 OffsetTop 144 SizeX 88 OffsetRight -764 SizeY 40 OffsetBottom -184 } components { SCR_EditBoxComponent "{547290FFBD5B33E9}" { m_sLabel "" } } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "StarterAdd" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 776 OffsetLeft 776 PositionY 144 OffsetTop 144 SizeX 188 OffsetRight -964 SizeY 40 OffsetBottom -184 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Save quantity" } } }
TextWidgetClass { Name "StarterCatalogState" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 192 OffsetTop 192 SizeX 936 OffsetRight -964 SizeY 28 OffsetBottom -220 } Color 0.9 0.92 0.94 1 Text "Choose a mod, then an item. Quantity 0 removes an item." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "StarterSavedHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 230 OffsetTop 230 SizeX 936 OffsetRight -964 SizeY 28 OffsetBottom -258 } Color 0.9 0.92 0.94 1 Text "Saved custom contents (select an entry to remove)" "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{4B5AE6E64037FFB4}UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout" { Name "StarterSaved" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 266 OffsetTop 266 SizeX 728 OffsetRight -756 SizeY 40 OffsetBottom -306 } components { SCR_ComboBoxComponent "{546B1D464BC32853}" { m_bUseLabel 0 m_sLabel "" m_fSizeWithoutLabel 40 } } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "StarterRemove" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 776 OffsetLeft 776 PositionY 266 OffsetTop 266 SizeX 188 OffsetRight -964 SizeY 40 OffsetBottom -306 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Remove item" } } }
TextWidgetClass { Name "StarterSummary" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 314 OffsetTop 314 SizeX 936 OffsetRight -964 SizeY 30 OffsetBottom -344 } Color 0.9 0.92 0.94 1 Text "Custom list is empty." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "StarterHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 352 OffsetTop 352 SizeX 936 OffsetRight -964 SizeY 72 OffsetBottom -424 } Color 0.9 0.92 0.94 1 Text "Up to 32 item types / 64 items. Changes apply next round, at prison or crash. The backpack has limited space; items that do not fit are omitted. Enemy spawn weights do not affect this loadout." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{4B5AE6E64037FFB4}UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout" { Name "StarterDestination" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 226 OffsetTop 226 SizeX 540 OffsetRight -568 SizeY 40 OffsetBottom -266 } components { SCR_ComboBoxComponent "{546B1D464BC32853}" { m_bUseLabel 0 m_sLabel "" m_fSizeWithoutLabel 40 } } }
TextWidgetClass { Name "StarterSavedHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 278 OffsetTop 278 SizeX 936 OffsetRight -964 SizeY 28 OffsetBottom -306 } Color 0.9 0.92 0.94 1 Text "Saved custom contents (select an entry to remove)" "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
ButtonWidgetClass : "{4B5AE6E64037FFB4}UI/layouts/WidgetLibrary/ComboBox/WLib_ComboBox.layout" { Name "StarterSaved" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 314 OffsetTop 314 SizeX 728 OffsetRight -756 SizeY 40 OffsetBottom -354 } components { SCR_ComboBoxComponent "{546B1D464BC32853}" { m_bUseLabel 0 m_sLabel "" m_fSizeWithoutLabel 40 } } }
ButtonWidgetClass : "{75C912A1C89BE6C2}UI/layouts/WidgetLibrary/Buttons/WLib_ButtonText.layout" { Name "StarterRemove" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 776 OffsetLeft 776 PositionY 314 OffsetTop 314 SizeX 188 OffsetRight -964 SizeY 40 OffsetBottom -354 } components { SCR_ButtonTextComponent "{5548F0143AE99095}" { m_sText "Remove item" } } }
TextWidgetClass { Name "StarterSummary" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 362 OffsetTop 362 SizeX 936 OffsetRight -964 SizeY 30 OffsetBottom -392 } Color 0.9 0.92 0.94 1 Text "Custom list is empty." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
TextWidgetClass { Name "StarterHelp" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 400 OffsetTop 400 SizeX 936 OffsetRight -964 SizeY 72 OffsetBottom -472 } Color 0.9 0.92 0.94 1 Text "Choose where to spawn each quantity before saving: inside the backpack or on the ground beside it. Up to 32 entries / 64 items combined. Applies next round, at prison or crash. Items that do not fit inside are also left nearby." "Font Size" 17 "Min Font Size" 17 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }
} }
FrameWidgetClass { "Is Visible" 0 Name "SarkaPage" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 220 OffsetLeft 220 PositionY 160 OffsetTop 160 SizeX 1000 OffsetRight -1220 SizeY 486 OffsetBottom -646 } {
TextWidgetClass { Name "SarkaHeading" Slot FrameWidgetSlot { Anchor 0 0 0 0 PositionX 28 OffsetLeft 28 PositionY 6 OffsetTop 6 SizeX 710 OffsetRight -738 SizeY 32 OffsetBottom -38 } Color 0.9 0.92 0.94 1 Text "Šárka special-forces hunters" "Font Size" 22 "Min Font Size" 22 Wrap 1 FontProperties FontProperties { Font "{3E7733BAC8C831F6}UI/Fonts/RobotoCondensed/RobotoCondensed_Regular.fnt" } }