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

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