Count occupied restarts as failed missions; sync published Workshop version
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-11-07 01:05:43 +01:00
parent 161100cf08
commit e421dcfc15
14 changed files with 640 additions and 50 deletions
@@ -55,6 +55,9 @@ class ERR_TelemetryState
string endpoint;
string previousMissionId;
string previousOutcome;
// Unsent, prebuilt failure for the last occupied snapshot. Only promoted to
// the queue if another instance starts before this mission records its end.
string restartFailure;
int droppedEvents;
ref array<string> pending = {};
}
@@ -157,6 +160,16 @@ class ERR_Telemetry
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;
}
if (!state.restartFailure.IsEmpty())
{
JsonLoadContext checkpoint = new JsonLoadContext();
string checkpointId, checkpointMission, checkpointServer, checkpointType, checkpointOutcome;
int checkpointPhase, checkpointPlayers;
if (state.restartFailure.Length() > 65536 || !checkpoint.LoadFromString(state.restartFailure)) return false;
if (!checkpoint.ReadValue("eventId", checkpointId) || !UUID.IsUUID(checkpointId) || !checkpoint.ReadValue("missionId", checkpointMission) || checkpointMission != state.previousMissionId || !UUID.IsUUID(checkpointMission)) return false;
if (!checkpoint.ReadValue("serverId", checkpointServer) || checkpointServer != state.serverId || !checkpoint.ReadValue("type", checkpointType) || checkpointType != "mission_finished") return false;
if (!checkpoint.ReadValue("outcome", checkpointOutcome) || checkpointOutcome != "defeat" || !checkpoint.ReadValue("phase", checkpointPhase) || checkpointPhase != ERR_Phase.DEFEAT || !checkpoint.ReadValue("connectedPlayers", checkpointPlayers) || checkpointPlayers <= 0) return false;
}
return true;
}
protected bool LoadState(string path)
@@ -182,6 +195,7 @@ class ERR_Telemetry
protected void Purge()
{
m_State.pending.Clear(); m_State.previousMissionId = ""; m_State.previousOutcome = "";
m_State.restartFailure = "";
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");
@@ -216,6 +230,7 @@ class ERR_Telemetry
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;
if (!m_Collected) RecoverRestart();
string previous = m_State.previousMissionId;
string outcome = m_State.previousOutcome;
m_State.previousMissionId = m_MissionId; m_State.previousOutcome = "unfinished";
@@ -224,6 +239,27 @@ class ERR_Telemetry
if (m_Collected) Event("telemetry_resumed");
else Event("mission_started", "", previous, outcome);
m_Collected = true;
Checkpoint();
}
protected void RecoverRestart()
{
if (m_State.previousOutcome == "unfinished" && !m_State.restartFailure.IsEmpty())
{
Enqueue(m_State.restartFailure, true);
m_State.previousOutcome = "defeat";
}
// Saved together with the new start and recovered result, before delivery.
m_State.restartFailure = "";
}
protected void Checkpoint()
{
if (!Enabled() || m_Ended || ERR_MissionRules.Terminal(m_Mode.Phase())) return;
if (GetGame().GetPlayerManager().GetPlayerCount() > 0)
Event("mission_finished", "defeat", "", "", true);
else if (!m_State.restartFailure.IsEmpty())
{
m_State.restartFailure = ""; m_Dirty = true; Save();
}
}
protected void Sample()
{
@@ -255,7 +291,7 @@ class ERR_Telemetry
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);
if (authenticated && m_Mode.Extraction()) player.escaped = m_Mode.Phase() == ERR_Phase.VICTORY && m_Mode.Extraction().DidEscape(account);
}
// Include disconnected escapees/participants with their final authoritative result.
array<ref ERR_PlayerLife> lives = {}; m_Mode.Players().Participants(lives);
@@ -271,10 +307,10 @@ class ERR_Telemetry
tracked.kills = stats.Kills; tracked.friendlyKills = stats.FriendlyKills;
tracked.deaths = stats.Deaths; tracked.downed = stats.Downed; tracked.recoveries = stats.Recoveries;
}
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Extraction().DidEscape(participant.AccountId);
if (m_Mode.Extraction()) tracked.escaped = m_Mode.Phase() == ERR_Phase.VICTORY && m_Mode.Extraction().DidEscape(participant.AccountId);
}
}
void RosterChanged(int playerId) { Sample(); }
void RosterChanged(int playerId) { Sample(); Checkpoint(); }
void PhaseChanged(ERR_Phase previous, ERR_Phase current)
{
if (!Enabled() || m_Ended) return;
@@ -285,8 +321,10 @@ class ERR_Telemetry
{
Refresh();
if (!Enabled() || m_Ended) return;
if (outcome == "interrupted" && GetGame().GetPlayerManager().GetPlayerCount() > 0) outcome = "defeat";
Sample(); m_Ended = true;
m_State.previousOutcome = outcome;
m_State.restartFailure = "";
Event("mission_finished", outcome);
}
protected string Terrain()
@@ -301,7 +339,7 @@ class ERR_Telemetry
}
return ERR_TerrainDisplayName.Resolve(world);
}
protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "")
protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "", bool checkpoint = false)
{
if (!Enabled()) return;
if (!m_Ended) Sample();
@@ -314,7 +352,9 @@ class ERR_Telemetry
json.WriteValue("partial", m_Partial);
int elapsedSeconds = Math.Max(0, (System.GetTickCount() - m_Started) / 1000);
json.WriteValue("elapsedSeconds", elapsedSeconds);
json.WriteValue("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome);
int phase = m_Mode.Phase();
if (outcome == "defeat") phase = ERR_Phase.DEFEAT;
json.WriteValue("phase", phase); json.WriteValue("outcome", outcome);
json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome);
string terrain = Terrain();
json.WriteValue("terrain", terrain);
@@ -325,12 +365,31 @@ class ERR_Telemetry
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());
string reason = m_Mode.EndReason();
if (outcome == "defeat" && !ERR_MissionRules.Terminal(m_Mode.Phase())) reason = "Mission interrupted by a restart while players were connected.";
json.WriteValue("reason", reason); 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; }
// This candidate is private profile state, never sent during a live mission.
if (checkpoint) { m_State.restartFailure = payload; m_Dirty = true; Save(); return; }
Enqueue(payload, type == "mission_finished");
if (type == "mission_finished")
{
m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain;
// Finish can arrive while a previous request is in flight. RemoveItem on its
// acknowledgement still removes that exact payload, leaving this final first.
if (outcome == "victory" || outcome == "defeat")
m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded...");
GetGame().GetCallqueue().CallLater(Pulse, 1, false);
}
m_Dirty = true;
Save();
}
protected void Enqueue(string payload, bool finalResult)
{
// Bound disk and memory during long outages. Prefer retaining result records.
if (m_State.pending.Count() >= 64)
{
@@ -345,19 +404,9 @@ class ERR_Telemetry
if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1;
m_State.pending.Remove(remove); m_State.droppedEvents++;
}
if (type == "mission_finished")
{
m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain;
// Finish can arrive while a previous request is in flight. RemoveItem on its
// acknowledgement still removes that exact payload, leaving this final first.
m_State.pending.InsertAt(payload, 0);
if (outcome == "victory" || outcome == "defeat")
m_Mode.ERR_PublishCommunityStatistics("This map's community record: waiting for the round result to be recorded...");
GetGame().GetCallqueue().CallLater(Pulse, 1, false);
}
if (finalResult) m_State.pending.InsertAt(payload, 0);
else m_State.pending.Insert(payload);
m_Dirty = true;
Save();
}
protected void Pulse()
{
@@ -368,6 +417,7 @@ class ERR_Telemetry
if (!m_Ended)
{
Sample();
Checkpoint();
if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); }
}
if (m_Dirty && !Save()) { CommunityUnavailable(); return; }