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
+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