Add ground starting supplies and mission telemetry; record Workshop 0.0.123
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user