Add community map records and telemetry API; record Workshop 0.0.126
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user