Fix telemetry delivery and report loaded island names; record Workshop 0.0.130
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-10-30 17:13:11 +01:00
parent 08e5cbf5ea
commit 3ec882dc4e
8 changed files with 114 additions and 17 deletions
+6 -1
View File
@@ -1,6 +1,11 @@
# Changelog # 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 ## 2026-10-30 — Quieter AI traffic
@@ -89,6 +89,7 @@ class ERR_Telemetry
protected string m_InFlight; protected string m_InFlight;
protected string m_FinalEventId; protected string m_FinalEventId;
protected string m_FinalOutcome; protected string m_FinalOutcome;
protected string m_FinalTerrain;
protected bool m_CommunityReady; protected bool m_CommunityReady;
protected int m_Started; protected int m_Started;
protected int m_LastSample; protected int m_LastSample;
@@ -288,6 +289,18 @@ class ERR_Telemetry
m_State.previousOutcome = outcome; m_State.previousOutcome = outcome;
Event("mission_finished", 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 = "") protected void Event(string type, string outcome = "", string previous = "", string previousOutcome = "")
{ {
if (!Enabled()) return; if (!Enabled()) return;
@@ -299,10 +312,12 @@ class ERR_Telemetry
json.WriteValue("serverId", m_State.serverId); json.WriteValue("serverName", m_Config.ServerName); json.WriteValue("serverId", m_State.serverId); json.WriteValue("serverName", m_Config.ServerName);
json.WriteValue("type", type); json.WriteValue("occurredAt", System.GetUnixTime()); json.WriteValue("type", type); json.WriteValue("occurredAt", System.GetUnixTime());
json.WriteValue("partial", m_Partial); 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("phase", m_Mode.Phase()); json.WriteValue("outcome", outcome);
json.WriteValue("previousMissionId", previous); json.WriteValue("previousOutcome", previousOutcome); 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()); SCR_MissionHeader header = SCR_MissionHeader.Cast(GetGame().GetMissionHeader());
string scenario; if (header) scenario = header.m_sName; string scenario; if (header) scenario = header.m_sName;
json.WriteValue("gameVersion", GetGame().GetBuildVersion()); json.WriteValue("gameVersion", GetGame().GetBuildVersion());
@@ -332,7 +347,7 @@ class ERR_Telemetry
} }
if (type == "mission_finished") 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 // Finish can arrive while a previous request is in flight. RemoveItem on its
// acknowledgement still removes that exact payload, leaving this final first. // acknowledgement still removes that exact payload, leaving this final first.
m_State.pending.InsertAt(payload, 0); m_State.pending.InsertAt(payload, 0);
@@ -402,7 +417,7 @@ class ERR_Telemetry
if (m_FinalOutcome != "victory" && m_FinalOutcome != "defeat") return; if (m_FinalOutcome != "victory" && m_FinalOutcome != "defeat") return;
JsonLoadContext reply = new JsonLoadContext(); JsonLoadContext reply = new JsonLoadContext();
ERR_CommunityStatistics community; 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; CommunityUnavailable(); return;
} }
+12 -4
View File
@@ -82,7 +82,7 @@ receiver must accept chunked request bodies (used by the native HTTP client):
"outcome": "victory", "outcome": "victory",
"previousMissionId": "", "previousMissionId": "",
"previousOutcome": "", "previousOutcome": "",
"terrain": "{RESOURCE_GUID}Worlds/Example.ent", "terrain": "Everon",
"scenario": "Escape", "scenario": "Escape",
"gameVersion": "engine build version", "gameVersion": "engine build version",
"mods": [{"id": "2528466DE220D91C", "name": "Co10 Escape Reintroduced", "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 3 radio defence, 4 extraction, 5 victory, 6 defeat. `mods` describes mounted
non-vanilla addons; version is the locally active Workshop revision when exposed 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 by the engine, otherwise empty. Never infer the newest Workshop version from an
empty value. Terrain is the actual loaded world resource, including takeover empty value. `terrain` is the island name resolved by the reporting game server
scenarios, rather than the next-round terrain preference. 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 ## Event and outcome semantics
@@ -149,7 +152,7 @@ precision), not survival time. Reconnects reuse the authenticated identity.
## Reliable ingestion and aggregation ## Reliable ingestion and aggregation
The default receiver also returns public map aggregates from `GET /api/summary` 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 `serverId` adds the installation's record for that map. Summaries expose counts
only; detailed player and mission records still require a read/admin token. 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 Operator-created server records are labelled separately. These are reports from
community-hosted missions, not independent anti-cheat attestations. 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 ## Buffering, disabling and limits
Requests have a 15-second timeout, with a 30-second callback watchdog. Retries Requests have a 15-second timeout, with a 30-second callback watchdog. Retries
+6 -5
View File
@@ -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 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. 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. `&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 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 not player/server names, account IDs, raw events or credentials. Full records
remain protected by admin/read credentials. remain protected by admin/read credentials.
Each summary has `schemaVersion: 1`, `terrain`, `allServers` and optional `server`. Each summary has `schemaVersion: 1`, `terrain`, `allServers` and optional `server`.
Each record contains `completed`, `wins`, `losses`, `partialMissions`, and Each record contains `completed`, `wins`, `losses`, `partialMissions`, and
`winRate` (percentage; null when there are no completed rounds). Map identity is `winRate` (percentage; null when there are no completed rounds). The game server resolves the island name from its loaded root scene, independently
the actual loaded world resource, compared case-insensitively. Different worlds of the scenario title. Names are compared case-insensitively. Older reports
are kept separate even when their generic scenario title is the same. 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 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 `community` summary for that event's map, server, mission and event ID. It is
+6 -1
View File
@@ -50,7 +50,12 @@ export function validateEvent(event) {
for (const key of ['partial', 'modsTruncated', 'playersTruncated']) { for (const key of ['partial', 'modsTruncated', 'playersTruncated']) {
requireValue(typeof event[key] === 'boolean', `Invalid ${key}`); 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[key]), `Invalid ${key}`);
} }
requireValue(integer(event.occurredAt, 253402300799), 'Invalid occurredAt'); requireValue(integer(event.occurredAt, 253402300799), 'Invalid occurredAt');
@@ -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); 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 () => { test('out-of-order snapshots and second terminal reports cannot reopen or double count a mission', async () => {
const e = event({players: []}); const e = event({players: []});
const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100}; const heartbeat = {...e, type: 'heartbeat', phase: 4, outcome: '', elapsedSeconds: 100};
+1 -2
View File
@@ -11,7 +11,6 @@ for a,b in [
('JsonLoadContext sent = new JsonLoadContext()', 'Json sent'), ('JsonLoadContext reply = new JsonLoadContext()', 'Json reply'), ('JsonLoadContext sent = new JsonLoadContext()', 'Json sent'), ('JsonLoadContext reply = new JsonLoadContext()', 'Json reply'),
('ERR_CommunityStatistics community;', 'Community* community = nullptr;'), ('community.', 'community->'), ('ERR_CommunityStatistics community;', 'Community* community = nullptr;'), ('community.', 'community->'),
('m_Mode.ERR_PublishCommunityStatistics(', 'mode.Publish('), ('m_State.serverId','serverId'), ('m_Mode.ERR_PublishCommunityStatistics(', 'mode.Publish('), ('m_State.serverId','serverId'),
('GetGame().GetWorldFile()', 'world'),
]: callbacks=callbacks.replace(a,b) ]: callbacks=callbacks.replace(a,b)
run(r''' run(r'''
#include <cassert> #include <cassert>
@@ -32,7 +31,7 @@ struct Json {
}; };
struct RestCallback {string data="response";string GetData(){return data;}}; struct RestCallback {string data="response";string GetData(){return data;}};
struct Service { 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; struct Mode {int writes=0;string text;void Publish(string v){writes++;text=v;}} mode;
bool Current(){return current;} bool Current(){return current;}
'''+callbacks+r''' '''+callbacks+r'''
+46
View File
@@ -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 <cassert>
#include <string>
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')