Fix keyed telemetry match attribution (Refs #144)
CI / checks (push) Successful in 5m26s

This commit is contained in:
2026-10-12 00:58:05 -04:00
parent 4ea291d305
commit 785cc4616b
24 changed files with 1115 additions and 112 deletions
+43 -9
View File
@@ -73,6 +73,22 @@ without trimming or truncation. Keep GUIDs as strings to preserve their precisio
and leading zeros. Round and stat values are nonnegative JSON integers.
Each GUID must appear only once within a report's player array.
Every event also accepts these optional top-level fields:
| Field | Meaning |
| ------- | --------------------------------------------------------------------------------------------------- |
| `match` | Reporter-generated game identity: a nonblank string of at most 64 UTF-16 code units. |
| `seq` | Positive JSON safe integer, at most 9,007,199,254,740,991, identifying this report within the game. |
Send both fields for reliable match boundaries, ordering, and retry handling.
Choose a fresh `match` value for every game, including restarts on the same map,
and keep it unchanged throughout that game. Match keys are scoped to the exact
`server` name. Start `seq` at 1 and increment it once per event, including
heartbeats. Retry a report with its original `match`, `seq`, and payload; do not
allocate a new sequence number for a retry. Either field may be omitted for
compatibility, but sequence-based deduplication requires both fields. Existing
reporters without these fields remain valid.
Every event also accepts an optional per-player `ping`: the game server's current
latency measurement in milliseconds, as a nonnegative JSON integer (for example,
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
@@ -95,6 +111,8 @@ Send when the game starts. Round and stats are not required.
{
"event": "game_start",
"server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 1,
"map": "zm_transit",
"players": [
{ "name": "Josh", "guid": "12345678901234567" },
@@ -111,6 +129,8 @@ Send the new round and each player's current stats.
{
"event": "round_change",
"server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 2,
"map": "zm_transit",
"round": 25,
"players": [
@@ -144,6 +164,8 @@ Send the final round and each player's final stats when the game ends.
{
"event": "game_end",
"server": "t6_zombie_town_1",
"match": "town-20261012-001",
"seq": 3,
"map": "zm_transit",
"round": 26,
"players": [
@@ -179,16 +201,28 @@ validation. Authenticated bodies over 1 MiB return 413 before JSON parsing.
Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON
content type 415.
The script sends each event once, in gameplay order, directly to Electric Cherry.
There is no discovery request, beta token, boot/session ID, sequence number,
custom feature event, external helper, or separate beta acknowledgement.
Basic payload checks happen in the receiver.
Send events in gameplay order directly to Electric Cherry. There is no discovery
request, beta token, custom feature event, external helper, or separate beta
acknowledgement. Basic payload checks happen in the receiver.
Electric Cherry saves the latest complete report, including player stats, and
With `match` and `seq`, repeated sequences are ignored even after an application
restart. A lower sequence that arrives late can fill a missing-report gap without
rolling back the current round, roster, or counters. A delayed `game_start` can
record the starting roster without resetting the game's observed progression.
A known older match key cannot replace a newer game's live state. When an unseen
key starts a new game before the previous keyed game's `game_end` arrives,
Matches labels the previous game **End report missing**. A later `game_end`
completes its own match without changing the newer game. Missing sequence counts
are diagnostic: they do not invent absent rounds, rosters, or an ending.
Duplicate and older reports do not advance beta requirements or replace the
current scoreboard. A successful response acknowledges the submitted report;
it does not mean that report became the current game state.
Electric Cherry saves the latest accepted report, including player stats, and
streams it to connected clients. A new start clears the previous round display;
an end closes the match. Receipt order determines the latest report. This small
protocol does not attempt to identify delayed events from earlier games or
provide exactly-once delivery across games.
an end closes the match. Without a match key, legacy receipt-order and
map/round heuristics remain in effect. Those reports cannot reliably distinguish
delayed events from earlier games; send them once in order.
Match timing is independent of player count: an empty `game_start` still records
the start, and empty rosters or RCON polls do not erase it. Fleet can display a
@@ -198,7 +232,7 @@ Fleet clears the previous match's timing rather than carrying it into the new on
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
of beta activity. That page documents cumulative stat accounting, round credit,
and the limits of identifying matches from this protocol.
and match identity and ordering behavior.
### Reporter deployment
+49 -24
View File
@@ -14,9 +14,10 @@ only under All squad sizes. The JSON API also defaults to all maps.
Matches only appear in server/global history once they reach round 2.
Leaderboards, summaries, map options, profile statistics and player history include only
completed matches with a received `game_end`. Active and interrupted matches do
not contribute. A player must also be observed at round 2 or later; players who
left before a completed game's end retain their recorded stats.
completed matches with a received `game_end`. Active, interrupted, and
**End report missing** matches do not contribute. A player must also be observed
at round 2 or later; players who left before a completed game's end retain their
recorded stats.
This also excludes existing round 1 records. Early telemetry is retained so
qualifying games keep their start time, roster and cumulative counters.
@@ -76,7 +77,9 @@ map and server, final round, start/end times, duration when the start was observ
player counts, and the full saved roster. Players missing from the final report
retain their earlier counters; players with no stats display dashes. Counters and
score are the highest reported values within the match, not necessarily its final
scoreboard. Active and interrupted matches remain listed without results links.
scoreboard. Active, interrupted, and **End report missing** matches remain listed
without results links. Matches can filter each status and show known gaps in the
reporter's sequence; a delayed report can reduce that missing-report count.
Match pages remain available after the original server is removed.
### Player identity
@@ -126,8 +129,9 @@ rate also requires 100 kills; kills per down requires five downs. The board show
the numerator, denominator and game count beside each ratio. Ratios are computed
from aggregate counters, not an average of individual match percentages.
Completion rate is the share of those ended games whose final report contained
the player; it is not a win rate. Active and interrupted games do not enter any
denominator. Zero-denominator ratios remain unknown and are not ranked.
the player; it is not a win rate. Games without a received `game_end`, including
**End report missing** games, do not enter any denominator. Zero-denominator ratios
remain unknown and are not ranked.
Qualification is applied before ranking, search and pagination. Summary cards
still describe all players in the game/map selection, including those below the
@@ -149,12 +153,29 @@ They persist across app restarts and server deletion, and are not subject to
campaign history retention. The latest scoreboard and leaderboard update in the
same transaction. Each match keeps player counters even after someone leaves.
A `game_start` opens a match; `game_end` completes it. A changed map, a lower round,
or a round report after a completed game can also establish a new match when the
start was missed. Superseded active matches remain in history as interrupted.
Repeated starts before any stats belong to the same match. Identical consecutive
payloads (ignoring receipt time and roster order) do not add records. Repeated
snapshots in a match cannot increase counters or rounds twice.
A `game_start` opens a match; `game_end` completes it. Reporters can send an optional
`match` key and per-game `seq` on every event; see the
[reporter protocol](beta-test-plans.md#event-fields) for field limits and retry rules.
A key identifies one game on one server, including when the start report was
missed. An unseen key opens a new game and labels the previous active keyed game
**End report missing**. This records missing evidence; it does not establish
whether the game finished or was interrupted. A late `game_end` can still complete its own match; old keys never
replace the newer game's live state.
With both fields present, each sequence is counted once, including across app
restarts. Lower sequences fill missing-report gaps without rolling back newer
rounds, counters, or rosters. A delayed `game_start` may fill the recorded starting
roster without resetting progression. The history API exposes the highest
sequence minus the count of unique received sequences as `missingReports`, or
`null` when sequencing is unavailable. A missing report count does not grant
credit for unobserved rounds or complete a game without `game_end`.
Without a match key, the legacy heuristics remain: a changed map, a lower round,
or a round report after a completed game can establish a new match when the
start was missed. Superseded active matches remain interrupted. Repeated starts
before any stats belong to the same match. Identical consecutive payloads
(ignoring receipt time and roster order) do not add records. Repeated snapshots
in a match cannot increase counters or rounds twice.
The first upgrade imports the latest stored full scoreboard from each server
once. That provides the available counters and highest round, but not discarded
@@ -167,14 +188,13 @@ explicitly identified instead of being labeled as a known selection. A match
retains the game/map identity recorded at ingestion even if the server is later
reconfigured or removed.
The current protocol sends events in gameplay order and has no match ID, event
ID, or source sequence. A late report from an older game cannot be reliably
distinguished from a new game. Likewise, identical starts before any stats are
indistinguishable from retries. Send reports once in order. Within a match,
counter decreases use the previous high-water mark; if a reconnect resets a
player's counters, subsequent activity may be undercounted until those counters
pass their earlier maxima. Exact replay handling and counter-reset accounting
would require additional reporter identity/ordering information.
Legacy reports without match identity cannot reliably distinguish a late report
from an older game from a new game. Likewise, identical starts before any stats
are indistinguishable from retries. Send those reports once in order. Within a
match, counter decreases use the previous high-water mark; if a reconnect resets
a player's counters, subsequent activity may be undercounted until those counters
pass their earlier maxima. Match keys and sequence numbers do not identify
player counter resets.
## Read API
@@ -349,10 +369,15 @@ an identity rename can change results without changing those timestamps.
### Related routes and current limits
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
summaries as `{ "matches": [...], "total": number }`. These routes still require
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
no offset pagination. Results are newest first by first observation, then match
ID, and include active/interrupted matches that reached round 2. Their
summaries as `{ "matches": [...], "total": number, "page": number, "limit": number, "maps": [...] }`.
These routes still require session authentication. Both accept `limit` (default
25, range 1–10,000), `page`, `game`, `map`, `q` (server name), `days` (0, 7, 30, or
90), `status`, `sort` (`started`, `round`, `duration`, or `players`), and `direction`
(`asc` or `desc`). Results default to newest first by first observation, then
match ID, and include unfinished matches that reached round 2. Status values are
`active`, `completed`, `interrupted`, and `end-report-missing`; omit the filter for
all statuses. `reporterMatchKey` is the supplied match key or `null`;
`missingReports` is the known sequence-gap count or `null`. Their
[ServerMatch fields](../../src/lib/match-history.ts) differ from leaderboard entries
and do not include player counters or a roster. Unknown server names return an
empty list. URL-encode server names in paths.