Add community map records and telemetry API; record Workshop 0.0.126
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
@@ -402,3 +402,11 @@ start fresh with the next mission. They work with statistics telemetry disabled
|
||||
and require no web service. At most 128 distinct participants are retained; the
|
||||
report explicitly marks truncation if that limit is reached. No account IDs are
|
||||
included in the displayed or replicated summary.
|
||||
|
||||
After a victory or defeat is accepted by the community statistics receiver, the
|
||||
screen also shows the current map's wins, failures and win rate on this server
|
||||
and across all reporting servers. Totals include this round and update if they
|
||||
arrive while the screen is open. Different maps are counted separately using
|
||||
their loaded world resources. Startup failures, empty restarts and interrupted
|
||||
sessions are excluded. Community totals show unavailable if reporting is disabled
|
||||
or delivery fails; local round statistics still work.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Frozen local Workbench community-statistics fixture; no live server or installed asset writes."""
|
||||
from pathlib import Path
|
||||
import hashlib,json,shutil,subprocess,sys,os,time
|
||||
ROOT=Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0,str(ROOT/'tools'))
|
||||
from source_manifest import addon_manifest
|
||||
from headless_game import assert_prefix_idle
|
||||
name=sys.argv[1]
|
||||
outcome=sys.argv[2]
|
||||
if outcome not in {'victory','defeat'}: raise ValueError('Expected victory or defeat')
|
||||
if not name.isalnum(): raise ValueError('Use an alphanumeric run suffix')
|
||||
source=Path('/tmp/erri-community-statistics-source-'+name)
|
||||
profile=Path('/tmp/erri-community-statistics-run-'+name)
|
||||
compat=ROOT/'.local/playtest-compat-20260920'
|
||||
assert_prefix_idle(compat)
|
||||
source.mkdir();profile.mkdir()
|
||||
for rel in addon_manifest(ROOT):
|
||||
target=source/rel;target.parent.mkdir(parents=True,exist_ok=True);shutil.copy2(ROOT/rel,target)
|
||||
shutil.copytree(ROOT/'tools',source/'tools',ignore=shutil.ignore_patterns('__pycache__'))
|
||||
(source/'docs').mkdir();shutil.copy2(ROOT/'docs/external-addons.json',source/'docs/external-addons.json')
|
||||
fixture=Path(__file__).with_name('native-fixture.c')
|
||||
target=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_ZCommunityStatisticsFixture.c'
|
||||
target.write_text(fixture.read_text().replace('TEST_OUTCOME', outcome))
|
||||
# Opt-in fixture is independent of the historical generic Core smoke.
|
||||
p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_NativeBaseSmoke.c'
|
||||
p.write_text(p.read_text().replace('array<string> independentFixtures = {','array<string> independentFixtures = {"community-statistics", '))
|
||||
p=source/'tools/headless_workbench.py'
|
||||
p.write_text(p.read_text().replace("choices=['coordinated-ai',", "choices=['community-statistics', 'coordinated-ai',").replace("a.smoke not in {'coordinated-ai',", "a.smoke not in {'community-statistics', 'coordinated-ai',"))
|
||||
# The existing fatal-loss diagnostic is guarded by errSmoke; this Core-only
|
||||
# runner uses errBaseSmoke. Adapt only that test-helper flag in the frozen copy.
|
||||
p=source/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerMedical.c'
|
||||
p.write_text(p.read_text().replace('System.GetCLIParam("errSmoke", enabled)', 'System.GetCLIParam("errBaseSmoke", enabled)'))
|
||||
installed=Path.home()/'.local/share/Steam/steamapps/compatdata/1874880/pfx/drive_c/users/steamuser/Documents/My Games/ArmaReforger/addons'
|
||||
(profile/'profile').mkdir()
|
||||
(profile/'profile/EscapistsFactions.json').write_text(json.dumps({'escapists':'ussr','enemy':'us','independent':'fia'})+'\n')
|
||||
(profiles:=profile/'fixture-inputs.json').write_text(json.dumps({'fixture_sha256':hashlib.sha256(fixture.read_bytes()).hexdigest(),'production_sources':addon_manifest(ROOT),'source':str(source),'profile':str(profile)},indent=2)+'\n')
|
||||
command=[sys.executable,str(source/'tools/headless_workbench.py'),'--compat',str(compat),'--profile',str(profile),'--external-addons',str(installed),'--project','core','--smoke','community-statistics','--rounds','1','--timeout','300']
|
||||
with (profile/'runner-output.log').open('w') as output:
|
||||
result=subprocess.Popen(command,stdout=output,stderr=subprocess.STDOUT)
|
||||
captured=False
|
||||
while result.poll() is None:
|
||||
if not captured and any('ERR STATS UI READY' in log.read_text(errors='replace') for log in (profile/'logs').glob('*/console.log')):
|
||||
for proc in Path('/proc').iterdir():
|
||||
if not proc.name.isdigit(): continue
|
||||
try:
|
||||
args=(proc/'cmdline').read_bytes().decode(errors='replace').replace('\\', '/')
|
||||
if 'ArmaReforgerWorkbenchSteamDiag.exe' not in args or str(source/'addons/core/addon.gproj') not in args: continue
|
||||
values=dict(x.split('=',1) for x in (proc/'environ').read_bytes().decode(errors='replace').split('\0') if '=' in x)
|
||||
if 'DISPLAY' not in values: continue
|
||||
env=os.environ.copy()
|
||||
for key in ['DISPLAY','XAUTHORITY']:
|
||||
if key in values: env[key]=values[key]
|
||||
capture=subprocess.run(['import','-display',values['DISPLAY'],'-window','root',str(profile/'results-desktop.png')],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=10)
|
||||
if capture.returncode==0: captured=True;break
|
||||
except (OSError,ValueError,subprocess.TimeoutExpired): pass
|
||||
time.sleep(1)
|
||||
print(json.dumps({'exit':result.returncode,'source':str(source),'profile':str(profile)}))
|
||||
print('\n'.join((profile/'runner-output.log').read_text().splitlines()[-45:]))
|
||||
raise SystemExit(result.returncode)
|
||||
@@ -0,0 +1,66 @@
|
||||
#ifdef WORKBENCH
|
||||
// Injected only into a frozen private fixture copy. No public telemetry requests.
|
||||
modded class ERR_GameMode
|
||||
{
|
||||
protected int m_ERRCommunityTestStage;
|
||||
protected float m_ERRCommunityTestWait;
|
||||
protected ref ERR_CommunityStatistics m_ERRCommunityTestResult;
|
||||
protected override void EOnInit(IEntity owner)
|
||||
{
|
||||
super.EOnInit(owner);
|
||||
if (Replication.IsServer() && !SCR_Global.IsEditMode()) GetGame().GetCallqueue().CallLater(ERR_CommunityTest, 500, true);
|
||||
}
|
||||
protected bool ERR_CommunityCheck(bool ok, string detail)
|
||||
{
|
||||
if (ok) { Print("ERR SMOKE PASS: community " + detail); return true; }
|
||||
Print("ERR SMOKE FAIL: community " + detail, LogLevel.ERROR);
|
||||
GetGame().GetCallqueue().Remove(ERR_CommunityTest); return false;
|
||||
}
|
||||
protected void ERR_CommunityTest()
|
||||
{
|
||||
if (!PreparationError().IsEmpty()) { ERR_CommunityCheck(false, PreparationError()); return; }
|
||||
if (GetWorld().GetWorldTime() < m_ERRCommunityTestWait) return;
|
||||
if (m_ERRCommunityTestStage == 0)
|
||||
{
|
||||
if (!Active()) return;
|
||||
SCR_PlayerController pc = SCR_PlayerController.Cast(GetGame().GetPlayerController());
|
||||
if (!pc || !pc.GetControlledEntity()) return;
|
||||
string account = UUID.GenV4(); string first; string second;
|
||||
if (!ERR_CommunityCheck(ERR_TelemetryCredentials.Resolve("https://fixture.invalid/api/mission-events", account, first), "private credential saved")) return;
|
||||
if (!ERR_CommunityCheck(ERR_TelemetryCredentials.Resolve("https://fixture.invalid/api/mission-events", account, second) && first == second && first.Length() == 72, "native credential serialization preserves installation secret")) return;
|
||||
ERR_CommunityStatistics result = new ERR_CommunityStatistics();
|
||||
result.schemaVersion = 1; result.serverId = account; result.missionId = UUID.GenV4(); result.eventId = UUID.GenV4();
|
||||
result.terrain = GetGame().GetWorldFile(); result.outcome = "TEST_OUTCOME";
|
||||
result.server = new ERR_CommunityMapRecord(); result.server.wins = 2; result.server.losses = 1; result.server.completed = 3; result.server.partialMissions = 0;
|
||||
result.allServers = new ERR_CommunityMapRecord(); result.allServers.wins = 7; result.allServers.losses = 3; result.allServers.completed = 10; result.allServers.partialMissions = 1;
|
||||
JsonSaveContext saved = new JsonSaveContext(); saved.EnableTypeDiscriminator(false); saved.WriteValue("community", result);
|
||||
JsonLoadContext loaded = new JsonLoadContext();
|
||||
if (!ERR_CommunityCheck(loaded.LoadFromString(saved.SaveToString()) && loaded.ReadValue("community", m_ERRCommunityTestResult) && m_ERRCommunityTestResult.Matches(result.serverId, result.missionId, result.eventId, result.terrain, result.outcome), "native JSON roundtrip and current map/result validation")) return;
|
||||
if (!ERR_CommunityCheck(!m_ERRCommunityTestResult.Matches(result.serverId, result.missionId, result.eventId, "another-map", result.outcome), "another map is rejected")) return;
|
||||
ERR_PublishCommunityStatistics("Waiting for community map records...");
|
||||
// Native end-screen fixture: this does not prove an organic escape route.
|
||||
m_Phase = ERR_Phase.EXTRACTION;
|
||||
ERR_Phase terminal = ERR_Phase.DEFEAT;
|
||||
if (result.outcome == "victory") terminal = ERR_Phase.VICTORY;
|
||||
if (!ERR_CommunityCheck(Transition(terminal), "native TEST_OUTCOME screen requested")) return;
|
||||
m_ERRCommunityTestStage = 1; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 2000; return;
|
||||
}
|
||||
if (m_ERRCommunityTestStage == 1)
|
||||
{
|
||||
ERR_PublishCommunityStatistics(m_ERRCommunityTestResult.Summary());
|
||||
m_ERRCommunityTestStage = 2; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 1000; return;
|
||||
}
|
||||
if (m_ERRCommunityTestStage == 2)
|
||||
{
|
||||
MenuBase menu = GetGame().GetMenuManager().GetTopMenu(); if (!menu) return;
|
||||
TextWidget text = TextWidget.Cast(menu.GetRootWidget().FindAnyWidget("RoundStatistics"));
|
||||
if (!ERR_CommunityCheck(text && text.IsVisible() && text.GetText() == ERR_RoundSummary() && text.GetText().Contains("All reporting servers: 7 wins | 3 failures | 70% win rate"), "open TEST_OUTCOME screen refreshes after late community result")) return;
|
||||
Print("ERR STATS UI READY: community TEST_OUTCOME screen");
|
||||
System.MakeScreenshot("$logs:community-TEST_OUTCOME");
|
||||
m_ERRCommunityTestStage = 3; m_ERRCommunityTestWait = GetWorld().GetWorldTime() + 5000; return;
|
||||
}
|
||||
Print("ERR SMOKE COMPLETE: community TEST_OUTCOME native credentials, JSON validation and result screen refresh");
|
||||
GetGame().GetCallqueue().Remove(ERR_CommunityTest);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"date": "2026-10-30",
|
||||
"worker": {"miniflare_tests_passed": 14, "deploy_dry_run": "passed", "production_deployment": "not performed for map-summary changes"},
|
||||
"native": {
|
||||
"victory": {"run": "mapwin02", "assertions_passed": 6, "result": "smoke-completed"},
|
||||
"defeat": {"run": "maploss01", "assertions_passed": 6, "result": "smoke-completed"},
|
||||
"script_or_runtime_failures": 0,
|
||||
"engine_errors_per_run": 13,
|
||||
"engine_errors": ["4 external localization resource GUID/name errors", "6 unknown Parent properties", "SlidingTrackMaterial", "m_HardLandingVelocity", "m_DeathLandingVelocity"],
|
||||
"runner_exit_code": 1,
|
||||
"scope": "Scripted local Workbench transitions and actual end-screen widget text updates. No organic escape, multiplayer replication or live Worker round trip tested. Desktop screenshot capture did not produce UI evidence.",
|
||||
"revision_note": "The subsequent guard preserving accepted records when older queued uploads fail was verified by the production-method policy test; native fixtures preceded that small guard change."
|
||||
},
|
||||
"game_policy_tests": ["test_telemetry.py", "test_telemetry_credentials.py", "test_community_statistics.py", "test_round_statistics.py", "test_failure_result_refresh.py"],
|
||||
"publication": "Not published to Workshop"
|
||||
}
|
||||
+50
-11
@@ -8,15 +8,34 @@ save-before-apply behavior also apply to this setting.
|
||||
|
||||
Telemetry runs only on the mission authority, never independently on joining
|
||||
clients. Workbench runs do not collect unless explicitly launched with the
|
||||
`errTelemetryTest` diagnostic flag. There is no statistics website or production
|
||||
receiver included in this change.
|
||||
`errTelemetryTest` diagnostic flag. A deployable Cloudflare Worker/D1 receiver is
|
||||
provided in [services/telemetry-worker](../services/telemetry-worker/README.md).
|
||||
It includes automatic server enrollment and protected mission/player query APIs.
|
||||
The Worker must be deployed separately. There is no statistics website.
|
||||
|
||||
## Receiving URL and configuration
|
||||
|
||||
The bundled receiving URL belongs in `addons/core/Configs/ERR/Telemetry.conf`.
|
||||
It is currently empty pending the receiver's actual URL. Until configured, no
|
||||
telemetry is collected or sent, even with the switch enabled. The menu reports
|
||||
this condition rather than claiming that statistics are being delivered.
|
||||
The bundled receiving URL in `addons/core/Configs/ERR/Telemetry.conf` is
|
||||
`https://co10escape-telemetry.zacekjakub.workers.dev/api/mission-events`.
|
||||
Any host running the updated mission can report without contacting the receiver
|
||||
administrator or editing its configuration. Mission → Statistics remains the
|
||||
administrator opt-out. This source change requires a new Workshop release;
|
||||
0.0.125 does not contain automatic enrollment.
|
||||
|
||||
Before the first request, the host saves its own randomly generated credential
|
||||
in `$profile:EscapistsTelemetryCredentials.json`. The Worker enrolls an unknown
|
||||
server ID on its first valid event and stores only the credential hash. Later
|
||||
requests must present the same credential. Enrollment and the initial event are
|
||||
committed together. Disabling telemetry prevents enrollment and delivery and
|
||||
discards pending events; the credential remains for continuity when re-enabled.
|
||||
No shared secret or receiver administrator credential is bundled in the mod.
|
||||
|
||||
Keep the credentials file with the profile state when moving a server. Damaged
|
||||
credentials suspend delivery without silently rotating them. The file retains
|
||||
up to 16 server/endpoint pairs, so changing receiving URLs does not send one
|
||||
receiver's secret to another. Explicit operator-issued tokens still override
|
||||
automatic credentials. The default receiver accepts community reports, not
|
||||
independently verified gameplay or identities.
|
||||
|
||||
An operator can override the URL and optionally name/authenticate their instance
|
||||
with `$profile:EscapistsTelemetry.json`:
|
||||
@@ -31,8 +50,9 @@ with `$profile:EscapistsTelemetry.json`:
|
||||
|
||||
Use the full POST endpoint, with its exact trailing-slash convention. HTTPS is
|
||||
required. URL credentials, fragments and query parameters are rejected. The
|
||||
optional token goes in the HTTPS JSON body, never in a URL, menu snapshot, log,
|
||||
Workshop resource, or persisted event queue. Configuration loads when the mission
|
||||
explicit or automatically generated token goes in the HTTPS JSON body, never in
|
||||
a URL, menu snapshot, log, Workshop resource, or persisted event queue.
|
||||
Configuration loads when the mission
|
||||
starts. An empty endpoint overrides the bundled default and prevents sending.
|
||||
|
||||
`serverId` is a generated UUID persisted in
|
||||
@@ -128,6 +148,23 @@ precision), not survival time. Reconnects reuse the authenticated identity.
|
||||
|
||||
## Reliable ingestion and aggregation
|
||||
|
||||
The default receiver also returns public map aggregates from `GET /api/summary`
|
||||
(and `GET /api/mission-events`). `terrain` filters the actual world resource;
|
||||
`serverId` adds the installation's record for that map. Summaries expose counts
|
||||
only; detailed player and mission records still require a read/admin token.
|
||||
|
||||
After durably accepting a victory or defeat, the Worker returns a `community`
|
||||
summary containing matching map/server/mission/event/outcome identifiers plus
|
||||
`server` and `allServers` records. Each record has completed rounds, wins,
|
||||
failures, win rate and partial-round coverage. The mission validates these
|
||||
identifiers and counts before displaying the current map's record on its result
|
||||
screen. Retries never increase the totals twice. Startup failures, interrupted
|
||||
sessions and empty restarts are excluded from win/failure totals. If a summary
|
||||
cannot be delivered before the next mission starts, the screen reports it as
|
||||
unavailable and keeps local statistics visible.
|
||||
Once a confirmed record is displayed, failures uploading older queued events
|
||||
cannot replace it with the unavailable message.
|
||||
|
||||
Return any **2xx only after durable storage**, including for an already accepted
|
||||
event. Other responses/timeouts retry the identical event with the same event ID.
|
||||
The receiver must enforce uniqueness on `(serverId, eventId)` and aggregate by
|
||||
@@ -154,9 +191,11 @@ Useful queries from terminal mission snapshots:
|
||||
- Milestone times: phase-event elapsed time differences for prison, radio and
|
||||
extraction. Treat missing phases, dropped events and partial runs as unknown.
|
||||
|
||||
Validate schema and limits and authenticate operator-issued tokens if statistics
|
||||
must be trusted. The bundled addon contains no shared secret. These are reports
|
||||
from community-hosted missions, not independent anti-cheat attestations.
|
||||
The Worker validates schema and limits and binds each server to its private
|
||||
credential after the first accepted event. Automatic enrollment proves continuity
|
||||
of a claimed installation, not its operator's identity or gameplay honesty.
|
||||
Operator-created server records are labelled separately. These are reports from
|
||||
community-hosted missions, not independent anti-cheat attestations.
|
||||
|
||||
## Buffering, disabling and limits
|
||||
|
||||
|
||||
Reference in New Issue
Block a user