Add community map records and telemetry API; record Workshop 0.0.126
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-30 14:19:39 +01:00
parent c6ee764c6c
commit 5453159867
26 changed files with 3201 additions and 20 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
ERR_TelemetryConfig {
Endpoint ""
Endpoint "https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events"
}
@@ -0,0 +1,44 @@
class ERR_CommunityMapRecord
{
int wins = -1;
int losses = -1;
int completed = -1;
int partialMissions = -1;
bool Valid()
{
return wins >= 0 && wins <= 1000000000 && losses >= 0 && losses <= 1000000000 && completed == wins + losses && completed > 0 && partialMissions >= 0 && partialMissions <= completed;
}
string Line(string label)
{
int rate = Math.Round(100.0 * wins / completed);
return string.Format("%1: %2 wins | %3 failures | ", label, wins, losses) + rate.ToString() + "% win rate";
}
}
class ERR_CommunityStatistics
{
int schemaVersion;
string terrain;
string serverId;
string missionId;
string eventId;
string outcome;
ref ERR_CommunityMapRecord server;
ref ERR_CommunityMapRecord allServers;
bool Matches(string expectedServer, string expectedMission, string expectedEvent, string expectedTerrain, string expectedOutcome)
{
if (schemaVersion != 1 || serverId != expectedServer || missionId != expectedMission || eventId != expectedEvent || terrain != expectedTerrain || outcome != expectedOutcome) return false;
if (outcome != "victory" && outcome != "defeat") return false;
if (!server || !allServers || !server.Valid() || !allServers.Valid()) return false;
if (server.wins > allServers.wins || server.losses > allServers.losses) return false;
if (outcome == "victory" && server.wins == 0) return false;
if (outcome == "defeat" && server.losses == 0) return false;
return true;
}
string Summary()
{
string text = "This map's record (including this round)\n";
text += server.Line("This server") + "\n" + allServers.Line("All reporting servers");
if (allServers.partialMissions > 0) text += "\nIncludes rounds with partial statistics.";
return text;
}
}
@@ -3,9 +3,21 @@ modded class ERR_GameMode
protected ref ERR_RoundStatistics m_ERRStatistics;
[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]
protected string m_ERRRoundSummary;
[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]
protected string m_ERRCommunitySummary = "Community map records unavailable.";
protected ref ScriptInvoker m_ERRStatisticsChanged = new ScriptInvoker();
ERR_RoundStatistics ERR_Statistics() { return m_ERRStatistics; }
string ERR_RoundSummary() { return m_ERRRoundSummary; }
string ERR_RoundSummary()
{
if (m_ERRRoundSummary.IsEmpty()) return "";
return m_ERRCommunitySummary + "\n\n" + m_ERRRoundSummary;
}
void ERR_PublishCommunityStatistics(string summary)
{
if (!Replication.IsServer() || summary.Length() > 2048 || m_ERRCommunitySummary == summary) return;
m_ERRCommunitySummary = summary;
Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();
}
ScriptInvoker ERR_OnStatisticsChanged() { return m_ERRStatisticsChanged; }
protected void ERR_OnRoundStatisticsReplicated() { m_ERRStatisticsChanged.Invoke(); }
void ERR_PublishRoundStatistics(string summary)
@@ -87,6 +87,9 @@ class ERR_Telemetry
protected RestContext m_Context;
protected string m_MissionId;
protected string m_InFlight;
protected string m_FinalEventId;
protected string m_FinalOutcome;
protected bool m_CommunityReady;
protected int m_Started;
protected int m_LastSample;
protected int m_NextHeartbeat;
@@ -103,6 +106,7 @@ class ERR_Telemetry
protected bool m_Dirty;
protected bool m_TruncatedPlayers;
protected bool m_TruncatedMods;
protected bool m_CredentialBlocked;
void ERR_Telemetry(ERR_GameMode mode)
{
@@ -189,6 +193,7 @@ class ERR_Telemetry
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_CredentialBlocked) return "Telemetry credential storage needs attention; delivery suspended.";
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());
}
@@ -289,7 +294,8 @@ class ERR_Telemetry
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);
string eventId = UUID.GenV4();
json.WriteValue("eventId", eventId); 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);
@@ -324,7 +330,18 @@ class ERR_Telemetry
if (remove == 0 && !m_InFlight.IsEmpty()) remove = 1;
m_State.pending.Remove(remove); m_State.droppedEvents++;
}
m_State.pending.Insert(payload); m_Dirty = true;
if (type == "mission_finished")
{
m_FinalEventId = eventId; m_FinalOutcome = outcome;
// 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);
}
else m_State.pending.Insert(payload);
m_Dirty = true;
Save();
}
protected void Pulse()
@@ -338,11 +355,21 @@ class ERR_Telemetry
Sample();
if (now >= m_NextHeartbeat) { m_NextHeartbeat = now + 300000; Event("heartbeat"); }
}
if (m_Dirty && !Save()) return;
if (m_Dirty && !Save()) { CommunityUnavailable(); return; }
if (m_Callback && now - m_RequestStarted > 30000) Failed(m_Callback);
if (m_Callback || m_State.pending.IsEmpty() || now < m_NextAttempt) return;
if (m_Config.Token.IsEmpty())
{
string token;
if (!ERR_TelemetryCredentials.Resolve(m_Config.Endpoint, m_State.serverId, token))
{
CommunityUnavailable();
m_CredentialBlocked = true; m_NextAttempt = now + 30000; return;
}
m_Config.Token = token; m_CredentialBlocked = false;
}
m_Context = GetGame().GetRestApi().GetContext(m_Config.Endpoint);
if (!m_Context) { m_NextAttempt = now + 300000; return; }
if (!m_Context) { CommunityUnavailable(); 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);
@@ -355,16 +382,39 @@ class ERR_Telemetry
}
protected void Delivered(RestCallback callback)
{
if (callback != m_Callback || !Enabled()) return;
if (callback != m_Callback || !Enabled() || !Current()) return;
if (!ERR_TelemetryRules.Acknowledged(callback.GetHttpCode())) { Failed(callback); return; }
UpdateCommunity(callback);
m_State.pending.RemoveItem(m_InFlight); m_InFlight = ""; m_Callback = null;
m_Failures = 0; m_Delivered++; m_Dirty = true; Save();
if (m_Ended) GetGame().GetCallqueue().CallLater(Pulse, 1, false);
}
protected void CommunityUnavailable()
{
if (!m_CommunityReady && Current() && m_Ended && (m_FinalOutcome == "victory" || m_FinalOutcome == "defeat"))
m_Mode.ERR_PublishCommunityStatistics("Community map records unavailable. This round's local statistics are shown below.");
}
protected void UpdateCommunity(RestCallback callback)
{
if (!m_Ended || m_FinalEventId.IsEmpty() || !Current()) return;
JsonLoadContext sent = new JsonLoadContext(); string sentEvent;
if (!sent.LoadFromString(m_InFlight) || !sent.ReadValue("eventId", sentEvent) || sentEvent != m_FinalEventId) return;
if (m_FinalOutcome != "victory" && m_FinalOutcome != "defeat") return;
JsonLoadContext reply = new JsonLoadContext();
ERR_CommunityStatistics community;
if (callback.GetData().Length() > 8192 || !reply.LoadFromString(callback.GetData()) || !reply.ReadValue("community", community) || !community || !community.Matches(m_State.serverId, m_MissionId, m_FinalEventId, GetGame().GetWorldFile(), m_FinalOutcome))
{
CommunityUnavailable(); return;
}
m_CommunityReady = true;
m_Mode.ERR_PublishCommunityStatistics(community.Summary());
}
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);
CommunityUnavailable();
if (m_Failures == 1) Print("Reintroduced [telemetry]: delivery failed; queued events will retry", LogLevel.WARNING);
}
void Close()
@@ -0,0 +1,52 @@
// Each installation claims its random server ID with its own persistent secret.
// No receiver administrator secret is bundled with the mod or sent by players.
class ERR_TelemetryCredential
{
string endpoint;
string serverId;
string token;
}
class ERR_TelemetryCredentials
{
static const string PATH = "$profile:EscapistsTelemetryCredentials.json";
static bool Valid(ERR_TelemetryCredential entry)
{
return entry && !entry.endpoint.IsEmpty() && UUID.IsUUID(entry.serverId) && entry.token.Length() >= 32 && entry.token.Length() <= 256;
}
static bool Resolve(string endpoint, string serverId, out string token)
{
token = "";
ref array<ref ERR_TelemetryCredential> entries = {};
if (FileIO.FileExists(PATH))
{
JsonLoadContext loaded = new JsonLoadContext();
// Never silently replace a damaged credential: the receiver already owns its
// hash, and a new secret must not take over the same server's history.
if (!loaded.LoadFromFile(PATH) || !loaded.ReadValue("credentials", entries) || !entries || entries.Count() > 16) return false;
foreach (ERR_TelemetryCredential known : entries)
{
if (!Valid(known)) return false;
if (known.endpoint == endpoint && known.serverId == serverId) token = known.token;
}
if (!token.IsEmpty()) return true;
}
if (entries.Count() >= 16) return false;
ERR_TelemetryCredential created = new ERR_TelemetryCredential();
created.endpoint = endpoint; created.serverId = serverId;
string first = UUID.GenV4(); string second = UUID.GenV4();
created.token = first + second;
entries.Insert(created);
JsonSaveContext saved = new JsonSaveContext(); saved.EnableTypeDiscriminator(false);
saved.WriteValue("credentials", entries);
if (!saved.SaveToFile(PATH + ".tmp")) return false;
string written;
if (!ERR_ProfileConfig.Text(PATH + ".tmp", written) || written != saved.SaveToString()) return false;
if (!FileIO.CopyFile(PATH + ".tmp", PATH)) return false;
string confirmed;
if (!ERR_ProfileConfig.Text(PATH, confirmed) || confirmed != written) return false;
FileIO.DeleteFile(PATH + ".tmp");
// Publish to the HTTP sender only after the exact credential is durable.
token = created.token;
return true;
}
}