Fix telemetry delivery and report loaded island names; record Workshop 0.0.130
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
This commit is contained in:
+6
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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'''
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user