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
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
@@ -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;
}
+12 -4
View File
@@ -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
+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
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
+6 -1
View File
@@ -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');
@@ -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};
+1 -2
View File
@@ -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 <cassert>
@@ -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'''
+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')