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 -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};