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
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -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'''
|
||||||
|
|||||||
@@ -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