From 3ec882dc4e643e4b3211eb92d8d7230b6e1c9d2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Fri, 30 Oct 2026 17:13:11 +0100 Subject: [PATCH] Fix telemetry delivery and report loaded island names; record Workshop 0.0.130 --- CHANGELOG.md | 7 ++- .../Scripts/Game/ERR/00_Core/ERR_Telemetry.c | 23 ++++++++-- docs/telemetry.md | 16 +++++-- services/telemetry-worker/README.md | 11 +++-- services/telemetry-worker/src/validation.js | 7 ++- services/telemetry-worker/test/worker.test.js | 18 ++++++++ tests/test_community_statistics.py | 3 +- tests/test_telemetry_terrain.py | 46 +++++++++++++++++++ 8 files changed, 114 insertions(+), 17 deletions(-) create mode 100644 tests/test_telemetry_terrain.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d20d2d..a5c2d9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog -Latest published Workshop version: **0.0.129** +Latest published Workshop version: **0.0.130** + +## 2026-10-30 — Community statistics delivery + +- Fixed rejected mission reports so wins, defeats and queued results can update community statistics. +- Game servers now report the loaded island name in statistics instead of the Game Master scenario path. ## 2026-10-30 — Quieter AI traffic diff --git a/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c b/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c index e9253c8..72f8566 100644 --- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c @@ -89,6 +89,7 @@ class ERR_Telemetry protected string m_InFlight; protected string m_FinalEventId; protected string m_FinalOutcome; + protected string m_FinalTerrain; protected bool m_CommunityReady; protected int m_Started; protected int m_LastSample; @@ -288,6 +289,18 @@ class ERR_Telemetry m_State.previousOutcome = outcome; Event("mission_finished", outcome); } + protected string Terrain() + { + // The loaded root scene identifies the island beneath GM/Conflict scenarios. + // Use the same terrain resolver as the mission title, on the reporting server. + string world = GetGame().GetWorldFile(); + if (GetGame().GetWorld() && GetGame().GetWorld().GetNumSubScenes() > 0) + { + string root = GetGame().GetWorld().GetSubSceneName(0); + if (!root.IsEmpty()) world = root; + } + return ERR_TerrainDisplayName.Resolve(world); + } protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "") { if (!Enabled()) return; @@ -299,10 +312,12 @@ class ERR_Telemetry 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); - json.WriteValue("elapsedSeconds", Math.Max(0, (System.GetTickCount() - m_Started) / 1000)); + int elapsedSeconds = Math.Max(0, (System.GetTickCount() - m_Started) / 1000); + json.WriteValue("elapsedSeconds", elapsedSeconds); json.WriteValue("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome); json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome); - json.WriteValue("terrain", GetGame().GetWorldFile()); + string terrain = Terrain(); + json.WriteValue("terrain", terrain); SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader()); string scenario; if (header) scenario = header.m_sName; json.WriteValue("gameVersion", GetGame().GetBuildVersion()); @@ -332,7 +347,7 @@ class ERR_Telemetry } if (type == "mission_finished") { - m_FinalEventId = eventId; m_FinalOutcome = outcome; + m_FinalEventId = eventId; m_FinalOutcome = outcome; m_FinalTerrain = terrain; // 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); @@ -402,7 +417,7 @@ class ERR_Telemetry 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)) + if (callback.GetData().Length() > 8192 || !reply.LoadFromString(callback.GetData()) || !reply.ReadValue("community", community) || !community || !community.Matches(m_State.serverId, m_MissionId, m_FinalEventId, m_FinalTerrain, m_FinalOutcome)) { CommunityUnavailable(); return; } diff --git a/docs/telemetry.md b/docs/telemetry.md index 81cb544..16e21cb 100644 --- a/docs/telemetry.md +++ b/docs/telemetry.md @@ -82,7 +82,7 @@ receiver must accept chunked request bodies (used by the native HTTP client): "outcome": "victory", "previousMissionId": "", "previousOutcome": "", - "terrain": "{RESOURCE_GUID}Worlds/Example.ent", + "terrain": "Everon", "scenario": "Escape", "gameVersion": "engine build version", "mods": [{"id": "2528466DE220D91C", "name": "Co10 Escape Reintroduced", "version": ""}], @@ -109,8 +109,11 @@ not an observed random roll. `phase` is 0 preparing, 1 prison, 2 radio search, 3 radio defence, 4 extraction, 5 victory, 6 defeat. `mods` describes mounted non-vanilla addons; version is the locally active Workshop revision when exposed by the engine, otherwise empty. Never infer the newest Workshop version from an -empty value. Terrain is the actual loaded world resource, including takeover -scenarios, rather than the next-round terrain preference. +empty value. `terrain` is the island name resolved by the reporting game server +from the loaded root scene (for example `Everon`), using the mission title +resolver. It does not use the Game Master scenario filename or the next-round +terrain preference. Reports through 0.0.129 contain world resource paths instead; +existing queued payloads retain their original values and event IDs. ## Event and outcome semantics @@ -149,7 +152,7 @@ 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; +(and `GET /api/mission-events`). `terrain` filters the reported island name (or world resource in older reports); `serverId` adds the installation's record for that map. Summaries expose counts only; detailed player and mission records still require a read/admin token. @@ -197,6 +200,11 @@ 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. +Published clients through 0.0.129 can send fractional `elapsedSeconds`. The +receiver accepts finite, nonnegative durations within the integer range and +rounds down before hashing and storage. This also lets existing saved queues +retry without edits; retries still use the original event IDs and count once. + ## Buffering, disabling and limits Requests have a 15-second timeout, with a 30-second callback watchdog. Retries diff --git a/services/telemetry-worker/README.md b/services/telemetry-worker/README.md index b2a79a9..db8e5f7 100644 --- a/services/telemetry-worker/README.md +++ b/services/telemetry-worker/README.md @@ -147,18 +147,19 @@ Never distribute the administrator or read credential to game servers. 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 +Use `?terrain=URL_ENCODED_ISLAND_NAME` 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, +with separate win/failure counts. The response contains counts and reported island names, 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. +`winRate` (percentage; null when there are no completed rounds). The game server resolves the island name from its loaded root scene, independently +of the scenario title. Names are compared case-insensitively. Older reports +through 0.0.129 retain their world-resource identifiers; query those with the +original resource path. The receiver does not infer or rewrite historical islands. 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 diff --git a/services/telemetry-worker/src/validation.js b/services/telemetry-worker/src/validation.js index 1995987..95533c0 100644 --- a/services/telemetry-worker/src/validation.js +++ b/services/telemetry-worker/src/validation.js @@ -50,7 +50,12 @@ export function validateEvent(event) { for (const key of ['partial', 'modsTruncated', 'playersTruncated']) { requireValue(typeof event[key] === 'boolean', `Invalid ${key}`); } - for (const key of ['elapsedSeconds', 'connectedPlayers', 'peakPlayers', 'droppedEvents']) { + // Published Enfusion clients serialize elapsed time as fractional seconds. + // Normalize before hashing/storage so their durable queues can retry unchanged. + requireValue(typeof event.elapsedSeconds === 'number' && Number.isFinite(event.elapsedSeconds) + && event.elapsedSeconds >= 0 && event.elapsedSeconds <= 2147483647, 'Invalid elapsedSeconds'); + event.elapsedSeconds = Math.floor(event.elapsedSeconds); + for (const key of ['connectedPlayers', 'peakPlayers', 'droppedEvents']) { requireValue(integer(event[key]), `Invalid ${key}`); } requireValue(integer(event.occurredAt, 253402300799), 'Invalid occurredAt'); diff --git a/services/telemetry-worker/test/worker.test.js b/services/telemetry-worker/test/worker.test.js index c193a0b..cb49881 100644 --- a/services/telemetry-worker/test/worker.test.js +++ b/services/telemetry-worker/test/worker.test.js @@ -92,6 +92,24 @@ test('concurrent retries store once; reordered JSON is equivalent; conflicting c assert.equal((await ingest({...e, serverId: otherServer}, otherToken)).status, 201); }); +test('published fractional-duration defeats are accepted, counted and retry idempotently', async () => { + const terrain = '{1234567890ABCDEF}Worlds/FractionalDuration.ent'; + const e = event({terrain, outcome: 'defeat', phase: 6, players: [], elapsedSeconds: 261.593994140625}); + const first = await ingest(e); + assert.equal(first.status, 201, await first.clone().text()); + assert.equal((await first.json()).accepted, true); + assert.equal((await detail(e)).elapsedSeconds, 261); + assert.equal((await ingest(e)).status, 200); + assert.equal((await ingest({...e, elapsedSeconds: 261})).status, 200); + const summary = await (await call(`/api/summary?terrain=${encodeURIComponent(terrain)}`)).json(); + assert.equal(summary.allServers.losses, 1); + assert.equal(summary.allServers.completed, 1); + for (const elapsedSeconds of [-0.1, 2147483647.1, '261.594', null]) { + assert.equal((await ingest(event({elapsedSeconds}))).status, 400); + } + assert.equal((await ingest(event({connectedPlayers: 0.5}))).status, 400); +}); + 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}; diff --git a/tests/test_community_statistics.py b/tests/test_community_statistics.py index 803bebf..df8b63c 100644 --- a/tests/test_community_statistics.py +++ b/tests/test_community_statistics.py @@ -11,7 +11,6 @@ for a,b in [ ('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 @@ -32,7 +31,7 @@ struct Json { }; 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"; + bool m_Ended=true,current=true,m_CommunityReady=false;string m_FinalEventId="final",m_FinalOutcome="victory",m_InFlight="final",serverId="server",m_MissionId="mission",m_FinalTerrain="map"; struct Mode {int writes=0;string text;void Publish(string v){writes++;text=v;}} mode; bool Current(){return current;} '''+callbacks+r''' diff --git a/tests/test_telemetry_terrain.py b/tests/test_telemetry_terrain.py new file mode 100644 index 0000000..6c5753c --- /dev/null +++ b/tests/test_telemetry_terrain.py @@ -0,0 +1,46 @@ +"""Exercise production terrain selection with engine world boundaries doubled.""" +from policy_harness import ROOT, body, run + +p = ROOT / 'addons/core/Scripts/Game/ERR/00_Core/ERR_Telemetry.c' +method = body(p, 'protected string Terrain(') +for a, b in [('protected ', ''), ('GetGame().', 'GetGame()->'), + ('GetWorld().', 'GetWorld()->'), ('ERR_TerrainDisplayName.Resolve', 'Resolve')]: + method = method.replace(a, b) +run(r''' +#include +#include +struct string:std::string {using std::string::string;bool IsEmpty(){return empty();}}; +struct World { + string root="$armareforger:Worlds/Eden/Eden.ent";int count=1; + int GetNumSubScenes(){return count;} + string GetSubSceneName(int index){assert(index==0);return root;} +} world; +struct Game { + World* loaded=&world;string scenario="$armareforger:Worlds/GameMaster/GameMaster.ent"; + World* GetWorld(){return loaded;}string GetWorldFile(){return scenario;} +} game; +Game* GetGame(){return &game;} +// The existing mission-title resolver is a separate native resource boundary. +string Resolve(string resource){ + if(resource=="$armareforger:Worlds/Eden/Eden.ent")return "Everon"; + if(resource=="$custom:Worlds/Nogova.ent")return "Nogova"; + return resource; +} +struct Telemetry { +''' + method + r''' +}; +int main(){ + Telemetry service; + assert(service.Terrain()=="Everon"); + // Same generic scenario name, different underlying island. + world.root="$custom:Worlds/Nogova.ent"; + assert(service.Terrain()=="Nogova"); + // Follow the loaded scene, even while scenario metadata is stale. + game.scenario="$armareforger:Worlds/GameMaster/GM_Eden.ent"; + assert(service.Terrain()=="Nogova"); + world.count=0;assert(service.Terrain()==game.scenario); + world.count=1;world.root="";assert(service.Terrain()==game.scenario); + game.loaded=nullptr;assert(service.Terrain()==game.scenario); +} +''') +print('PASS: telemetry uses the loaded island beneath scenario wrappers and handles unavailable roots')