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:
+7
-1
@@ -1,6 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
Latest published Workshop version: **0.0.125**
|
||||
Latest published Workshop version: **0.0.126**
|
||||
|
||||
## 2026-10-30 — Community mission statistics
|
||||
|
||||
- Mission statistics now connect automatically on community servers without manual registration.
|
||||
- Server administrators can disable reporting under Mission → Statistics.
|
||||
- Victory and failure screens show the current map's wins, failures and win rate on this server and across all reporting servers.
|
||||
|
||||
## 2026-10-30 — Mission results
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -402,3 +402,11 @@ start fresh with the next mission. They work with statistics telemetry disabled
|
||||
and require no web service. At most 128 distinct participants are retained; the
|
||||
report explicitly marks truncation if that limit is reached. No account IDs are
|
||||
included in the displayed or replicated summary.
|
||||
|
||||
After a victory or defeat is accepted by the community statistics receiver, the
|
||||
screen also shows the current map's wins, failures and win rate on this server
|
||||
and across all reporting servers. Totals include this round and update if they
|
||||
arrive while the screen is open. Different maps are counted separately using
|
||||
their loaded world resources. Startup failures, empty restarts and interrupted
|
||||
sessions are excluded. Community totals show unavailable if reporting is disabled
|
||||
or delivery fails; local round statistics still work.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frozen local Workbench community-statistics fixture; no live server or installed asset writes."""
|
||||
from pathlib import Path
|
||||
import hashlib,json,shutil,subprocess,sys,os,time
|
||||
ROOT=Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0,str(ROOT/'tools'))
|
||||
from source_manifest import addon_manifest
|
||||
from headless_game import assert_prefix_idle
|
||||
name=sys.argv[1]
|
||||
outcome=sys.argv[2]
|
||||
if outcome not in {'victory','defeat'}: raise ValueError('Expected victory or defeat')
|
||||
if not name.isalnum(): raise ValueError('Use an alphanumeric run suffix')
|
||||
source=Path('/tmp/erri-community-statistics-source-'+name)
|
||||
profile=Path('/tmp/erri-community-statistics-run-'+name)
|
||||
compat=ROOT/'.local/playtest-compat-20260920'
|
||||
assert_prefix_idle(compat)
|
||||
source.mkdir();profile.mkdir()
|
||||
for rel in addon_manifest(ROOT):
|
||||
target=source/rel;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(ROOT/rel,target)
|
||||
shutil.copytree(ROOT/'tools',source/'tools',ignore=shutil.ignore_patterns('__pycache__'))
|
||||
(source/'docs').mkdir();shutil.copy2(ROOT/'docs/external-addons.json',source/'docs/external-addons.json')
|
||||
fixture=Path(__file__).with_name('native-fixture.c')
|
||||
target=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_ZCommunityStatisticsFixture.c'
|
||||
target.write_text(fixture.read_text().replace('TEST_OUTCOME', outcome))
|
||||
# Opt-in fixture is independent of the historical generic Core smoke.
|
||||
p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c'
|
||||
p.write_text(p.read_text().replace('array<string> independentFixtures = {','array<string> independentFixtures = {"community-statistics", '))
|
||||
p=source/'tools/headless_workbench.py'
|
||||
p.write_text(p.read_text().replace("choices=['coordinated-ai',", "choices=['community-statistics', 'coordinated-ai',").replace("a.smoke not in {'coordinated-ai',", "a.smoke not in {'community-statistics', 'coordinated-ai',"))
|
||||
# The existing fatal-loss diagnostic is guarded by errSmoke; this Core-only
|
||||
# runner uses errBaseSmoke. Adapt only that test-helper flag in the frozen copy.
|
||||
p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c'
|
||||
p.write_text(p.read_text().replace('System.GetCLIParam("errSmoke", enabled)', 'System.GetCLIParam("errBaseSmoke", enabled)'))
|
||||
installed=Path.home()/'.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons'
|
||||
(profile/'profile').mkdir()
|
||||
(profile/'profile/EscapistsFactions.json').write_text(json.dumps({'escapists':'ussr','enemy':'us','independent':'fia'})+'\n')
|
||||
(profiles:=profile/'fixture-inputs.json').write_text(json.dumps({'fixture_sha256':hashlib.sha256(fixture.read_bytes()).hexdigest(),'production_sources':addon_manifest(ROOT),'source':str(source),'profile':str(profile)},indent=2)+'\n')
|
||||
command=[sys.executable,str(source/'tools/headless_workbench.py'),'--compat',str(compat),'--profile',str(profile),'--external-addons',str(installed),'--project','core','--smoke','community-statistics','--rounds','1','--timeout','300']
|
||||
with (profile/'runner-output.log').open('w') as output:
|
||||
result=subprocess.Popen(command,stdout=output,stderr=subprocess.STDOUT)
|
||||
captured=False
|
||||
while result.poll() is None:
|
||||
if not captured and any('ERR STATS UI READY' in log.read_text(errors='replace') for log in (profile/'logs').glob('*/console.log')):
|
||||
for proc in Path('/proc').iterdir():
|
||||
if not proc.name.isdigit(): continue
|
||||
try:
|
||||
args=(proc/'cmdline').read_bytes().decode(errors='replace').replace('\\', '/')
|
||||
if 'ArmaReforgerWorkbenchSteamDiag.exe' not in args or str(source/'addons/core/addon.gproj') not in args: continue
|
||||
values=dict(x.split('=',1) for x in (proc/'environ').read_bytes().decode(errors='replace').split('\0') if '=' in x)
|
||||
if 'DISPLAY' not in values: continue
|
||||
env=os.environ.copy()
|
||||
for key in ['DISPLAY','XAUTHORITY']:
|
||||
if key in values: env[key]=values[key]
|
||||
capture=subprocess.run(['import','-display',values['DISPLAY'],'-window','root',str(profile/'results-desktop.png')],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if capture.returncode==0: captured=True;break
|
||||
except (OSError,ValueError,subprocess.TimeoutExpired): pass
|
||||
time.sleep(1)
|
||||
print(json.dumps({'exit':result.returncode,'source':str(source),'profile':str(profile)}))
|
||||
print('\n'.join((profile/'runner-output.log').read_text().splitlines()[-45:]))
|
||||
raise SystemExit(result.returncode)
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifdef WORKBENCH
|
||||
// Injected only into a frozen private fixture copy. No public telemetry requests.
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected int m_ERRCommunityTestStage;
|
||||
protected float m_ERRCommunityTestWait;
|
||||
protected ref ERR_CommunityStatistics m_ERRCommunityTestResult;
|
||||
protected override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);
|
||||
if (Replication.IsServer() && !SCR_Global.IsEditMode()) GetGame().GetCallqueue().CallLater(ERR_CommunityTest, 500, true);
|
||||
}
|
||||
protected bool ERR_CommunityCheck(bool ok, string detail)
|
||||
{
|
||||
if (ok) { Print("ERR SMOKE PASS: community " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: community " + detail, LogLevel.ERROR);
|
||||
GetGame().GetCallqueue().Remove(ERR_CommunityTest); return false;
|
||||
}
|
||||
protected void ERR_CommunityTest()
|
||||
{
|
||||
if (!PreparationError().IsEmpty()) { ERR_CommunityCheck(false, PreparationError()); return; }
|
||||
if (GetWorld().GetWorldTime() < m_ERRCommunityTestWait) return;
|
||||
if (m_ERRCommunityTestStage == 0)
|
||||
{
|
||||
if (!Active()) return;
|
||||
SCR_PlayerController pc = SCR_PlayerController.Cast(GetGame().GetPlayerController());
|
||||
if (!pc || !pc.GetControlledEntity()) return;
|
||||
string account = UUID.GenV4(); string first; string second;
|
||||
if (!ERR_CommunityCheck(ERR_TelemetryCredentials.Resolve("https://fixture.invalid/api/mission-events", account, first), "private credential saved")) return;
|
||||
if (!ERR_CommunityCheck(ERR_TelemetryCredentials.Resolve("https://fixture.invalid/api/mission-events", account, second) && first == second && first.Length() == 72, "native credential serialization preserves installation secret")) return;
|
||||
ERR_CommunityStatistics result = new ERR_CommunityStatistics();
|
||||
result.schemaVersion = 1; result.serverId = account; result.missionId = UUID.GenV4(); result.eventId = UUID.GenV4();
|
||||
result.terrain = GetGame().GetWorldFile(); result.outcome = "TEST_OUTCOME";
|
||||
result.server = new ERR_CommunityMapRecord(); result.server.wins = 2; result.server.losses = 1; result.server.completed = 3; result.server.partialMissions = 0;
|
||||
result.allServers = new ERR_CommunityMapRecord(); result.allServers.wins = 7; result.allServers.losses = 3; result.allServers.completed = 10; result.allServers.partialMissions = 1;
|
||||
JsonSaveContext saved = new JsonSaveContext(); saved.EnableTypeDiscriminator(false); saved.WriteValue("community", result);
|
||||
JsonLoadContext loaded = new JsonLoadContext();
|
||||
if (!ERR_CommunityCheck(loaded.LoadFromString(saved.SaveToString()) && loaded.ReadValue("community", m_ERRCommunityTestResult) && m_ERRCommunityTestResult.Matches(result.serverId, result.missionId, result.eventId, result.terrain, result.outcome), "native JSON roundtrip and current map/result validation")) return;
|
||||
if (!ERR_CommunityCheck(!m_ERRCommunityTestResult.Matches(result.serverId, result.missionId, result.eventId, "another-map", result.outcome), "another map is rejected")) return;
|
||||
ERR_PublishCommunityStatistics("Waiting for community map records...");
|
||||
// Native end-screen fixture: this does not prove an organic escape route.
|
||||
m_Phase = ERR_Phase.EXTRACTION;
|
||||
ERR_Phase terminal = ERR_Phase.DEFEAT;
|
||||
if (result.outcome == "victory") terminal = ERR_Phase.VICTORY;
|
||||
if (!ERR_CommunityCheck(Transition(terminal), "native TEST_OUTCOME screen requested")) return;
|
||||
m_ERRCommunityTestStage = 1; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 2000; return;
|
||||
}
|
||||
if (m_ERRCommunityTestStage == 1)
|
||||
{
|
||||
ERR_PublishCommunityStatistics(m_ERRCommunityTestResult.Summary());
|
||||
m_ERRCommunityTestStage = 2; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 1000; return;
|
||||
}
|
||||
if (m_ERRCommunityTestStage == 2)
|
||||
{
|
||||
MenuBase menu = GetGame().GetMenuManager().GetTopMenu(); if (!menu) return;
|
||||
TextWidget text = TextWidget.Cast(menu.GetRootWidget().FindAnyWidget("RoundStatistics"));
|
||||
if (!ERR_CommunityCheck(text && text.IsVisible() && text.GetText() == ERR_RoundSummary() && text.GetText().Contains("All reporting servers: 7 wins | 3 failures | 70% win rate"), "open TEST_OUTCOME screen refreshes after late community result")) return;
|
||||
Print("ERR STATS UI READY: community TEST_OUTCOME screen");
|
||||
System.MakeScreenshot("$logs:community-TEST_OUTCOME");
|
||||
m_ERRCommunityTestStage = 3; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 5000; return;
|
||||
}
|
||||
Print("ERR SMOKE COMPLETE: community TEST_OUTCOME native credentials, JSON validation and result screen refresh");
|
||||
GetGame().GetCallqueue().Remove(ERR_CommunityTest);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"date": "2026-10-30",
|
||||
"worker": {"miniflare_tests_passed": 14, "deploy_dry_run": "passed", "production_deployment": "not performed for map-summary changes"},
|
||||
"native": {
|
||||
"victory": {"run": "mapwin02", "assertions_passed": 6, "result": "smoke-completed"},
|
||||
"defeat": {"run": "maploss01", "assertions_passed": 6, "result": "smoke-completed"},
|
||||
"script_or_runtime_failures": 0,
|
||||
"engine_errors_per_run": 13,
|
||||
"engine_errors": ["4 external localization resource GUID/name errors", "6 unknown Parent properties", "SlidingTrackMaterial", "m_HardLandingVelocity", "m_DeathLandingVelocity"],
|
||||
"runner_exit_code": 1,
|
||||
"scope": "Scripted local Workbench transitions and actual end-screen widget text updates. No organic escape, multiplayer replication or live Worker round trip tested. Desktop screenshot capture did not produce UI evidence.",
|
||||
"revision_note": "The subsequent guard preserving accepted records when older queued uploads fail was verified by the production-method policy test; native fixtures preceded that small guard change."
|
||||
},
|
||||
"game_policy_tests": ["test_telemetry.py", "test_telemetry_credentials.py", "test_community_statistics.py", "test_round_statistics.py", "test_failure_result_refresh.py"],
|
||||
"publication": "Not published to Workshop"
|
||||
}
|
||||
+50
-11
@@ -8,15 +8,34 @@ save-before-apply behavior also apply to this setting.
|
||||
|
||||
Telemetry runs only on the mission authority, never independently on joining
|
||||
clients. Workbench runs do not collect unless explicitly launched with the
|
||||
`errTelemetryTest` diagnostic flag. There is no statistics website or production
|
||||
receiver included in this change.
|
||||
`errTelemetryTest` diagnostic flag. A deployable Cloudflare Worker/D1 receiver is
|
||||
provided in [services/telemetry-worker](../services/telemetry-worker/README.md).
|
||||
It includes automatic server enrollment and protected mission/player query APIs.
|
||||
The Worker must be deployed separately. There is no statistics website.
|
||||
|
||||
## Receiving URL and configuration
|
||||
|
||||
The bundled receiving URL belongs in `addons/core/Configs/ERR/Telemetry.conf`.
|
||||
It is currently empty pending the receiver's actual URL. Until configured, no
|
||||
telemetry is collected or sent, even with the switch enabled. The menu reports
|
||||
this condition rather than claiming that statistics are being delivered.
|
||||
The bundled receiving URL in `addons/core/Configs/ERR/Telemetry.conf` is
|
||||
`https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events`.
|
||||
Any host running the updated mission can report without contacting the receiver
|
||||
administrator or editing its configuration. Mission → Statistics remains the
|
||||
administrator opt-out. This source change requires a new Workshop release;
|
||||
0.0.125 does not contain automatic enrollment.
|
||||
|
||||
Before the first request, the host saves its own randomly generated credential
|
||||
in `$profile:EscapistsTelemetryCredentials.json`. The Worker enrolls an unknown
|
||||
server ID on its first valid event and stores only the credential hash. Later
|
||||
requests must present the same credential. Enrollment and the initial event are
|
||||
committed together. Disabling telemetry prevents enrollment and delivery and
|
||||
discards pending events; the credential remains for continuity when re-enabled.
|
||||
No shared secret or receiver administrator credential is bundled in the mod.
|
||||
|
||||
Keep the credentials file with the profile state when moving a server. Damaged
|
||||
credentials suspend delivery without silently rotating them. The file retains
|
||||
up to 16 server/endpoint pairs, so changing receiving URLs does not send one
|
||||
receiver's secret to another. Explicit operator-issued tokens still override
|
||||
automatic credentials. The default receiver accepts community reports, not
|
||||
independently verified gameplay or identities.
|
||||
|
||||
An operator can override the URL and optionally name/authenticate their instance
|
||||
with `$profile:EscapistsTelemetry.json`:
|
||||
@@ -31,8 +50,9 @@ with `$profile:EscapistsTelemetry.json`:
|
||||
|
||||
Use the full POST endpoint, with its exact trailing-slash convention. HTTPS is
|
||||
required. URL credentials, fragments and query parameters are rejected. The
|
||||
optional token goes in the HTTPS JSON body, never in a URL, menu snapshot, log,
|
||||
Workshop resource, or persisted event queue. Configuration loads when the mission
|
||||
explicit or automatically generated token goes in the HTTPS JSON body, never in
|
||||
a URL, menu snapshot, log, Workshop resource, or persisted event queue.
|
||||
Configuration loads when the mission
|
||||
starts. An empty endpoint overrides the bundled default and prevents sending.
|
||||
|
||||
`serverId` is a generated UUID persisted in
|
||||
@@ -128,6 +148,23 @@ precision), not survival time. Reconnects reuse the authenticated identity.
|
||||
|
||||
## Reliable ingestion and aggregation
|
||||
|
||||
The default receiver also returns public map aggregates from `GET /api/summary`
|
||||
(and `GET /api/mission-events`). `terrain` filters the actual world resource;
|
||||
`serverId` adds the installation's record for that map. Summaries expose counts
|
||||
only; detailed player and mission records still require a read/admin token.
|
||||
|
||||
After durably accepting a victory or defeat, the Worker returns a `community`
|
||||
summary containing matching map/server/mission/event/outcome identifiers plus
|
||||
`server` and `allServers` records. Each record has completed rounds, wins,
|
||||
failures, win rate and partial-round coverage. The mission validates these
|
||||
identifiers and counts before displaying the current map's record on its result
|
||||
screen. Retries never increase the totals twice. Startup failures, interrupted
|
||||
sessions and empty restarts are excluded from win/failure totals. If a summary
|
||||
cannot be delivered before the next mission starts, the screen reports it as
|
||||
unavailable and keeps local statistics visible.
|
||||
Once a confirmed record is displayed, failures uploading older queued events
|
||||
cannot replace it with the unavailable message.
|
||||
|
||||
Return any **2xx only after durable storage**, including for an already accepted
|
||||
event. Other responses/timeouts retry the identical event with the same event ID.
|
||||
The receiver must enforce uniqueness on `(serverId, eventId)` and aggregate by
|
||||
@@ -154,9 +191,11 @@ Useful queries from terminal mission snapshots:
|
||||
- Milestone times: phase-event elapsed time differences for prison, radio and
|
||||
extraction. Treat missing phases, dropped events and partial runs as unknown.
|
||||
|
||||
Validate schema and limits and authenticate operator-issued tokens if statistics
|
||||
must be trusted. The bundled addon contains no shared secret. These are reports
|
||||
from community-hosted missions, not independent anti-cheat attestations.
|
||||
The Worker validates schema and limits and binds each server to its private
|
||||
credential after the first accepted event. Automatic enrollment proves continuity
|
||||
of a claimed installation, not its operator's identity or gameplay honesty.
|
||||
Operator-created server records are labelled separately. These are reports from
|
||||
community-hosted missions, not independent anti-cheat attestations.
|
||||
|
||||
## Buffering, disabling and limits
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Local development only. Replace this value before running wrangler dev.
|
||||
ADMIN_TOKEN=replace-with-a-random-secret-at-least-32-characters-long
|
||||
# Optional independent read-only credential; admin credentials can also read.
|
||||
# READ_TOKEN=another-random-secret-at-least-32-characters-long
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
.dev.vars*
|
||||
.env*
|
||||
!.dev.vars.example
|
||||
@@ -0,0 +1,252 @@
|
||||
# Co10 Escape telemetry receiver
|
||||
|
||||
Cloudflare Worker and D1 REST API for the existing [mission telemetry protocol](../../docs/telemetry.md).
|
||||
The game sends one JSON event per POST to `/api/mission-events`. Any community
|
||||
server running the updated mission enrolls automatically on its first valid
|
||||
event. Operators do not need an account, a manually issued token, or a profile
|
||||
override. Mission → Statistics remains the administrator opt-out.
|
||||
|
||||
The Worker name is `co10escape-telemetry`; the bundled mod endpoint is
|
||||
`https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events`.
|
||||
Automatic enrollment requires a new Workshop release after 0.0.125 and the
|
||||
updated Worker below. These source files alone do not change either deployment.
|
||||
|
||||
## Local verification
|
||||
|
||||
Requires Node.js 22 or newer and npm. From this directory:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npm test
|
||||
npm run check
|
||||
cp .dev.vars.example .dev.vars
|
||||
# Replace the example ADMIN_TOKEN with a random value (32–256 characters).
|
||||
npm run migrate:local
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The tests run the Worker in Miniflare/workerd with real local D1 SQL. They cover
|
||||
authentication, duplicate/concurrent retries, payload conflicts, out-of-order
|
||||
snapshots, final-result preservation, actual chunked HTTP requests, limits,
|
||||
legacy counters, storage rollback, server revocation, credential rotation,
|
||||
automatic enrollment races, ingestion rate limits, public-summary privacy and
|
||||
per-map win/failure totals across servers.
|
||||
They use disposable databases and synthetic identities. `npm run check` bundles
|
||||
the deployable Worker without publishing it. Miniflare is pinned to the version
|
||||
bundled with Wrangler so development and test runtimes match.
|
||||
|
||||
## Deploy
|
||||
|
||||
The existing account already has D1 database `erri-telemetry`, bound as `DB` in
|
||||
`wrangler.jsonc`. The renamed Worker reuses this database and its history. From
|
||||
this directory, with Wrangler already logged in:
|
||||
|
||||
```sh
|
||||
npm run migrate:remote
|
||||
npm run deploy
|
||||
npx wrangler secret put ADMIN_TOKEN
|
||||
```
|
||||
|
||||
The migration adds the automatic/managed registration label. Deploying the new
|
||||
name creates `co10escape-telemetry`; secrets on `erri-telemetry` do not transfer.
|
||||
Enter the privately saved admin credential at the prompt for the new Worker.
|
||||
The old Worker is not deleted by these commands. After the new endpoint is
|
||||
verified, the old Worker can be retired separately.
|
||||
|
||||
For a fresh Cloudflare account, run `npx wrangler login`, then
|
||||
`npx wrangler d1 create co10escape-telemetry`. Update the database ID and name in
|
||||
`wrangler.jsonc` and run the migration/deploy commands above. Choose unused rate
|
||||
limiter namespace IDs if 1001–1003 already serve another application in the account.
|
||||
An independent read-only credential is optional:
|
||||
|
||||
```sh
|
||||
npx wrangler secret put READ_TOKEN
|
||||
```
|
||||
|
||||
Use independently generated random secrets of 32–256 characters. Enter them at
|
||||
Wrangler's prompt; do not put them in Git or in the addon. Before ADMIN_TOKEN is
|
||||
set, administration and detailed reads fail closed. Public aggregate summaries
|
||||
remain available. Public ingestion enrolls servers
|
||||
independently of this admin credential. Wrangler prints the Worker URL, for example
|
||||
`https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev`. `/health` is a public liveness
|
||||
check, not a database readiness test. The service does not enable CORS; a future
|
||||
browser dashboard should use a backend to keep API credentials private.
|
||||
|
||||
Deployment uses Cloudflare's documented [D1 migrations](https://developers.cloudflare.com/d1/reference/migrations/),
|
||||
[Worker secrets](https://developers.cloudflare.com/workers/configuration/secrets/),
|
||||
and [transactional D1 batches](https://developers.cloudflare.com/d1/worker-api/d1-database/#batch).
|
||||
|
||||
## Automatic community-server enrollment
|
||||
|
||||
The updated addon saves a random server ID in its telemetry state and generates
|
||||
an independent secret from two random UUIDs. It verifies the secret on disk in
|
||||
`$profile:EscapistsTelemetryCredentials.json` before sending any request. No
|
||||
receiver admin secret is included in the mod. The first valid event atomically
|
||||
creates the server record and stores the event. Only the credential hash is stored
|
||||
by D1. Following events and retries require that same installation credential.
|
||||
|
||||
Automatic records are labelled `registration: "automatic"`; operator-created
|
||||
records are labelled `"managed"`. Neither label is an anti-cheat attestation.
|
||||
An automatic claim cannot replace an existing server's credential or reactivate
|
||||
a revoked server. Concurrent claims with different credentials yield one owner.
|
||||
Preserve the profile's state and credentials together when moving a server.
|
||||
Corrupted credential storage suspends delivery; it is never silently reset.
|
||||
|
||||
The Worker bounds request bodies and uses three Cloudflare rate limiters:
|
||||
300 requests/minute per source, 10 new enrollment attempts/minute per source,
|
||||
and 30 valid requests/minute per server credential. Source keys are hashes of
|
||||
Cloudflare's connecting-IP header, used only in ephemeral limiter state. Limits
|
||||
are approximate and local to each Cloudflare location, not a global storage
|
||||
quota. Shared hosting addresses share source quotas; a 429 makes the game retry.
|
||||
See [Cloudflare rate limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/).
|
||||
|
||||
## Optional managed server credentials
|
||||
|
||||
Each game server has its own `serverId` in
|
||||
`$profile:EscapistsTelemetryState.json`, inside `state.serverId`. Register this
|
||||
existing ID; do not replace the state file, mission IDs or queued events.
|
||||
|
||||
The normal automatic path needs no operator action. The administrative API also
|
||||
supports pre-registering an existing profile ID with a manually issued token.
|
||||
For an already enrolled server, use token rotation instead of registering again.
|
||||
|
||||
Make an authenticated request (substitute the ID read from the profile):
|
||||
|
||||
```http
|
||||
POST /api/servers
|
||||
Authorization: Bearer ADMIN_TOKEN
|
||||
Content-Type: application/json
|
||||
|
||||
{"serverId":"11111111-1111-4111-8111-111111111111","name":"My Escape server"}
|
||||
```
|
||||
|
||||
The 201 response contains `serverId`, `endpoint`, and a new `token`. The raw token
|
||||
is returned only on registration or rotation; D1 stores its SHA-256 hash. Save the
|
||||
token directly in the server's private profile configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://co10escape-telemetry.YOUR-SUBDOMAIN.workers.dev/api/mission-events",
|
||||
"serverName": "My Escape server",
|
||||
"token": "TOKEN_FROM_REGISTRATION"
|
||||
}
|
||||
```
|
||||
|
||||
Restrict access to that file. Restart the mission to load it. The in-game
|
||||
Statistics page should report delivered events. Confirm the mission with
|
||||
`GET /api/missions?serverId=SERVER_ID`. The game token is sent in the JSON envelope
|
||||
as required by the existing native HTTP sender; it cannot read data, register
|
||||
servers or submit events for another server ID.
|
||||
|
||||
`POST /api/servers/SERVER_ID/token` rotates a credential and reactivates a revoked
|
||||
server. Update the private profile with the returned token and restart the
|
||||
mission. `DELETE /api/servers/SERVER_ID` revokes ingestion without deleting history.
|
||||
Never distribute the administrator or read credential to game servers.
|
||||
|
||||
## Map records and the results screen
|
||||
|
||||
Opening `/`, `/api/summary`, or `/api/mission-events` in a browser returns public
|
||||
JSON with collected win/failure totals. No token is required for these summaries.
|
||||
Use `?terrain=URL_ENCODED_WORLD_RESOURCE` to restrict totals to one map, and add
|
||||
`&serverId=SERVER_UUID` to include that installation's record for the same map.
|
||||
Without a terrain filter, the response includes overall totals and up to 100 maps
|
||||
with separate win/failure counts. The response contains counts and map resources,
|
||||
not player/server names, account IDs, raw events or credentials. Full records
|
||||
remain protected by admin/read credentials.
|
||||
|
||||
Each summary has `schemaVersion: 1`, `terrain`, `allServers` and optional `server`.
|
||||
Each record contains `completed`, `wins`, `losses`, `partialMissions`, and
|
||||
`winRate` (percentage; null when there are no completed rounds). Map identity is
|
||||
the actual loaded world resource, compared case-insensitively. Different worlds
|
||||
are kept separate even when their generic scenario title is the same.
|
||||
|
||||
The acknowledgement for an accepted victory/defeat event also includes a
|
||||
`community` summary for that event's map, server, mission and event ID. It is
|
||||
computed after storage and includes the just-finished round exactly once.
|
||||
An equivalent retry returns updated totals without counting the round again.
|
||||
A conflicting second terminal snapshot cannot claim inclusion. A failure to
|
||||
read totals does not undo a successful event acknowledgement.
|
||||
|
||||
The updated mission prioritizes its final result, validates the response against
|
||||
the current map/server/mission/event/outcome, and replicates a short summary to
|
||||
the win/failure screen. The screen refreshes if totals arrive after it opens.
|
||||
It shows map-specific wins, failures and rounded win rates for this server and
|
||||
all reporting servers. Local round statistics remain available when reporting is
|
||||
disabled, storage/network fails, the receiver lacks summary support, or the
|
||||
mission ends before delivery completes. Community totals then show unavailable;
|
||||
they are not invented as zero. This feature requires the updated Workshop build.
|
||||
|
||||
## API
|
||||
|
||||
All responses are JSON with `Cache-Control: no-store`. Administrative endpoints
|
||||
require `Authorization: Bearer ADMIN_TOKEN`. Read endpoints accept ADMIN_TOKEN or
|
||||
the optional READ_TOKEN. Public summaries and `/health` do not require a token;
|
||||
ingestion uses each installation's own credential.
|
||||
|
||||
| Method and path | Purpose |
|
||||
| --- | --- |
|
||||
| `GET /health` | Public liveness check |
|
||||
| `GET /`, `GET /api/summary`, `GET /api/mission-events` | Public map win/failure summaries; optional `terrain` and `serverId` filters |
|
||||
| `POST /api/mission-events` | Ingest `{ "token": "...", "event": {...} }`; first valid event enrolls an unknown server |
|
||||
| `POST /api/servers` | Register `{serverId, name}` and return a new token |
|
||||
| `GET /api/servers` | List server labels, IDs, registration origin, creation times and active state; never token hashes |
|
||||
| `POST /api/servers/:serverId/token` | Rotate token and reactivate server |
|
||||
| `DELETE /api/servers/:serverId` | Revoke ingestion; retain history |
|
||||
| `GET /api/missions` | Mission summaries, newest-created first |
|
||||
| `GET /api/missions/:serverId/:missionId` | Authoritative selected snapshot with players, mods, settings and result |
|
||||
| `GET /api/missions/:serverId/:missionId/events` | Raw event history, newest-received first |
|
||||
| `GET /api/stats` | Distinct mission counts grouped by outcome, with partial-mission counts |
|
||||
| `GET /api/players` | Authenticated participants' attempts, personal escapes and combat totals |
|
||||
|
||||
`/api/missions`, `/api/stats` and `/api/players` accept an optional `serverId` filter.
|
||||
`/api/missions` also accepts `outcome` (`victory`, `defeat`, `preparation_failed`,
|
||||
`restarted_empty`, `interrupted`, or `unfinished`). Mission, server and event lists
|
||||
accept `limit` (1–100, default 50) and `before` from the preceding `nextBefore`.
|
||||
Player lists sort by stable account ID and use `after` from `nextAfter`, plus
|
||||
`limit`. A non-null cursor means another page may exist; the last page can be empty.
|
||||
Read results use integer 0/1 for SQLite-derived flags; raw events retain booleans.
|
||||
|
||||
Ingestion returns 201 after a new durable event, or 200 with `duplicate: true`
|
||||
after an equivalent retry. Victory/defeat acknowledgements include current
|
||||
map records when available. An already used server/event key with different
|
||||
content receives 409. Invalid payloads receive 400, unauthorized requests 401,
|
||||
oversized bodies 413, unsupported media types 415, rate limits 429, and storage failures 503.
|
||||
The current game client retries every non-2xx, including permanent errors; fix
|
||||
invalid credentials/schema or inspect the queue rather than treating 4xx as
|
||||
successful delivery. Request bodies are bounded while streaming, including when
|
||||
there is no Content-Length. Event JSON is limited to 65,536 UTF-16 code units;
|
||||
the HTTP envelope permits 264 KiB for UTF-8 expansion and the credential.
|
||||
|
||||
## Storage and counting rules
|
||||
|
||||
`events` retains validated event objects, keyed uniquely by `(serverId, eventId)`.
|
||||
Envelope tokens, request headers and IP addresses are not stored by this code.
|
||||
An SQL trigger updates `missions` in the same transaction. D1 must commit both
|
||||
before the Worker returns 2xx. A failed projection rolls back the event too.
|
||||
|
||||
For each `(serverId, missionId)`, the first accepted terminal snapshot wins.
|
||||
Subsequent terminal events remain in the history but cannot change its result or
|
||||
count again. Before completion, snapshots advance by elapsed mission time, then
|
||||
event wall-clock time; late starts/restarts cannot clear a terminal result.
|
||||
Reported `previousOutcome` is retained as evidence in events, never converted
|
||||
into an invented result for a missing mission. Unknown endings stay unfinished.
|
||||
|
||||
Player totals include authenticated, participating players from victory/defeat
|
||||
snapshots only. Spectators and temporary connection IDs are excluded. A mission
|
||||
victory does not imply a personal escape. Startup failures, empty restarts and
|
||||
interrupted sessions do not count as gameplay wins/losses or player attempts.
|
||||
An interrupted mission can still have raw combat data in its detail snapshot.
|
||||
|
||||
Legacy missing counters remain null, with `missionsWithKills`,
|
||||
`missionsWithFriendlyKills`, `missionsWithDeaths`, `missionsWithDowned` and
|
||||
`missionsWithRecoveries` showing each total's coverage. Partial and truncated
|
||||
mission counts remain visible; missing players cannot be reconstructed. Results
|
||||
are community reports bound to installation credentials, not independent anti-cheat attestations.
|
||||
|
||||
Raw events and final snapshots are retained indefinitely by default. Monitor D1
|
||||
storage as usage grows. Deleting raw events also deletes their deduplication keys;
|
||||
do not prune them while a server may still retry old queued events. A retention
|
||||
policy should preserve deduplication tombstones if raw payloads are removed.
|
||||
Keep read credentials private: mission details contain player names and account
|
||||
IDs. Worker request logging is disabled in the supplied configuration. Cloudflare
|
||||
platform logging/settings are managed separately in the account.
|
||||
@@ -0,0 +1,87 @@
|
||||
CREATE TABLE servers (
|
||||
server_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id TEXT NOT NULL REFERENCES servers(server_id),
|
||||
event_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
payload_hash TEXT NOT NULL,
|
||||
payload TEXT NOT NULL CHECK (json_valid(payload)),
|
||||
UNIQUE (server_id, event_id)
|
||||
);
|
||||
CREATE INDEX events_mission ON events(server_id, mission_id, id);
|
||||
|
||||
CREATE TABLE missions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id TEXT NOT NULL REFERENCES servers(server_id),
|
||||
mission_id TEXT NOT NULL,
|
||||
event_id TEXT NOT NULL,
|
||||
terminal INTEGER NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
elapsed_seconds INTEGER NOT NULL,
|
||||
occurred_at INTEGER NOT NULL,
|
||||
received_at INTEGER NOT NULL,
|
||||
partial INTEGER NOT NULL,
|
||||
peak_players INTEGER NOT NULL,
|
||||
dropped_events INTEGER NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
UNIQUE (server_id, mission_id)
|
||||
);
|
||||
CREATE INDEX missions_server_page ON missions(server_id, id DESC);
|
||||
CREATE INDEX missions_outcome ON missions(outcome, server_id);
|
||||
|
||||
-- An event and its mission projection commit together. Duplicate inserts never
|
||||
-- fire this trigger. First terminal snapshot wins; late starts/heartbeats cannot
|
||||
-- reopen a mission. Elapsed mission time takes precedence over wall-clock skew.
|
||||
CREATE TRIGGER project_mission AFTER INSERT ON events BEGIN
|
||||
INSERT INTO missions (
|
||||
server_id, mission_id, event_id, terminal, outcome, elapsed_seconds,
|
||||
occurred_at, received_at, partial, peak_players, dropped_events, payload
|
||||
) VALUES (
|
||||
NEW.server_id, NEW.mission_id, NEW.event_id,
|
||||
json_extract(NEW.payload, '$.type') = 'mission_finished',
|
||||
json_extract(NEW.payload, '$.outcome'),
|
||||
json_extract(NEW.payload, '$.elapsedSeconds'),
|
||||
json_extract(NEW.payload, '$.occurredAt'), NEW.received_at,
|
||||
json_extract(NEW.payload, '$.partial'), json_extract(NEW.payload, '$.peakPlayers'),
|
||||
json_extract(NEW.payload, '$.droppedEvents'), NEW.payload
|
||||
) ON CONFLICT(server_id, mission_id) DO UPDATE SET
|
||||
event_id = excluded.event_id, terminal = excluded.terminal,
|
||||
outcome = excluded.outcome, elapsed_seconds = excluded.elapsed_seconds,
|
||||
occurred_at = excluded.occurred_at, received_at = excluded.received_at,
|
||||
partial = MAX(missions.partial, excluded.partial),
|
||||
peak_players = MAX(missions.peak_players, excluded.peak_players),
|
||||
dropped_events = MAX(missions.dropped_events, excluded.dropped_events),
|
||||
payload = excluded.payload
|
||||
WHERE missions.terminal = 0 AND (
|
||||
excluded.terminal = 1 OR excluded.elapsed_seconds > missions.elapsed_seconds OR
|
||||
(excluded.elapsed_seconds = missions.elapsed_seconds AND excluded.occurred_at >= missions.occurred_at)
|
||||
);
|
||||
END;
|
||||
|
||||
-- Final snapshots alone feed rankings: spectator and temporary connection IDs
|
||||
-- never become global player records. Missing legacy counters remain NULL.
|
||||
CREATE VIEW final_players AS
|
||||
SELECT m.server_id, m.mission_id, m.outcome, m.elapsed_seconds,
|
||||
m.partial, json_extract(m.payload, '$.playersTruncated') AS players_truncated,
|
||||
json_extract(p.value, '$.id') AS player_id,
|
||||
json_extract(p.value, '$.name') AS name,
|
||||
json_extract(p.value, '$.escaped') AS escaped,
|
||||
json_extract(p.value, '$.connected') AS connected,
|
||||
json_extract(p.value, '$.connectedSeconds') AS connected_seconds,
|
||||
json_extract(p.value, '$.kills') AS kills,
|
||||
json_extract(p.value, '$.friendlyKills') AS friendly_kills,
|
||||
json_extract(p.value, '$.deaths') AS deaths,
|
||||
json_extract(p.value, '$.downed') AS downed,
|
||||
json_extract(p.value, '$.recoveries') AS recoveries
|
||||
FROM missions m, json_each(m.payload, '$.players') p
|
||||
WHERE m.terminal = 1 AND m.outcome IN ('victory', 'defeat')
|
||||
AND json_extract(p.value, '$.authenticated') = 1
|
||||
AND json_extract(p.value, '$.participated') = 1;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Preserve existing operator-managed credentials and label public enrollment.
|
||||
ALTER TABLE servers ADD COLUMN registration TEXT NOT NULL DEFAULT 'managed'
|
||||
CHECK (registration IN ('managed', 'automatic'));
|
||||
+1596
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "co10escape-telemetry",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test test/*.test.js",
|
||||
"dev": "wrangler dev",
|
||||
"check": "wrangler deploy --dry-run --outdir dist",
|
||||
"deploy": "wrangler deploy",
|
||||
"migrate:local": "wrangler d1 migrations apply DB --local",
|
||||
"migrate:remote": "wrangler d1 migrations apply DB --remote"
|
||||
},
|
||||
"devDependencies": {
|
||||
"miniflare": "5.20261023.3-beta",
|
||||
"wrangler": "4.156.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import {HttpError, requireValue, object, string, uuid, outcomes, validateEvent, canonical, readJson} from './validation.js';
|
||||
|
||||
const json = (value, status = 200) => new Response(JSON.stringify(value), {
|
||||
status, headers: {'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff'},
|
||||
});
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
async function hash(value) {
|
||||
const bytes = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
|
||||
return [...new Uint8Array(bytes)].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
function equal(a, b) {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||||
return diff === 0;
|
||||
}
|
||||
function newToken() {
|
||||
return [...crypto.getRandomValues(new Uint8Array(32))].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
async function authorize(request, env, readOnly = false) {
|
||||
if (!string(env.ADMIN_TOKEN, 256, 32)) throw new HttpError(503, 'Service credentials are not configured');
|
||||
const token = request.headers.get('authorization')?.match(/^Bearer (\S+)$/)?.[1];
|
||||
if (!string(token, 256, 32)) throw new HttpError(401, 'Unauthorized');
|
||||
const digest = await hash(token);
|
||||
const admin = equal(digest, await hash(env.ADMIN_TOKEN));
|
||||
const reader = readOnly && string(env.READ_TOKEN, 256, 32) && equal(digest, await hash(env.READ_TOKEN));
|
||||
if (!admin && !reader) throw new HttpError(401, 'Unauthorized');
|
||||
}
|
||||
function method(request, expected) {
|
||||
if (request.method !== expected) throw new HttpError(405, `Use ${expected}`);
|
||||
}
|
||||
function id(value) {
|
||||
requireValue(uuid(value), 'Invalid UUID'); return value.toLowerCase();
|
||||
}
|
||||
function paging(url) {
|
||||
const limit = url.searchParams.get('limit') ?? '50';
|
||||
const before = url.searchParams.get('before') ?? String(Number.MAX_SAFE_INTEGER);
|
||||
requireValue(/^\d+$/.test(limit) && +limit >= 1 && +limit <= 100, 'limit must be 1–100');
|
||||
requireValue(/^\d+$/.test(before) && Number.isSafeInteger(+before) && +before > 0, 'Invalid before cursor');
|
||||
return {limit: +limit, before: +before};
|
||||
}
|
||||
function serverFilter(url) {
|
||||
const value = url.searchParams.get('serverId'); return value === null ? null : id(value);
|
||||
}
|
||||
async function rateLimit(binding, key) {
|
||||
if (!binding) throw new HttpError(503, 'Ingestion rate limits are not configured');
|
||||
if (!(await binding.limit({key})).success) throw new HttpError(429, 'Too many requests; retry later');
|
||||
}
|
||||
|
||||
// Count terminal gameplay results once per mission, scoped to the actual loaded
|
||||
// terrain resource. Startup failures, interrupted sessions and empty restarts
|
||||
// never become map losses. No identities or arbitrary player text leave this API.
|
||||
async function mapRecords(db, terrain, serverId = null) {
|
||||
const scope = terrain === null ? '' : " AND lower(json_extract(payload, '$.terrain')) = lower(?)";
|
||||
const terrainValues = terrain === null ? [] : [terrain];
|
||||
const select = `SELECT COUNT(*) AS completed,
|
||||
COALESCE(SUM(outcome = 'victory'), 0) AS wins,
|
||||
COALESCE(SUM(outcome = 'defeat'), 0) AS losses,
|
||||
COALESCE(SUM(partial), 0) AS partialMissions
|
||||
FROM missions WHERE terminal = 1 AND outcome IN ('victory', 'defeat')${scope}`;
|
||||
const statements = [db.prepare(select).bind(...terrainValues)];
|
||||
if (serverId) statements.push(db.prepare(`${select} AND server_id = ?`).bind(...terrainValues, serverId));
|
||||
const results = await db.batch(statements);
|
||||
const record = (row) => ({...row, winRate: row.completed ? Math.round(1000 * row.wins / row.completed) / 10 : null});
|
||||
return {schemaVersion: 1, terrain, allServers: record(results[0].results[0]),
|
||||
server: serverId ? record(results[1].results[0]) : null};
|
||||
}
|
||||
|
||||
async function publicSummary(request, env, url) {
|
||||
method(request, 'GET');
|
||||
await rateLimit(env.REQUEST_LIMITER, 'summary:' + await hash(request.headers.get('cf-connecting-ip') ?? 'local'));
|
||||
const terrain = url.searchParams.get('terrain');
|
||||
requireValue(terrain === null || string(terrain, 1024, 1), 'Invalid terrain');
|
||||
const summary = await mapRecords(env.DB, terrain, serverFilter(url));
|
||||
if (terrain === null) {
|
||||
// A bounded alphabetical map list makes the browser response useful without
|
||||
// knowing a resource ID. The terrain filter retrieves any map individually.
|
||||
const rows = await env.DB.prepare(`SELECT lower(json_extract(payload, '$.terrain')) AS terrain,
|
||||
COUNT(*) AS completed, SUM(outcome = 'victory') AS wins, SUM(outcome = 'defeat') AS losses
|
||||
FROM missions WHERE terminal = 1 AND outcome IN ('victory', 'defeat')
|
||||
GROUP BY lower(json_extract(payload, '$.terrain')) ORDER BY terrain LIMIT 101`).all();
|
||||
summary.maps = rows.results.slice(0, 100);
|
||||
summary.mapsTruncated = rows.results.length > 100;
|
||||
}
|
||||
return json(summary);
|
||||
}
|
||||
|
||||
async function ingest(request, env) {
|
||||
method(request, 'POST');
|
||||
// Cloudflare supplies this header. Hash it for ephemeral rate-limit keys;
|
||||
// neither the original IP nor its hash is persisted in the telemetry database.
|
||||
const source = await hash(request.headers.get('cf-connecting-ip') ?? 'local');
|
||||
await rateLimit(env.REQUEST_LIMITER, source);
|
||||
const envelope = await readJson(request);
|
||||
requireValue(object(envelope) && Object.keys(envelope).every(k => ['token', 'event'].includes(k)), 'Invalid envelope');
|
||||
if (!string(envelope.token, 256, 32)) throw new HttpError(401, 'Unauthorized');
|
||||
requireValue(object(envelope.event), 'Invalid event');
|
||||
const serverId = id(envelope.event.serverId);
|
||||
const server = await env.DB.prepare('SELECT token_hash, active FROM servers WHERE server_id = ?').bind(serverId).first();
|
||||
// Always hash before comparison; never persist/log the incoming token.
|
||||
const digest = await hash(envelope.token);
|
||||
if (server && (!equal(digest, server.token_hash) || !server.active)) throw new HttpError(401, 'Unauthorized');
|
||||
const event = validateEvent(envelope.event);
|
||||
const payload = canonical(event);
|
||||
// Native queue bounds strings, so permit UTF-8 expansion at the HTTP boundary.
|
||||
if (payload.length > 65536) throw new HttpError(413, 'Event too large');
|
||||
if (!server) await rateLimit(env.ENROLLMENT_LIMITER, source);
|
||||
// Include the credential hash so guesses cannot exhaust another server's quota.
|
||||
await rateLimit(env.SERVER_LIMITER, `${serverId}:${digest}`);
|
||||
const payloadHash = await hash(payload);
|
||||
const results = await env.DB.batch([
|
||||
env.DB.prepare(`INSERT INTO servers(server_id, name, token_hash, created_at, registration)
|
||||
VALUES (?, ?, ?, ?, 'automatic') ON CONFLICT(server_id) DO NOTHING`)
|
||||
.bind(serverId, event.serverName, digest, now()),
|
||||
env.DB.prepare(`INSERT INTO events(server_id, event_id, mission_id, received_at, payload_hash, payload)
|
||||
SELECT ?, ?, ?, ?, ?, ? FROM servers WHERE server_id = ? AND token_hash = ? AND active = 1
|
||||
ON CONFLICT(server_id, event_id) DO NOTHING RETURNING event_id`)
|
||||
.bind(serverId, event.eventId, event.missionId, now(), payloadHash, payload, serverId, digest),
|
||||
env.DB.prepare('SELECT payload_hash FROM events WHERE server_id = ? AND event_id = ?').bind(serverId, event.eventId),
|
||||
env.DB.prepare('SELECT token_hash, active FROM servers WHERE server_id = ?').bind(serverId),
|
||||
]);
|
||||
// Resolve concurrent first claims and revocations inside the same transaction.
|
||||
const owner = results[3].results[0];
|
||||
if (!owner?.active || !equal(owner.token_hash, digest)) throw new HttpError(401, 'Unauthorized');
|
||||
if (results[2].results[0]?.payload_hash !== payloadHash) throw new HttpError(409, 'eventId already exists with different content');
|
||||
const duplicate = results[1].results.length === 0;
|
||||
const reply = {accepted: true, duplicate, eventId: event.eventId};
|
||||
if (event.type === 'mission_finished' && ['victory', 'defeat'].includes(event.outcome)) {
|
||||
// Stats are a post-commit convenience. Failure to read them must not turn a
|
||||
// durably accepted event into a false storage failure or cause double credit.
|
||||
try {
|
||||
const selected = await env.DB.prepare('SELECT event_id FROM missions WHERE server_id = ? AND mission_id = ?')
|
||||
.bind(serverId, event.missionId).first();
|
||||
if (selected?.event_id === event.eventId) {
|
||||
reply.community = {...await mapRecords(env.DB, event.terrain, serverId), serverId,
|
||||
missionId: event.missionId, eventId: event.eventId, outcome: event.outcome};
|
||||
}
|
||||
} catch { /* Acknowledgement remains valid; the game shows unavailable totals. */ }
|
||||
}
|
||||
return json(reply, duplicate ? 200 : 201);
|
||||
}
|
||||
|
||||
async function servers(request, env, url) {
|
||||
await authorize(request, env);
|
||||
if (request.method === 'GET') {
|
||||
const {limit, before} = paging(url);
|
||||
const result = await env.DB.prepare(`SELECT rowid AS cursor, server_id AS serverId, name, active, registration, created_at AS createdAt
|
||||
FROM servers WHERE rowid < ? ORDER BY rowid DESC LIMIT ?`).bind(before, limit).all();
|
||||
return json({servers: result.results, nextBefore: result.results.length === limit ? result.results.at(-1).cursor : null});
|
||||
}
|
||||
method(request, 'POST');
|
||||
const body = await readJson(request, 4096);
|
||||
requireValue(object(body) && Object.keys(body).every(k => ['serverId', 'name'].includes(k)), 'Invalid server');
|
||||
const serverId = id(body.serverId);
|
||||
requireValue(string(body.name, 120, 1), 'name must be 1–120 characters');
|
||||
const token = newToken();
|
||||
const created = await env.DB.prepare(`INSERT INTO servers(server_id, name, token_hash, created_at)
|
||||
VALUES (?, ?, ?, ?) ON CONFLICT(server_id) DO NOTHING RETURNING server_id`)
|
||||
.bind(serverId, body.name, await hash(token), now()).first();
|
||||
if (!created) throw new HttpError(409, 'Server already exists; rotate its token instead');
|
||||
return json({serverId, token, endpoint: `${url.origin}/api/mission-events`}, 201);
|
||||
}
|
||||
|
||||
async function serverCredential(request, env, serverId, rotate) {
|
||||
await authorize(request, env);
|
||||
method(request, rotate ? 'POST' : 'DELETE');
|
||||
const token = rotate ? newToken() : null;
|
||||
const statement = rotate
|
||||
? env.DB.prepare('UPDATE servers SET token_hash = ?, active = 1 WHERE server_id = ? RETURNING server_id').bind(await hash(token), serverId)
|
||||
: env.DB.prepare('UPDATE servers SET active = 0 WHERE server_id = ? RETURNING server_id').bind(serverId);
|
||||
if (!await statement.first()) throw new HttpError(404, 'Server not found');
|
||||
return json(rotate ? {serverId, token} : {serverId, active: false});
|
||||
}
|
||||
|
||||
async function missions(request, env, url) {
|
||||
method(request, 'GET'); await authorize(request, env, true);
|
||||
const {limit, before} = paging(url), serverId = serverFilter(url);
|
||||
const outcome = url.searchParams.get('outcome');
|
||||
requireValue(outcome === null || [...outcomes, 'unfinished'].includes(outcome), 'Invalid outcome filter');
|
||||
const conditions = ['id < ?'], values = [before];
|
||||
if (serverId) { conditions.push('server_id = ?'); values.push(serverId); }
|
||||
if (outcome) { conditions.push('outcome = ?'); values.push(outcome === 'unfinished' ? '' : outcome); }
|
||||
const rows = await env.DB.prepare(`SELECT id AS cursor, server_id AS serverId, mission_id AS missionId,
|
||||
CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome,
|
||||
elapsed_seconds AS elapsedSeconds, occurred_at AS occurredAt, received_at AS receivedAt,
|
||||
partial, peak_players AS peakPlayers, dropped_events AS droppedEvents,
|
||||
json_extract(payload, '$.terrain') AS terrain, json_extract(payload, '$.playersTruncated') AS playersTruncated
|
||||
FROM missions WHERE ${conditions.join(' AND ')} ORDER BY id DESC LIMIT ?`).bind(...values, limit).all();
|
||||
return json({missions: rows.results, nextBefore: rows.results.length === limit ? rows.results.at(-1).cursor : null});
|
||||
}
|
||||
|
||||
async function missionDetails(request, env, serverId, missionId, events, url) {
|
||||
method(request, 'GET'); await authorize(request, env, true);
|
||||
if (events) {
|
||||
const {limit, before} = paging(url);
|
||||
const rows = await env.DB.prepare(`SELECT id, received_at, payload FROM events
|
||||
WHERE server_id = ? AND mission_id = ? AND id < ? ORDER BY id DESC LIMIT ?`)
|
||||
.bind(serverId, missionId, before, limit).all();
|
||||
return json({events: rows.results.map(row => ({cursor: row.id, receivedAt: row.received_at, event: JSON.parse(row.payload)})),
|
||||
nextBefore: rows.results.length === limit ? rows.results.at(-1).id : null});
|
||||
}
|
||||
const row = await env.DB.prepare('SELECT received_at, payload FROM missions WHERE server_id = ? AND mission_id = ?')
|
||||
.bind(serverId, missionId).first();
|
||||
if (!row) throw new HttpError(404, 'Mission not found');
|
||||
return json({receivedAt: row.received_at, event: JSON.parse(row.payload)});
|
||||
}
|
||||
|
||||
async function stats(request, env, url) {
|
||||
method(request, 'GET'); await authorize(request, env, true);
|
||||
const serverId = serverFilter(url);
|
||||
const result = await env.DB.prepare(`SELECT CASE WHEN terminal = 1 THEN outcome ELSE 'unfinished' END AS outcome,
|
||||
COUNT(*) AS missions, SUM(partial) AS partialMissions FROM missions
|
||||
${serverId ? 'WHERE server_id = ?' : ''} GROUP BY terminal, outcome`)
|
||||
.bind(...(serverId ? [serverId] : [])).all();
|
||||
return json({serverId, outcomes: result.results});
|
||||
}
|
||||
|
||||
async function players(request, env, url) {
|
||||
method(request, 'GET'); await authorize(request, env, true);
|
||||
const serverId = serverFilter(url), {limit} = paging(url);
|
||||
const after = url.searchParams.get('after');
|
||||
if (after !== null) id(after);
|
||||
const conditions = [], values = [];
|
||||
if (serverId) { conditions.push('server_id = ?'); values.push(serverId); }
|
||||
if (after) { conditions.push('player_id > ?'); values.push(after.toLowerCase()); }
|
||||
const rows = await env.DB.prepare(`SELECT player_id AS playerId, COUNT(*) AS attempts,
|
||||
SUM(escaped) AS escapes, SUM(1 - escaped) AS didNotEscape,
|
||||
SUM(partial) AS partialMissions, SUM(players_truncated) AS truncatedMissions,
|
||||
MIN(CASE WHEN escaped = 1 THEN elapsed_seconds END) AS bestEscapeSeconds,
|
||||
SUM(kills) AS kills, COUNT(kills) AS missionsWithKills,
|
||||
SUM(friendly_kills) AS friendlyKills, COUNT(friendly_kills) AS missionsWithFriendlyKills,
|
||||
SUM(deaths) AS deaths, COUNT(deaths) AS missionsWithDeaths,
|
||||
SUM(downed) AS downed, COUNT(downed) AS missionsWithDowned,
|
||||
SUM(recoveries) AS recoveries, COUNT(recoveries) AS missionsWithRecoveries
|
||||
FROM final_players ${conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''}
|
||||
GROUP BY player_id ORDER BY player_id LIMIT ?`).bind(...values, limit).all();
|
||||
return json({players: rows.results, nextAfter: rows.results.length === limit ? rows.results.at(-1).playerId : null});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
try {
|
||||
const url = new URL(request.url), path = url.pathname;
|
||||
if (path === '/health') { method(request, 'GET'); return json({ok: true, service: 'co10escape-telemetry'}); }
|
||||
if (path === '/' || path === '/api/summary' || (path === '/api/mission-events' && request.method === 'GET')) {
|
||||
return await publicSummary(request, env, url);
|
||||
}
|
||||
if (path === '/api/mission-events') return await ingest(request, env);
|
||||
if (path === '/api/servers') return await servers(request, env, url);
|
||||
const credential = path.match(/^\/api\/servers\/([^/]+)(\/token)?$/);
|
||||
if (credential) return await serverCredential(request, env, id(credential[1]), !!credential[2]);
|
||||
if (path === '/api/missions') return await missions(request, env, url);
|
||||
const mission = path.match(/^\/api\/missions\/([^/]+)\/([^/]+)(\/events)?$/);
|
||||
if (mission) return await missionDetails(request, env, id(mission[1]), id(mission[2]), !!mission[3], url);
|
||||
if (path === '/api/stats') return await stats(request, env, url);
|
||||
if (path === '/api/players') return await players(request, env, url);
|
||||
throw new HttpError(404, 'Not found');
|
||||
} catch (error) {
|
||||
// Database failures must never acknowledge an event. Do not log request
|
||||
// bodies, player identities, SQL parameters or credentials on failure.
|
||||
if (error instanceof HttpError) return json({error: error.message}, error.status);
|
||||
return json({error: 'Storage temporarily unavailable'}, 503);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
export class HttpError extends Error {
|
||||
constructor(status, message) { super(message); this.status = status; }
|
||||
}
|
||||
export function requireValue(ok, message) {
|
||||
if (!ok) throw new HttpError(400, message);
|
||||
}
|
||||
export function object(value) {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
export function string(value, max, min = 0) {
|
||||
return typeof value === 'string' && value.length >= min && value.length <= max;
|
||||
}
|
||||
export function integer(value, max = 2147483647) {
|
||||
return Number.isInteger(value) && value >= 0 && value <= max;
|
||||
}
|
||||
export function uuid(value) {
|
||||
return typeof value === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)
|
||||
&& value !== '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
function keys(value, allowed) {
|
||||
requireValue(object(value) && Object.keys(value).every(k => allowed.includes(k)), 'Unexpected object fields');
|
||||
}
|
||||
const types = ['mission_started', 'mission_restarted', 'phase_changed', 'heartbeat', 'telemetry_resumed', 'mission_finished'];
|
||||
export const outcomes = ['victory', 'defeat', 'preparation_failed', 'restarted_empty', 'interrupted'];
|
||||
const counters = ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries'];
|
||||
|
||||
export function validateEvent(event) {
|
||||
keys(event, ['schemaVersion', 'eventId', 'missionId', 'serverId', 'serverName', 'type',
|
||||
'occurredAt', 'elapsedSeconds', 'partial', 'phase', 'outcome', 'previousMissionId',
|
||||
'previousOutcome', 'terrain', 'scenario', 'gameVersion', 'mods', 'modsTruncated',
|
||||
'playersTruncated', 'connectedPlayers', 'peakPlayers', 'players', 'reason',
|
||||
'preparationError', 'settings', 'droppedEvents']);
|
||||
requireValue(event.schemaVersion === 1, 'Unsupported schemaVersion');
|
||||
for (const key of ['serverId', 'missionId', 'eventId']) {
|
||||
requireValue(uuid(event[key]), `Invalid ${key}`);
|
||||
event[key] = event[key].toLowerCase();
|
||||
}
|
||||
requireValue(types.includes(event.type), 'Invalid event type');
|
||||
requireValue(event.type === 'mission_finished' ? outcomes.includes(event.outcome) : event.outcome === '', 'Invalid event outcome');
|
||||
requireValue(integer(event.phase, 6), 'Invalid phase');
|
||||
requireValue(event.outcome !== 'victory' || event.phase === 5, 'Victory requires phase 5');
|
||||
requireValue(event.outcome !== 'defeat' || event.phase === 6, 'Defeat requires phase 6');
|
||||
requireValue(event.previousMissionId === '' || uuid(event.previousMissionId), 'Invalid previousMissionId');
|
||||
event.previousMissionId = event.previousMissionId.toLowerCase();
|
||||
requireValue(['', 'unfinished', ...outcomes].includes(event.previousOutcome), 'Invalid previousOutcome');
|
||||
for (const [key, limit] of Object.entries({serverName: 120, terrain: 1024, scenario: 512,
|
||||
gameVersion: 128, reason: 4096, preparationError: 4096})) {
|
||||
requireValue(string(event[key], limit), `Invalid ${key}`);
|
||||
}
|
||||
for (const key of ['partial', 'modsTruncated', 'playersTruncated']) {
|
||||
requireValue(typeof event[key] === 'boolean', `Invalid ${key}`);
|
||||
}
|
||||
for (const key of ['elapsedSeconds', 'connectedPlayers', 'peakPlayers', 'droppedEvents']) {
|
||||
requireValue(integer(event[key]), `Invalid ${key}`);
|
||||
}
|
||||
requireValue(integer(event.occurredAt, 253402300799), 'Invalid occurredAt');
|
||||
requireValue(event.peakPlayers >= event.connectedPlayers, 'peakPlayers is below connectedPlayers');
|
||||
requireValue(Array.isArray(event.players) && event.players.length <= 128, 'Invalid players');
|
||||
const ids = new Set();
|
||||
for (const p of event.players) {
|
||||
keys(p, ['id', 'name', 'authenticated', 'connected', 'participated', 'escaped', 'connectedSeconds', ...counters]);
|
||||
requireValue(string(p.id, 128, 1) && string(p.name, 128), 'Invalid player identity');
|
||||
for (const key of ['authenticated', 'connected', 'participated', 'escaped']) {
|
||||
requireValue(typeof p[key] === 'boolean', `Invalid player ${key}`);
|
||||
}
|
||||
requireValue(!p.authenticated || uuid(p.id), 'Authenticated player ID must be a UUID');
|
||||
if (p.authenticated) p.id = p.id.toLowerCase();
|
||||
requireValue(!ids.has(p.id), 'Duplicate player identity'); ids.add(p.id);
|
||||
requireValue(!p.escaped || (p.participated && event.outcome === 'victory'), 'Invalid escaped player');
|
||||
requireValue(integer(p.connectedSeconds), 'Invalid connectedSeconds');
|
||||
for (const key of counters) {
|
||||
requireValue(p[key] === undefined || integer(p[key]), `Invalid player ${key}`);
|
||||
}
|
||||
}
|
||||
requireValue(Array.isArray(event.mods) && event.mods.length <= 128, 'Invalid mods');
|
||||
for (const mod of event.mods) {
|
||||
keys(mod, ['id', 'name', 'version']);
|
||||
requireValue(string(mod.id, 128, 1) && string(mod.name, 512) && string(mod.version, 128), 'Invalid mod');
|
||||
}
|
||||
requireValue(object(event.settings) && Object.keys(event.settings).length <= 512, 'Invalid settings');
|
||||
for (const [key, value] of Object.entries(event.settings)) {
|
||||
requireValue(/^[a-zA-Z][a-zA-Z0-9_]{0,127}$/.test(key) && Number.isInteger(value)
|
||||
&& value >= -2147483648 && value <= 2147483647, 'Invalid setting');
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
// Sort object keys for idempotency even if a proxy reformats JSON. Array order
|
||||
// remains meaningful. Only the event is persisted; the envelope token is not.
|
||||
export function canonical(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
||||
if (object(value)) return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(',')}}`;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
export async function readJson(request, limit = 270336) {
|
||||
if (request.headers.get('content-type')?.split(';')[0].trim().toLowerCase() !== 'application/json') {
|
||||
throw new HttpError(415, 'Content-Type must be application/json');
|
||||
}
|
||||
if (Number(request.headers.get('content-length')) > limit) throw new HttpError(413, 'Request too large');
|
||||
if (!request.body) throw new HttpError(400, 'JSON body required');
|
||||
const reader = request.body.getReader();
|
||||
const chunks = []; let size = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (size > limit) { await reader.cancel(); throw new HttpError(413, 'Request too large'); }
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally { reader.releaseLock(); }
|
||||
const bytes = new Uint8Array(size); let offset = 0;
|
||||
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
||||
try { return JSON.parse(new TextDecoder('utf-8', {fatal: true}).decode(bytes)); }
|
||||
catch { throw new HttpError(400, 'Invalid JSON'); }
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import {after, before, test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFile, readdir} from 'node:fs/promises';
|
||||
import {request as httpRequest} from 'node:http';
|
||||
import {Miniflare, convertV4MiniflareOptions} from 'miniflare';
|
||||
import worker from '../src/index.js';
|
||||
|
||||
const ADMIN = 'test-admin-credential-0123456789abcdef';
|
||||
const READ = 'test-read-only-credential-0123456789abcdef';
|
||||
const serverId = '11111111-1111-4111-8111-111111111111';
|
||||
const otherServer = '22222222-2222-4222-8222-222222222222';
|
||||
const playerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
|
||||
let mf, db, token, otherToken;
|
||||
const auth = {authorization: `Bearer ${ADMIN}`};
|
||||
const readAuth = {authorization: `Bearer ${READ}`};
|
||||
function event(overrides = {}) {
|
||||
return {
|
||||
schemaVersion: 1, eventId: crypto.randomUUID(), missionId: crypto.randomUUID(),
|
||||
serverId, serverName: 'Test server', type: 'mission_finished', occurredAt: 1793360000,
|
||||
elapsedSeconds: 120, partial: false, phase: 5, outcome: 'victory', previousMissionId: '',
|
||||
previousOutcome: '', terrain: '{1234567890ABCDEF}Worlds/Test.ent', scenario: 'Escape',
|
||||
gameVersion: '1.6', mods: [{id: '2528466DE220D91C', name: 'Co10 Escape Reintroduced', version: '0.0.125'}],
|
||||
modsTruncated: false, playersTruncated: false, connectedPlayers: 1, peakPlayers: 1,
|
||||
players: [{id: playerId, name: 'Player', authenticated: true, connected: true,
|
||||
participated: true, escaped: true, connectedSeconds: 120, kills: 2, friendlyKills: 0,
|
||||
deaths: 0, downed: 1, recoveries: 1}],
|
||||
reason: 'Escape complete', preparationError: '', settings: {telemetryEnabled: 1, random: -1},
|
||||
droppedEvents: 0, ...overrides,
|
||||
};
|
||||
}
|
||||
async function call(path, options = {}) {
|
||||
return mf.dispatchFetch(`https://telemetry.example${path}`, options);
|
||||
}
|
||||
async function post(path, body, headers = {}) {
|
||||
return call(path, {method: 'POST', headers: {'content-type': 'application/json', ...headers}, body: JSON.stringify(body)});
|
||||
}
|
||||
const ingest = (e, credential = token) => post('/api/mission-events', {token: credential, event: e});
|
||||
async function detail(e) {
|
||||
const response = await call(`/api/missions/${e.serverId}/${e.missionId}`, {headers: readAuth});
|
||||
assert.equal(response.status, 200); return (await response.json()).event;
|
||||
}
|
||||
before(async () => {
|
||||
mf = new Miniflare(convertV4MiniflareOptions({host: '127.0.0.1', port: 0, workers: [{name: 'telemetry',
|
||||
modules: ['index.js', 'validation.js'].map(name => ({type: 'ESModule', path: new URL(`../src/${name}`, import.meta.url).pathname})),
|
||||
compatibilityDate: '2026-10-23', d1Databases: ['DB'],
|
||||
ratelimits: {
|
||||
REQUEST_LIMITER: {namespace_id: '1001', simple: {limit: 300, period: 60}},
|
||||
ENROLLMENT_LIMITER: {namespace_id: '1002', simple: {limit: 10, period: 60}},
|
||||
SERVER_LIMITER: {namespace_id: '1003', simple: {limit: 30, period: 60}},
|
||||
},
|
||||
bindings: {ADMIN_TOKEN: ADMIN, READ_TOKEN: READ}}]}));
|
||||
db = await mf.getD1Database('DB');
|
||||
const migrations = new URL('../migrations/', import.meta.url);
|
||||
for (const name of (await readdir(migrations)).filter(n => n.endsWith('.sql')).sort()) {
|
||||
const migration = await readFile(new URL(name, migrations), 'utf8');
|
||||
// Blank-line-separated statements preserve the trigger's internal semicolons.
|
||||
for (const sql of migration.split(/;\n\n/)) if (sql.trim()) await db.prepare(sql).run();
|
||||
}
|
||||
for (const sid of [serverId, otherServer]) {
|
||||
const response = await post('/api/servers', {serverId: sid, name: 'Test'}, auth);
|
||||
assert.equal(response.status, 201, await response.clone().text());
|
||||
const body = await response.json();
|
||||
assert.equal(body.token.length, 64);
|
||||
if (sid === serverId) token = body.token; else otherToken = body.token;
|
||||
}
|
||||
});
|
||||
after(async () => { await mf?.dispose(); });
|
||||
|
||||
test('credentials bind writes to a registered server; read-only credentials cannot administer', async () => {
|
||||
assert.equal((await ingest(event(), 'x'.repeat(64))).status, 401);
|
||||
assert.equal((await ingest(event({serverId: otherServer}))).status, 401);
|
||||
assert.equal((await call('/api/stats')).status, 401);
|
||||
assert.equal((await post('/api/servers', {serverId: crypto.randomUUID(), name: 'No'}, readAuth)).status, 401);
|
||||
assert.equal((await call('/api/stats', {headers: readAuth})).status, 200);
|
||||
const stored = await db.prepare('SELECT token_hash FROM servers WHERE server_id = ?').bind(serverId).first();
|
||||
assert.notEqual(stored.token_hash, token);
|
||||
assert.equal((await post('/api/servers', {serverId, name: 'Duplicate'}, auth)).status, 409);
|
||||
});
|
||||
|
||||
test('concurrent retries store once; reordered JSON is equivalent; conflicting content is rejected', async () => {
|
||||
const e = event();
|
||||
const responses = await Promise.all(Array.from({length: 6}, () => ingest(e)));
|
||||
assert.deepEqual(responses.map(r => r.status).sort(), [200, 200, 200, 200, 200, 201]);
|
||||
const reordered = Object.fromEntries(Object.entries(e).reverse());
|
||||
assert.equal((await ingest(reordered)).status, 200);
|
||||
assert.equal((await ingest({...e, elapsedSeconds: 999})).status, 409);
|
||||
const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE event_id = ?').bind(e.eventId).first();
|
||||
assert.equal(count.n, 1);
|
||||
assert.equal((await detail(e)).elapsedSeconds, 120);
|
||||
const row = await db.prepare('SELECT payload FROM events WHERE event_id = ?').bind(e.eventId).first();
|
||||
assert.ok(!row.payload.includes(token)); assert.ok(!row.payload.includes('"token"'));
|
||||
assert.equal((await ingest({...e, serverId: otherServer}, otherToken)).status, 201);
|
||||
});
|
||||
|
||||
test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => {
|
||||
const e = event({players: []});
|
||||
const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100};
|
||||
assert.equal((await ingest(heartbeat)).status, 201);
|
||||
assert.equal((await ingest({...heartbeat, eventId: crypto.randomUUID(), elapsedSeconds: 50, occurredAt: e.occurredAt + 500})).status, 201);
|
||||
assert.equal((await detail(e)).elapsedSeconds, 100);
|
||||
assert.equal((await ingest({...e, eventId: crypto.randomUUID(), occurredAt: e.occurredAt - 100})).status, 201);
|
||||
for (const type of ['mission_started', 'mission_restarted', 'heartbeat', 'telemetry_resumed']) {
|
||||
assert.equal((await ingest({...heartbeat, type, eventId: crypto.randomUUID(), elapsedSeconds: 1000})).status, 201);
|
||||
}
|
||||
assert.equal((await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6})).status, 201);
|
||||
assert.equal((await detail(e)).outcome, 'victory');
|
||||
const rows = await db.prepare('SELECT COUNT(*) AS n FROM missions WHERE server_id = ? AND mission_id = ?').bind(serverId, e.missionId).first();
|
||||
assert.equal(rows.n, 1);
|
||||
const log = await call(`/api/missions/${serverId}/${e.missionId}/events?limit=2`, {headers: readAuth});
|
||||
const data = await log.json(); assert.equal(data.events.length, 2); assert.ok(data.nextBefore);
|
||||
});
|
||||
|
||||
test('rankings use actual authenticated participant escapes and preserve absent legacy counters', async () => {
|
||||
const isolated = crypto.randomUUID();
|
||||
const credentials = await (await post('/api/servers', {serverId: isolated, name: 'Rankings'}, auth)).json();
|
||||
const e = event({serverId: isolated});
|
||||
const legacy = {...e.players[0], escaped: false};
|
||||
for (const key of ['kills', 'friendlyKills', 'deaths', 'downed', 'recoveries']) delete legacy[key];
|
||||
const spectator = {...legacy, id: crypto.randomUUID(), participated: false};
|
||||
const anonymous = {...legacy, id: `${e.missionId}:connection:1`, authenticated: false};
|
||||
assert.equal((await ingest({...e, players: [legacy, spectator, anonymous]}, credentials.token)).status, 201);
|
||||
for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) {
|
||||
assert.equal((await ingest(event({serverId: isolated, outcome, phase: 0, players: [legacy]}), credentials.token)).status, 201);
|
||||
}
|
||||
const url = `/api/players?serverId=${isolated}`;
|
||||
let result = await (await call(url, {headers: readAuth})).json();
|
||||
assert.equal(result.players.length, 1);
|
||||
assert.equal(result.players[0].attempts, 1); assert.equal(result.players[0].escapes, 0);
|
||||
assert.equal(result.players[0].kills, null); assert.equal(result.players[0].missionsWithKills, 0);
|
||||
assert.equal((await ingest(event({serverId: isolated}), credentials.token)).status, 201);
|
||||
result = await (await call(url, {headers: readAuth})).json();
|
||||
assert.equal(result.players[0].attempts, 2); assert.equal(result.players[0].escapes, 1);
|
||||
assert.equal(result.players[0].kills, 2); assert.equal(result.players[0].missionsWithKills, 1);
|
||||
const totals = await (await call(`/api/stats?serverId=${isolated}`, {headers: readAuth})).json();
|
||||
assert.deepEqual(Object.fromEntries(totals.outcomes.map(r => [r.outcome, r.missions])),
|
||||
{victory: 2, preparation_failed: 1, restarted_empty: 1, interrupted: 1});
|
||||
});
|
||||
|
||||
test('real chunked HTTP request is accepted without Content-Length', async () => {
|
||||
const origin = await mf.ready;
|
||||
const body = JSON.stringify({token, event: event()});
|
||||
const response = await new Promise((resolve, reject) => {
|
||||
const req = httpRequest(new URL('/api/mission-events', origin), {method: 'POST', headers: {'content-type': 'application/json'}}, res => {
|
||||
let text = ''; res.on('data', chunk => { text += chunk; });
|
||||
res.on('end', () => resolve({status: res.statusCode, body: JSON.parse(text)}));
|
||||
});
|
||||
req.on('error', reject); req.write(body.slice(0, 80)); req.end(body.slice(80));
|
||||
});
|
||||
assert.equal(response.status, 201); assert.equal(response.body.accepted, true);
|
||||
});
|
||||
|
||||
test('schema validation and streaming limits reject malformed input without durable rows', async () => {
|
||||
const e = event();
|
||||
for (const malformed of [
|
||||
{...e, schemaVersion: 2}, {...e, phase: 4}, {...e, players: [...e.players, ...e.players]},
|
||||
{...e, players: [{...e.players[0], kills: -1}]}, {...e, extraSecret: token},
|
||||
{...e, players: [{...e.players[0], participated: false}]},
|
||||
{...e, players: Array.from({length: 129}, () => e.players[0])},
|
||||
]) assert.equal((await ingest(malformed)).status, 400);
|
||||
assert.equal((await call('/api/mission-events', {method: 'POST', body: '{}'})).status, 415);
|
||||
assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: '{'})).status, 400);
|
||||
const stream = new ReadableStream({start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(' '.repeat(270337))); controller.close();
|
||||
}});
|
||||
assert.equal((await call('/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'}, body: stream, duplex: 'half'})).status, 413);
|
||||
assert.equal((await call('/api/missions?limit=1000000', {headers: readAuth})).status, 400);
|
||||
assert.equal((await call('/api/missions?serverId=invalid', {headers: readAuth})).status, 400);
|
||||
});
|
||||
|
||||
test('failed projection rolls back the event and returns non-2xx; retry succeeds after repair', async () => {
|
||||
const e = event();
|
||||
await db.prepare(`CREATE TRIGGER simulate_storage_failure BEFORE INSERT ON missions
|
||||
WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run();
|
||||
assert.equal((await ingest(e)).status, 503);
|
||||
assert.equal(await db.prepare('SELECT event_id FROM events WHERE event_id = ?').bind(e.eventId).first(), null);
|
||||
await db.prepare('DROP TRIGGER simulate_storage_failure').run();
|
||||
assert.equal((await ingest(e)).status, 201);
|
||||
const result = await worker.fetch(new Request('https://example/api/stats', {headers: auth}), {});
|
||||
assert.equal(result.status, 503); // Missing admin configuration fails closed.
|
||||
});
|
||||
|
||||
test('server revocation retains history; rotation invalidates old credential and restores ingestion', async () => {
|
||||
const sid = crypto.randomUUID();
|
||||
const registered = await (await post('/api/servers', {serverId: sid, name: 'Rotation'}, auth)).json();
|
||||
const e = event({serverId: sid});
|
||||
assert.equal((await ingest(e, registered.token)).status, 201);
|
||||
assert.equal((await call(`/api/servers/${sid}`, {method: 'DELETE', headers: auth})).status, 200);
|
||||
assert.equal((await ingest(e, registered.token)).status, 401);
|
||||
assert.equal((await detail(e)).outcome, 'victory');
|
||||
const rotated = await (await call(`/api/servers/${sid}/token`, {method: 'POST', headers: auth})).json();
|
||||
assert.equal((await ingest(e, registered.token)).status, 401);
|
||||
assert.equal((await ingest(e, rotated.token)).status, 200);
|
||||
});
|
||||
|
||||
test('unmanaged servers enroll on first valid event and keep their own private write credential', async () => {
|
||||
const e = event({serverId: crypto.randomUUID()}), credential = crypto.randomUUID() + crypto.randomUUID();
|
||||
assert.equal((await ingest({...e, schemaVersion: 99}, credential)).status, 400);
|
||||
assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null);
|
||||
assert.equal((await ingest(e, credential)).status, 201);
|
||||
const row = await db.prepare('SELECT registration, token_hash FROM servers WHERE server_id = ?').bind(e.serverId).first();
|
||||
assert.equal(row.registration, 'automatic'); assert.notEqual(row.token_hash, credential);
|
||||
assert.equal((await ingest(e, credential)).status, 200); // lost acknowledgement / restart
|
||||
assert.equal((await ingest({...e, eventId: crypto.randomUUID()}, 'x'.repeat(64))).status, 401);
|
||||
await call(`/api/servers/${e.serverId}`, {method: 'DELETE', headers: auth});
|
||||
assert.equal((await ingest(e, credential)).status, 401); // no automatic reactivation
|
||||
assert.equal((await call('/api/servers', {headers: {authorization: `Bearer ${credential}`}})).status, 401);
|
||||
});
|
||||
|
||||
test('concurrent automatic claims never let two credentials own the same server', async () => {
|
||||
const e = event({serverId: crypto.randomUUID()}), a = 'a'.repeat(64), b = 'b'.repeat(64);
|
||||
const replies = await Promise.all([ingest(e, a), ingest({...e, eventId: crypto.randomUUID()}, b)]);
|
||||
assert.deepEqual(replies.map(r => r.status).sort(), [201, 401]);
|
||||
const count = await db.prepare('SELECT COUNT(*) AS n FROM events WHERE server_id = ?').bind(e.serverId).first();
|
||||
assert.equal(count.n, 1);
|
||||
});
|
||||
|
||||
test('automatic claim and event roll back together when projection fails', async () => {
|
||||
const e = event({serverId: crypto.randomUUID()}), credential = 'r'.repeat(64);
|
||||
await db.prepare(`CREATE TRIGGER fail_new_server BEFORE INSERT ON missions
|
||||
WHEN NEW.mission_id = '${e.missionId}' BEGIN SELECT RAISE(ABORT, 'test failure'); END`).run();
|
||||
assert.equal((await ingest(e, credential)).status, 503);
|
||||
assert.equal(await db.prepare('SELECT server_id FROM servers WHERE server_id = ?').bind(e.serverId).first(), null);
|
||||
await db.prepare('DROP TRIGGER fail_new_server').run();
|
||||
assert.equal((await ingest(e, credential)).status, 201);
|
||||
});
|
||||
|
||||
test('public rate limits reject excess traffic without storing more events', async () => {
|
||||
const e = event({serverId: crypto.randomUUID()}), credential = 'q'.repeat(64);
|
||||
assert.equal((await ingest(e, credential)).status, 201);
|
||||
let rejected = false;
|
||||
for (let i = 0; i < 35; i++) {
|
||||
if ((await ingest(e, credential)).status === 429) { rejected = true; break; }
|
||||
}
|
||||
assert.ok(rejected, 'per-server binding must enforce its quota');
|
||||
const input = () => new Request('https://example/api/mission-events', {method: 'POST', headers: {'content-type': 'application/json'},
|
||||
body: JSON.stringify({token: credential, event: event({serverId: crypto.randomUUID()})})});
|
||||
const allowed = {limit: async () => ({success: true})};
|
||||
const denied = {limit: async () => ({success: false})};
|
||||
assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: allowed, ENROLLMENT_LIMITER: denied})).status, 429);
|
||||
assert.equal((await worker.fetch(input(), {DB: db, REQUEST_LIMITER: denied})).status, 429);
|
||||
assert.equal((await worker.fetch(input(), {DB: db})).status, 503);
|
||||
});
|
||||
|
||||
test('public map summary is usable in a browser and empty maps have zero results, not an invented win rate', async () => {
|
||||
const terrain = '{ABCD000000000000}Worlds/NoReports.ent';
|
||||
for (const path of ['/', '/api/summary', '/api/mission-events']) {
|
||||
const response = await call(path + '?terrain=' + encodeURIComponent(terrain));
|
||||
assert.equal(response.status, 200);
|
||||
const data = await response.json();
|
||||
assert.deepEqual(data.allServers, {completed: 0, wins: 0, losses: 0, partialMissions: 0, winRate: null});
|
||||
assert.equal(data.server, null);
|
||||
}
|
||||
assert.equal((await call('/api/players')).status, 401);
|
||||
assert.equal((await call('/api/missions')).status, 401);
|
||||
});
|
||||
|
||||
test('map records count victories and defeats across servers, excluding other maps and non-gameplay endings', async () => {
|
||||
const terrain = '{ABCD123456789012}Worlds/MapRecord.ent';
|
||||
const e = event({terrain, serverId: otherServer});
|
||||
const first = await (await ingest(e, otherToken)).json();
|
||||
assert.equal(first.community.eventId, e.eventId);
|
||||
assert.equal(first.community.terrain, terrain);
|
||||
assert.deepEqual(first.community.allServers, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100});
|
||||
const defeat = event({terrain, serverId, outcome: 'defeat', phase: 6, players: [], partial: true});
|
||||
assert.equal((await ingest(defeat)).status, 201);
|
||||
for (const outcome of ['preparation_failed', 'restarted_empty', 'interrupted']) {
|
||||
assert.equal((await ingest(event({terrain, serverId: otherServer, outcome, phase: 0, players: []}), otherToken)).status, 201);
|
||||
}
|
||||
assert.equal((await ingest(event({terrain: 'Different-map', serverId: otherServer}), otherToken)).status, 201);
|
||||
// Case differences in resource spelling do not split the same loaded map.
|
||||
const params = new URLSearchParams({terrain: terrain.toLowerCase(), serverId: otherServer});
|
||||
const response = await call('/api/summary?' + params);
|
||||
const summary = await response.json();
|
||||
assert.deepEqual(summary.allServers, {completed: 2, wins: 1, losses: 1, partialMissions: 1, winRate: 50});
|
||||
assert.deepEqual(summary.server, {completed: 1, wins: 1, losses: 0, partialMissions: 0, winRate: 100});
|
||||
const retry = await (await ingest(e, otherToken)).json();
|
||||
assert.equal(retry.duplicate, true); assert.equal(retry.community.allServers.completed, 2);
|
||||
// A conflicting second terminal event must not claim its result was included.
|
||||
const later = await (await ingest({...e, eventId: crypto.randomUUID(), outcome: 'defeat', phase: 6, players: []}, otherToken)).json();
|
||||
assert.equal(later.community, undefined);
|
||||
const text = JSON.stringify(summary);
|
||||
for (const sensitive of [playerId, token, otherToken, 'Player', 'Test server', e.missionId]) assert.ok(!text.includes(sensitive));
|
||||
const maps = await (await call('/')).json();
|
||||
const row = maps.maps.find(m => m.terrain === terrain.toLowerCase());
|
||||
assert.deepEqual(row, {terrain: terrain.toLowerCase(), completed: 2, wins: 1, losses: 1});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "node_modules/wrangler/config-schema.json",
|
||||
"name": "co10escape-telemetry",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-10-23",
|
||||
"workers_dev": true,
|
||||
"d1_databases": [{
|
||||
"binding": "DB",
|
||||
// Reuse the database already created in this account.
|
||||
"database_name": "erri-telemetry",
|
||||
"database_id": "70afec97-cb05-4024-ad57-2256c7089dc1",
|
||||
"migrations_dir": "migrations"
|
||||
}],
|
||||
"ratelimits": [
|
||||
{ "name": "REQUEST_LIMITER", "namespace_id": "1001", "simple": { "limit": 300, "period": 60 } },
|
||||
{ "name": "ENROLLMENT_LIMITER", "namespace_id": "1002", "simple": { "limit": 10, "period": 60 } },
|
||||
{ "name": "SERVER_LIMITER", "namespace_id": "1003", "simple": { "limit": 30, "period": 60 } }
|
||||
],
|
||||
"observability": { "enabled": false }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Production map-response validation and delayed delivery, with engine JSON/UI doubled."""
|
||||
from policy_harness import ROOT, body, run
|
||||
|
||||
base = ROOT/'addons/core/Scripts/Game/ERR/00_Core'
|
||||
p = base/'ERR_CommunityStatistics.c'
|
||||
valid = body(p, 'bool Valid(')
|
||||
matches = body(p, 'bool Matches(').replace('server.', 'server->').replace('allServers.', 'allServers->')
|
||||
callbacks = '\n'.join(body(base/'ERR_Telemetry.c', name) for name in ['protected void CommunityUnavailable(', 'protected void UpdateCommunity('])
|
||||
for a,b in [
|
||||
('protected ', ''), ('RestCallback callback','RestCallback* callback'), ('callback.', 'callback->'),
|
||||
('JsonLoadContext sent = new JsonLoadContext()', 'Json sent'), ('JsonLoadContext reply = new JsonLoadContext()', 'Json reply'),
|
||||
('ERR_CommunityStatistics community;', 'Community* community = nullptr;'), ('community.', 'community->'),
|
||||
('m_Mode.ERR_PublishCommunityStatistics(', 'mode.Publish('), ('m_State.serverId','serverId'),
|
||||
('GetGame().GetWorldFile()', 'world'),
|
||||
]: callbacks=callbacks.replace(a,b)
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
struct string:std::string {using std::string::string;using std::string::operator=;bool IsEmpty()const{return empty();}int Length()const{return size();}};
|
||||
struct Record {int wins=2,losses=1,completed=3,partialMissions=0;
|
||||
'''+valid+r'''
|
||||
};
|
||||
struct Community {int schemaVersion=1;string serverId="server",missionId="mission",eventId="final",terrain="map",outcome="victory";Record local,global;Record* server=&local;Record* allServers=&global;
|
||||
'''+matches+r'''
|
||||
string Summary(){return "map record";}
|
||||
} result;
|
||||
struct Json {
|
||||
string input;
|
||||
bool LoadFromString(string v){input=v;return v=="old"||v=="final"||v=="response";}
|
||||
bool ReadValue(string,string& value){value=input;return true;}
|
||||
bool ReadValue(string,Community*& value){value=&result;return true;}
|
||||
};
|
||||
struct RestCallback {string data="response";string GetData(){return data;}};
|
||||
struct Service {
|
||||
bool m_Ended=true,current=true,m_CommunityReady=false;string m_FinalEventId="final",m_FinalOutcome="victory",m_InFlight="final",serverId="server",m_MissionId="mission",world="map";
|
||||
struct Mode {int writes=0;string text;void Publish(string v){writes++;text=v;}} mode;
|
||||
bool Current(){return current;}
|
||||
'''+callbacks+r'''
|
||||
};
|
||||
int main(){
|
||||
assert(result.Matches("server","mission","final","map","victory"));
|
||||
assert(!result.Matches("other","mission","final","map","victory"));
|
||||
assert(!result.Matches("server","old","final","map","victory"));
|
||||
assert(!result.Matches("server","mission","old-event","map","victory"));
|
||||
assert(!result.Matches("server","mission","final","another-map","victory"));
|
||||
assert(!result.Matches("server","mission","final","map","defeat"));
|
||||
result.schemaVersion=2;assert(!result.Matches("server","mission","final","map","victory"));result.schemaVersion=1;
|
||||
result.global.wins=1;result.global.completed=2;assert(!result.Matches("server","mission","final","map","victory"));result.global=Record{};
|
||||
result.local.wins=-1;assert(!result.local.Valid());result.local=Record{};
|
||||
result.local.completed=4;assert(!result.local.Valid());result.local=Record{};
|
||||
result.local.partialMissions=4;assert(!result.local.Valid());result.local=Record{};
|
||||
Service service;RestCallback cb;
|
||||
service.m_InFlight="old";service.UpdateCommunity(&cb);assert(service.mode.writes==0);
|
||||
service.m_InFlight="final";service.current=false;service.UpdateCommunity(&cb);assert(service.mode.writes==0);
|
||||
service.current=true;service.m_Ended=false;service.UpdateCommunity(&cb);assert(service.mode.writes==0);
|
||||
service.m_Ended=true;service.UpdateCommunity(&cb);assert(service.mode.text=="map record");
|
||||
int receivedWrites=service.mode.writes;service.CommunityUnavailable();assert(service.mode.writes==receivedWrites); // an older queued upload failing cannot erase confirmed totals
|
||||
service.m_CommunityReady=false;
|
||||
result.terrain="other-map";service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos);result.terrain="map";
|
||||
service.UpdateCommunity(&cb);assert(service.mode.text=="map record"); // later valid retry refreshes the fallback
|
||||
service.m_CommunityReady=false;
|
||||
cb.data="invalid-json";service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos);
|
||||
cb.data=string(8193,'x');service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos);
|
||||
cb.data="response";service.m_FinalOutcome="interrupted";int before=service.mode.writes;service.UpdateCommunity(&cb);assert(service.mode.writes==before);
|
||||
service.m_FinalOutcome="defeat";result.outcome="defeat";service.UpdateCommunity(&cb);assert(service.mode.text=="map record");
|
||||
service.m_CommunityReady=false;
|
||||
result.local.losses=0;result.local.completed=2;service.UpdateCommunity(&cb);assert(service.mode.text.find("unavailable")!=std::string::npos);
|
||||
}
|
||||
''')
|
||||
mode=(base/'ERR_RoundStatisticsLifecycle.c').read_text()
|
||||
assert '[RplProp(onRplName: "ERR_OnRoundStatisticsReplicated")]\n protected string m_ERRCommunitySummary' in mode
|
||||
publish=body(base/'ERR_RoundStatisticsLifecycle.c','void ERR_PublishCommunityStatistics(')
|
||||
assert '!Replication.IsServer()' in publish and 'Replication.BumpMe(); ERR_OnRoundStatisticsReplicated();' in publish
|
||||
assert 'm_ERRCommunitySummary + "\\n\\n" + m_ERRRoundSummary' in mode
|
||||
sender=(base/'ERR_Telemetry.c').read_text()
|
||||
assert 'm_State.pending.InsertAt(payload, 0);' in sender
|
||||
assert 'GetGame().GetCallqueue().CallLater(Pulse, 1, false)' in sender
|
||||
print('PASS: map/server/mission/event/outcome validation, bounded counters, stale delivery exclusion, current-round refresh and unavailable recovery')
|
||||
@@ -7,6 +7,7 @@ def cpp(v):
|
||||
return v
|
||||
rules='\n'.join(cpp(body(p,x)) for x in ['static bool Acknowledged(', 'static int RetrySeconds(', 'static string Outcome('])
|
||||
methods='\n'.join(cpp(body(p,x)) for x in ['bool Enabled(', 'void Refresh(', 'protected void Purge(', 'void Finish(', 'void PhaseChanged(', 'protected void Delivered(', 'protected void Failed(', 'void Close('])
|
||||
methods=methods.replace('GetGame().GetCallqueue().CallLater(Pulse, 1, false)', 'pulses++')
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <cmath>
|
||||
@@ -34,6 +35,7 @@ struct Service {
|
||||
State state;State* m_State=&state;RestCallback* m_Callback=nullptr;void* m_Context=nullptr;void* m_Mode=nullptr;
|
||||
string m_InFlight;int m_Failures=0,m_Delivered=0,m_NextAttempt=0,samples=0,events=0,removed=0,saves=0;
|
||||
string type,result;
|
||||
int pulses=0;void UpdateCommunity(RestCallback*){}void CommunityUnavailable(){}
|
||||
bool Current(){return current;}
|
||||
void Sample(){samples++;}
|
||||
void Event(string t,string o="",string p="",string prior=""){type=t;result=o;events++;}
|
||||
@@ -86,7 +88,7 @@ assert lifecycle.index('m_ERRTelemetry.Finish(ERR_TelemetryRules.Outcome') < lif
|
||||
assert '#ifdef WORKBENCH' in lifecycle and 'errTelemetryTest' in lifecycle
|
||||
assert 'GetRestApi' not in lifecycle
|
||||
assert 'm_State.pending.Count() >= 64' in s and 'payload.Length() > 65536' in s
|
||||
assert s.index('if (m_Dirty && !Save()) return;') < s.index('m_Context.POST(')
|
||||
assert s.index('if (m_Dirty && !Save())') < s.index('m_Context.POST(')
|
||||
assert 'ReadValue("token"' in s and 'WriteValue("token", m_Config.Token)' in s
|
||||
# Persisted event data must not contain authentication material.
|
||||
event=body(p,'protected void Event(')
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Execute production credential persistence with storage/JSON boundary doubles."""
|
||||
from policy_harness import ROOT, body, run
|
||||
|
||||
p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_TelemetryCredentials.c'
|
||||
methods = '\n'.join(body(p, s) for s in ['static bool Valid(', 'static bool Resolve('])
|
||||
for a, b in [
|
||||
('out string token', 'string& token'),
|
||||
('ref array<ref ERR_TelemetryCredential> entries = {};', 'Entries entries;'),
|
||||
('ERR_TelemetryCredential entry', 'Credential* entry'),
|
||||
('ERR_TelemetryCredential known', 'Credential* known'),
|
||||
('ERR_TelemetryCredential created = new ERR_TelemetryCredential()', 'Credential* created = new Credential()'),
|
||||
('JsonLoadContext loaded = new JsonLoadContext()', 'JsonLoadContext loaded'),
|
||||
('JsonSaveContext saved = new JsonSaveContext()', 'JsonSaveContext saved'),
|
||||
('foreach (Credential* known :', 'for (Credential* known :'),
|
||||
('entry.', 'entry->'), ('known.', 'known->'), ('created.', 'created->'),
|
||||
('FileIO.', 'FileIO::'), ('UUID.', 'UUID::'), ('ERR_ProfileConfig.', 'Profile::'),
|
||||
]:
|
||||
methods = methods.replace(a, b)
|
||||
run(r'''
|
||||
#include <cassert>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
struct string : std::string {
|
||||
using std::string::string; using std::string::operator=;
|
||||
string(std::string s):std::string(s){} bool IsEmpty() const{return empty();} int Length()const{return size();}
|
||||
};
|
||||
struct Credential {string endpoint,serverId,token;};
|
||||
struct Entries:std::vector<Credential*> {
|
||||
int Count(){return size();} void Insert(Credential* v){push_back(v);} bool operator!(){return false;}
|
||||
};
|
||||
std::map<std::string,string> files;
|
||||
std::map<std::string,Entries> documents;
|
||||
bool failSave=false,failCopy=false,failRead=false;int generated=0,serial=0;
|
||||
namespace UUID {bool IsUUID(string s){return s=="server"||s=="other";}
|
||||
string GenV4(){generated++;return string(36, char('a'+generated%20));}}
|
||||
namespace FileIO {
|
||||
bool FileExists(string p){return files.count(p);}
|
||||
bool CopyFile(string a,string b){if(failCopy)return false;files[b]=files[a];return true;}
|
||||
void DeleteFile(string p){files.erase(p);}
|
||||
}
|
||||
namespace Profile {bool Text(string p,string& text){if(failRead||!files.count(p))return false;text=files[p];return true;}}
|
||||
struct JsonLoadContext {
|
||||
string raw;
|
||||
bool LoadFromFile(string p){raw=files[p];return documents.count(raw);}
|
||||
bool ReadValue(string,Entries& e){e=documents[raw];return true;}
|
||||
};
|
||||
struct JsonSaveContext {
|
||||
string raw;
|
||||
void EnableTypeDiscriminator(bool){}
|
||||
void WriteValue(string,Entries e){raw="document-"+std::to_string(++serial);documents[raw]=e;}
|
||||
bool SaveToFile(string p){if(failSave)return false;files[p]=raw;return true;}
|
||||
string SaveToString(){return raw;}
|
||||
};
|
||||
struct Credentials {
|
||||
static inline string PATH="credentials.json";
|
||||
'''+methods+r'''
|
||||
};
|
||||
int main(){
|
||||
string token;
|
||||
assert(Credentials::Resolve("https://one", "server", token));
|
||||
assert(token.Length()==72&&generated==2&&files.count(Credentials::PATH));
|
||||
string first=token;assert(!files.count(Credentials::PATH+".tmp"));
|
||||
token="";assert(Credentials::Resolve("https://one", "server", token));
|
||||
assert(token==first&&generated==2); // mission restart reuses the durable secret
|
||||
assert(Credentials::Resolve("https://two", "server", token)&&token!=first);
|
||||
assert(Credentials::Resolve("https://one", "server", token)&&token==first); // endpoint isolation
|
||||
assert(Credentials::Resolve("https://one", "other", token)&&token!=first); // identity isolation
|
||||
failSave=true;assert(!Credentials::Resolve("https://save-fail", "server", token)&&token.IsEmpty());failSave=false;
|
||||
failCopy=true;assert(!Credentials::Resolve("https://copy-fail", "server", token)&&token.IsEmpty());failCopy=false;
|
||||
failRead=true;assert(!Credentials::Resolve("https://verify-fail", "server", token)&&token.IsEmpty());failRead=false;
|
||||
assert(Credentials::Resolve("https://one", "server", token)&&token==first);
|
||||
auto before=generated;files[Credentials::PATH]="corrupt";
|
||||
assert(!Credentials::Resolve("https://one", "server", token)&&token.IsEmpty()&&generated==before);
|
||||
assert(files[Credentials::PATH]=="corrupt"); // preserve damaged evidence; do not silently rotate
|
||||
files.clear();documents.clear();
|
||||
for(int i=0;i<16;i++)assert(Credentials::Resolve("endpoint-"+std::to_string(i),"server",token));
|
||||
assert(!Credentials::Resolve("endpoint-overflow","server",token)&&token.IsEmpty());
|
||||
}
|
||||
''')
|
||||
sender = p.with_name('ERR_Telemetry.c')
|
||||
pulse = sender.read_text().split('protected void Pulse(', 1)[1].split('protected void Delivered(', 1)[0]
|
||||
assert pulse.index('if (!Enabled()) return;') < pulse.index('ERR_TelemetryCredentials.Resolve')
|
||||
assert pulse.index('if (m_Dirty && !Save())') < pulse.index('ERR_TelemetryCredentials.Resolve') < pulse.index('m_Context.POST(')
|
||||
assert 'if (m_Config.Token.IsEmpty())' in pulse
|
||||
assert 'Token' not in body(sender, 'protected void Event(')
|
||||
assert 'Credentials' not in body(sender, 'protected void Purge(') # opt-out keeps identity, deletes queued personal data
|
||||
assert 'https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events' in (ROOT/'addons/core/Configs/ERR/Telemetry.conf').read_text()
|
||||
print('PASS: durable installation credential, restart/endpoint/identity isolation, write failure, corruption, bounded retention and disabled-send gates')
|
||||
@@ -13,6 +13,9 @@ def check():
|
||||
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_workshop_changelog.py'],check=True)
|
||||
subprocess.run([sys.executable,'-m','unittest','discover','-s',str(ROOT/'tests'),'-p','test_discord_changelog_ci.py'],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_round_statistics.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_telemetry_credentials.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_community_statistics.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_controller_init.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_terrain_chat_retention.py')],check=True)
|
||||
subprocess.run([sys.executable,str(ROOT/'tests/test_empty_server_restart.py')],check=True)
|
||||
|
||||
Reference in New Issue
Block a user