This commit is contained in:
@@ -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.
|
and leading zeros. Round and stat values are nonnegative JSON integers.
|
||||||
Each GUID must appear only once within a report's player array.
|
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
|
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,
|
latency measurement in milliseconds, as a nonnegative JSON integer (for example,
|
||||||
`"ping": 42`). Send `null` or omit the field when unavailable; do not substitute
|
`"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",
|
"event": "game_start",
|
||||||
"server": "t6_zombie_town_1",
|
"server": "t6_zombie_town_1",
|
||||||
|
"match": "town-20261012-001",
|
||||||
|
"seq": 1,
|
||||||
"map": "zm_transit",
|
"map": "zm_transit",
|
||||||
"players": [
|
"players": [
|
||||||
{ "name": "Josh", "guid": "12345678901234567" },
|
{ "name": "Josh", "guid": "12345678901234567" },
|
||||||
@@ -111,6 +129,8 @@ Send the new round and each player's current stats.
|
|||||||
{
|
{
|
||||||
"event": "round_change",
|
"event": "round_change",
|
||||||
"server": "t6_zombie_town_1",
|
"server": "t6_zombie_town_1",
|
||||||
|
"match": "town-20261012-001",
|
||||||
|
"seq": 2,
|
||||||
"map": "zm_transit",
|
"map": "zm_transit",
|
||||||
"round": 25,
|
"round": 25,
|
||||||
"players": [
|
"players": [
|
||||||
@@ -144,6 +164,8 @@ Send the final round and each player's final stats when the game ends.
|
|||||||
{
|
{
|
||||||
"event": "game_end",
|
"event": "game_end",
|
||||||
"server": "t6_zombie_town_1",
|
"server": "t6_zombie_town_1",
|
||||||
|
"match": "town-20261012-001",
|
||||||
|
"seq": 3,
|
||||||
"map": "zm_transit",
|
"map": "zm_transit",
|
||||||
"round": 26,
|
"round": 26,
|
||||||
"players": [
|
"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
|
Invalid JSON or fields return 400, an unknown/destroyed server 404, and a non-JSON
|
||||||
content type 415.
|
content type 415.
|
||||||
|
|
||||||
The script sends each event once, in gameplay order, directly to Electric Cherry.
|
Send events in gameplay order directly to Electric Cherry. There is no discovery
|
||||||
There is no discovery request, beta token, boot/session ID, sequence number,
|
request, beta token, custom feature event, external helper, or separate beta
|
||||||
custom feature event, external helper, or separate beta acknowledgement.
|
acknowledgement. Basic payload checks happen in the receiver.
|
||||||
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;
|
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
|
an end closes the match. Without a match key, legacy receipt-order and
|
||||||
protocol does not attempt to identify delayed events from earlier games or
|
map/round heuristics remain in effect. Those reports cannot reliably distinguish
|
||||||
provide exactly-once delivery across games.
|
delayed events from earlier games; send them once in order.
|
||||||
|
|
||||||
Match timing is independent of player count: an empty `game_start` still records
|
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
|
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
|
Reports also feed the persistent [Leaderboard](leaderboard.md), independently
|
||||||
of beta activity. That page documents cumulative stat accounting, round credit,
|
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
|
### Reporter deployment
|
||||||
|
|
||||||
|
|||||||
+49
-24
@@ -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.
|
Matches only appear in server/global history once they reach round 2.
|
||||||
Leaderboards, summaries, map options, profile statistics and player history include only
|
Leaderboards, summaries, map options, profile statistics and player history include only
|
||||||
completed matches with a received `game_end`. Active and interrupted matches do
|
completed matches with a received `game_end`. Active, interrupted, and
|
||||||
not contribute. A player must also be observed at round 2 or later; players who
|
**End report missing** matches do not contribute. A player must also be observed
|
||||||
left before a completed game's end retain their recorded stats.
|
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
|
This also excludes existing round 1 records. Early telemetry is retained so
|
||||||
qualifying games keep their start time, roster and cumulative counters.
|
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
|
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
|
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
|
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.
|
Match pages remain available after the original server is removed.
|
||||||
|
|
||||||
### Player identity
|
### 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
|
the numerator, denominator and game count beside each ratio. Ratios are computed
|
||||||
from aggregate counters, not an average of individual match percentages.
|
from aggregate counters, not an average of individual match percentages.
|
||||||
Completion rate is the share of those ended games whose final report contained
|
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
|
the player; it is not a win rate. Games without a received `game_end`, including
|
||||||
denominator. Zero-denominator ratios remain unknown and are not ranked.
|
**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
|
Qualification is applied before ranking, search and pagination. Summary cards
|
||||||
still describe all players in the game/map selection, including those below the
|
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
|
campaign history retention. The latest scoreboard and leaderboard update in the
|
||||||
same transaction. Each match keeps player counters even after someone leaves.
|
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,
|
A `game_start` opens a match; `game_end` completes it. Reporters can send an optional
|
||||||
or a round report after a completed game can also establish a new match when the
|
`match` key and per-game `seq` on every event; see the
|
||||||
start was missed. Superseded active matches remain in history as interrupted.
|
[reporter protocol](beta-test-plans.md#event-fields) for field limits and retry rules.
|
||||||
Repeated starts before any stats belong to the same match. Identical consecutive
|
A key identifies one game on one server, including when the start report was
|
||||||
payloads (ignoring receipt time and roster order) do not add records. Repeated
|
missed. An unseen key opens a new game and labels the previous active keyed game
|
||||||
snapshots in a match cannot increase counters or rounds twice.
|
**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
|
The first upgrade imports the latest stored full scoreboard from each server
|
||||||
once. That provides the available counters and highest round, but not discarded
|
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
|
retains the game/map identity recorded at ingestion even if the server is later
|
||||||
reconfigured or removed.
|
reconfigured or removed.
|
||||||
|
|
||||||
The current protocol sends events in gameplay order and has no match ID, event
|
Legacy reports without match identity cannot reliably distinguish a late report
|
||||||
ID, or source sequence. A late report from an older game cannot be reliably
|
from an older game from a new game. Likewise, identical starts before any stats
|
||||||
distinguished from a new game. Likewise, identical starts before any stats are
|
are indistinguishable from retries. Send those reports once in order. Within a
|
||||||
indistinguishable from retries. Send reports once in order. Within a match,
|
match, counter decreases use the previous high-water mark; if a reconnect resets
|
||||||
counter decreases use the previous high-water mark; if a reconnect resets a
|
a player's counters, subsequent activity may be undercounted until those counters
|
||||||
player's counters, subsequent activity may be undercounted until those counters
|
pass their earlier maxima. Match keys and sequence numbers do not identify
|
||||||
pass their earlier maxima. Exact replay handling and counter-reset accounting
|
player counter resets.
|
||||||
would require additional reporter identity/ordering information.
|
|
||||||
|
|
||||||
## Read API
|
## Read API
|
||||||
|
|
||||||
@@ -349,10 +369,15 @@ an identity rename can change results without changing those timestamps.
|
|||||||
### Related routes and current limits
|
### Related routes and current limits
|
||||||
|
|
||||||
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
`GET /api/matches` and `GET /api/servers/{name}/matches` return JSON match
|
||||||
summaries as `{ "matches": [...], "total": number }`. These routes still require
|
summaries as `{ "matches": [...], "total": number, "page": number, "limit": number, "maps": [...] }`.
|
||||||
session authentication. Both accept only `limit` (default 25, range 1–10,000); there is
|
These routes still require session authentication. Both accept `limit` (default
|
||||||
no offset pagination. Results are newest first by first observation, then match
|
25, range 1–10,000), `page`, `game`, `map`, `q` (server name), `days` (0, 7, 30, or
|
||||||
ID, and include active/interrupted matches that reached round 2. Their
|
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
|
[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
|
and do not include player counters or a roster. Unknown server names return an
|
||||||
empty list. URL-encode server names in paths.
|
empty list. URL-encode server names in paths.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export interface ChatMatch {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
lastReceivedAt: string;
|
lastReceivedAt: string;
|
||||||
status: 'active' | 'completed' | 'interrupted';
|
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
startObserved: boolean;
|
startObserved: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface MatchHistoryQuery {
|
|||||||
map: string;
|
map: string;
|
||||||
q: string;
|
q: string;
|
||||||
days: number;
|
days: number;
|
||||||
status: '' | 'active' | 'completed' | 'interrupted';
|
status: '' | 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
sort: MatchSort;
|
sort: MatchSort;
|
||||||
direction: 'asc' | 'desc';
|
direction: 'asc' | 'desc';
|
||||||
page: number;
|
page: number;
|
||||||
@@ -35,7 +35,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
|
|||||||
map.length > 1024 ||
|
map.length > 1024 ||
|
||||||
q.length > 100 ||
|
q.length > 100 ||
|
||||||
![0, 7, 30, 90].includes(days) ||
|
![0, 7, 30, 90].includes(days) ||
|
||||||
!['', 'active', 'completed', 'interrupted'].includes(status) ||
|
!['', 'active', 'completed', 'interrupted', 'end-report-missing'].includes(status) ||
|
||||||
!matchSorts.includes(sort as MatchSort) ||
|
!matchSorts.includes(sort as MatchSort) ||
|
||||||
!['asc', 'desc'].includes(direction) ||
|
!['asc', 'desc'].includes(direction) ||
|
||||||
!Number.isSafeInteger(page) ||
|
!Number.isSafeInteger(page) ||
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export interface ServerMatch {
|
|||||||
startedAt: string;
|
startedAt: string;
|
||||||
lastReportedAt: string | null;
|
lastReportedAt: string | null;
|
||||||
endedAt: string | null;
|
endedAt: string | null;
|
||||||
status: 'active' | 'completed' | 'interrupted';
|
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
|
reporterMatchKey: string | null;
|
||||||
|
// Unknown without sequence reports; otherwise gaps through the highest received sequence.
|
||||||
|
missingReports: number | null;
|
||||||
startPlayers: number | null;
|
startPlayers: number | null;
|
||||||
uniquePlayers: number;
|
uniquePlayers: number;
|
||||||
endPlayers: number | null;
|
endPlayers: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/** Reporter identities and received sequences survive controller restarts with match history. */
|
||||||
|
export function migrateLeaderboardReports(handle: Database.Database): void {
|
||||||
|
const columns = new Set(
|
||||||
|
(handle.prepare('PRAGMA table_info(leaderboard_matches)').all() as { name: string }[]).map(
|
||||||
|
(column) => column.name
|
||||||
|
)
|
||||||
|
);
|
||||||
|
for (const [name, definition] of [
|
||||||
|
['reporter_match_key', 'TEXT'],
|
||||||
|
['last_seq', 'INTEGER NOT NULL DEFAULT 0'],
|
||||||
|
['report_count', 'INTEGER NOT NULL DEFAULT 0']
|
||||||
|
]) {
|
||||||
|
if (!columns.has(name))
|
||||||
|
handle.exec(`ALTER TABLE leaderboard_matches ADD COLUMN ${name} ${definition}`);
|
||||||
|
}
|
||||||
|
handle.exec(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_leaderboard_reporter_match
|
||||||
|
ON leaderboard_matches(server, reporter_match_key)
|
||||||
|
WHERE reporter_match_key IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS leaderboard_match_reports (
|
||||||
|
match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE,
|
||||||
|
seq INTEGER NOT NULL CHECK (typeof(seq) = 'integer' AND seq > 0),
|
||||||
|
applied INTEGER NOT NULL CHECK (applied IN (0,1)),
|
||||||
|
PRIMARY KEY (match_id, seq)
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { enableIncrementalVacuum, retentionClocks } from './retention-clocks.js'
|
|||||||
import { migrateHostSchema } from '../hosts/schema.js';
|
import { migrateHostSchema } from '../hosts/schema.js';
|
||||||
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
|
import { migrateAuthSchema, migrateSuperViewerRole } from '../auth/schema.js';
|
||||||
import { migrateGameFileSchema } from '../game-files/schema.js';
|
import { migrateGameFileSchema } from '../game-files/schema.js';
|
||||||
|
import { migrateLeaderboardReports } from './leaderboard-reports.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
// Append only: never renumber entries or change an applied migration to add an
|
// Append only: never renumber entries or change an applied migration to add an
|
||||||
@@ -97,6 +98,11 @@ export const migrations: readonly Migration[] = [
|
|||||||
name: 'super viewer authentication role',
|
name: 'super viewer authentication role',
|
||||||
mode: 'foreign-keys-off',
|
mode: 'foreign-keys-off',
|
||||||
run: migrateSuperViewerRole
|
run: migrateSuperViewerRole
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 18,
|
||||||
|
name: 'durable leaderboard report identity and sequences',
|
||||||
|
run: migrateLeaderboardReports
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,17 @@ interface Match {
|
|||||||
id: number;
|
id: number;
|
||||||
map_key: string;
|
map_key: string;
|
||||||
game: string;
|
game: string;
|
||||||
status: 'active' | 'completed' | 'interrupted';
|
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
|
||||||
highest_round: number;
|
highest_round: number;
|
||||||
last_event: GameTelemetry['event'];
|
last_event: GameTelemetry['event'];
|
||||||
last_fingerprint: string;
|
last_fingerprint: string;
|
||||||
|
start_observed: number;
|
||||||
|
started_at: string;
|
||||||
|
server_deleted_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KeyedMatch extends Match {
|
||||||
|
last_seq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Participation {
|
interface Participation {
|
||||||
@@ -51,7 +58,7 @@ function mapIdentity(handle: Database.Database, report: GameTelemetry) {
|
|||||||
|
|
||||||
function fingerprint(report: GameTelemetry): string {
|
function fingerprint(report: GameTelemetry): string {
|
||||||
// Receipt timestamps and roster ordering do not identify new gameplay.
|
// Receipt timestamps and roster ordering do not identify new gameplay.
|
||||||
const { receivedAt: _receivedAt, timing: _timing, ...event } = report;
|
const { receivedAt: _receivedAt, timing: _timing, match: _match, seq: _seq, ...event } = report;
|
||||||
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
|
const players = [...event.players].sort((a, b) => a.guid.localeCompare(b.guid));
|
||||||
return createHash('sha256')
|
return createHash('sha256')
|
||||||
.update(JSON.stringify({ ...event, players }))
|
.update(JSON.stringify({ ...event, players }))
|
||||||
@@ -59,7 +66,16 @@ function fingerprint(report: GameTelemetry): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Called inside the same transaction as the latest scoreboard snapshot. */
|
/** Called inside the same transaction as the latest scoreboard snapshot. */
|
||||||
export function recordLeaderboardTelemetry(handle: Database.Database, report: GameTelemetry): void {
|
export function recordLeaderboardTelemetry(
|
||||||
|
handle: Database.Database,
|
||||||
|
report: GameTelemetry
|
||||||
|
): { matchId: number; current: boolean } {
|
||||||
|
if (report.match !== undefined) return recordKeyedTelemetry(handle, report);
|
||||||
|
return { matchId: recordLegacyTelemetry(handle, report), current: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also used by the version-0 snapshot backfill, before keyed schema exists.
|
||||||
|
function recordLegacyTelemetry(handle: Database.Database, report: GameTelemetry): number {
|
||||||
const identity = mapIdentity(handle, report);
|
const identity = mapIdentity(handle, report);
|
||||||
const signature = fingerprint(report);
|
const signature = fingerprint(report);
|
||||||
let match = handle
|
let match = handle
|
||||||
@@ -74,7 +90,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
handle
|
handle
|
||||||
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
|
.prepare('UPDATE leaderboard_matches SET last_received_at = ? WHERE id = ?')
|
||||||
.run(report.receivedAt, match.id);
|
.run(report.receivedAt, match.id);
|
||||||
if (match.status !== 'interrupted') return;
|
if (match.status !== 'interrupted') return match.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isStart = report.event === 'game_start';
|
const isStart = report.event === 'game_start';
|
||||||
@@ -95,6 +111,20 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
)
|
)
|
||||||
.run(report.receivedAt, match.id);
|
.run(report.receivedAt, match.id);
|
||||||
}
|
}
|
||||||
|
match = createMatch(handle, report, identity, signature);
|
||||||
|
}
|
||||||
|
// No prior match always creates one above.
|
||||||
|
if (!match) throw new Error('Could not create telemetry match');
|
||||||
|
applyReport(handle, report, match, signature);
|
||||||
|
return match.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMatch(
|
||||||
|
handle: Database.Database,
|
||||||
|
report: GameTelemetry,
|
||||||
|
identity: ReturnType<typeof mapIdentity>,
|
||||||
|
signature: string
|
||||||
|
): Match {
|
||||||
const inserted = handle
|
const inserted = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO leaderboard_matches
|
`INSERT INTO leaderboard_matches
|
||||||
@@ -109,26 +139,27 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
identity.name,
|
identity.name,
|
||||||
report.receivedAt,
|
report.receivedAt,
|
||||||
report.receivedAt,
|
report.receivedAt,
|
||||||
Number(isStart),
|
Number(report.event === 'game_start'),
|
||||||
report.event,
|
report.event,
|
||||||
signature
|
signature
|
||||||
);
|
);
|
||||||
match = {
|
return handle
|
||||||
id: Number(inserted.lastInsertRowid),
|
.prepare('SELECT * FROM leaderboard_matches WHERE id = ?')
|
||||||
game: identity.game,
|
.get(inserted.lastInsertRowid) as Match;
|
||||||
map_key: identity.key,
|
|
||||||
status: 'active',
|
|
||||||
highest_round: 0,
|
|
||||||
last_event: report.event,
|
|
||||||
last_fingerprint: signature
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!match) return;
|
|
||||||
|
|
||||||
|
function applyReport(
|
||||||
|
handle: Database.Database,
|
||||||
|
report: GameTelemetry,
|
||||||
|
match: Match,
|
||||||
|
signature: string
|
||||||
|
): void {
|
||||||
|
const isStart = report.event === 'game_start';
|
||||||
|
const round = isStart ? 0 : report.round;
|
||||||
if (isStart) {
|
if (isStart) {
|
||||||
handle
|
handle
|
||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE leaderboard_matches SET start_players = COALESCE(start_players, ?) WHERE id = ?'
|
'UPDATE leaderboard_matches SET start_observed = 1, start_players = COALESCE(start_players, ?) WHERE id = ?'
|
||||||
)
|
)
|
||||||
.run(report.players.length, match.id);
|
.run(report.players.length, match.id);
|
||||||
}
|
}
|
||||||
@@ -159,8 +190,10 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
rounds_survived = rounds_survived + excluded.rounds_survived,
|
rounds_survived = rounds_survived + excluded.rounds_survived,
|
||||||
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
|
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
|
||||||
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
|
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
|
||||||
best_score = MAX(best_score, excluded.best_score), last_round = excluded.last_round,
|
best_score = MAX(best_score, excluded.best_score), last_round = MAX(last_round, excluded.last_round),
|
||||||
present = 1, has_stats = MAX(has_stats, excluded.has_stats),
|
present = 1, has_stats = MAX(has_stats, excluded.has_stats),
|
||||||
|
first_observed_round = CASE WHEN excluded.present_at_start = 1 THEN 0 ELSE first_observed_round END,
|
||||||
|
present_at_start = CASE WHEN excluded.present_at_start = 1 THEN 1 ELSE present_at_start END,
|
||||||
completed = MAX(completed, excluded.completed), last_seen_at = excluded.last_seen_at`);
|
completed = MAX(completed, excluded.completed), last_seen_at = excluded.last_seen_at`);
|
||||||
|
|
||||||
// Highest round includes late joins; total rounds only credits progression
|
// Highest round includes late joins; total rounds only credits progression
|
||||||
@@ -172,7 +205,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
const survived =
|
const survived =
|
||||||
!isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
|
!isStart && prior?.present ? Math.max(0, completedRounds - prior.last_round) : 0;
|
||||||
saveIdentity.run(
|
saveIdentity.run(
|
||||||
identity.game,
|
match.game,
|
||||||
player.guid,
|
player.guid,
|
||||||
player.name,
|
player.name,
|
||||||
report.receivedAt,
|
report.receivedAt,
|
||||||
@@ -217,6 +250,153 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordKeyedTelemetry(
|
||||||
|
handle: Database.Database,
|
||||||
|
report: GameTelemetry
|
||||||
|
): { matchId: number; current: boolean } {
|
||||||
|
const signature = fingerprint(report);
|
||||||
|
let match = handle
|
||||||
|
.prepare('SELECT * FROM leaderboard_matches WHERE server = ? AND reporter_match_key = ?')
|
||||||
|
.get(report.server, report.match) as KeyedMatch | undefined;
|
||||||
|
if (!match) {
|
||||||
|
// A key is a durable identity. Only a previously unseen key supersedes
|
||||||
|
// the current game; a retry for a known old key cannot close a new one.
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE leaderboard_matches SET
|
||||||
|
status = CASE WHEN reporter_match_key IS NULL THEN 'interrupted' ELSE 'end-report-missing' END,
|
||||||
|
ended_at = ? WHERE server = ? AND server_deleted_at IS NULL AND status = 'active'`
|
||||||
|
)
|
||||||
|
.run(report.receivedAt, report.server);
|
||||||
|
const created = createMatch(handle, report, mapIdentity(handle, report), signature);
|
||||||
|
handle
|
||||||
|
.prepare('UPDATE leaderboard_matches SET reporter_match_key = ? WHERE id = ?')
|
||||||
|
.run(report.match, created.id);
|
||||||
|
match = { ...created, last_seq: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { matchId: match.id, current: false };
|
||||||
|
if (report.seq !== undefined) {
|
||||||
|
const inserted = handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_match_reports (match_id, seq, applied)
|
||||||
|
VALUES (?, ?, 0) ON CONFLICT DO NOTHING`
|
||||||
|
)
|
||||||
|
.run(match.id, report.seq);
|
||||||
|
if (!inserted.changes) return result;
|
||||||
|
// Count received sequences even when their state is too old to apply.
|
||||||
|
// This represents gaps without allocating a row for each missing value.
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE leaderboard_matches SET last_seq = MAX(last_seq, ?),
|
||||||
|
report_count = report_count + 1 WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(report.seq, match.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stale =
|
||||||
|
report.seq !== undefined
|
||||||
|
? report.seq < match.last_seq
|
||||||
|
: report.event !== 'game_start' && report.round < match.highest_round;
|
||||||
|
const delayedStart =
|
||||||
|
report.event === 'game_start' &&
|
||||||
|
(stale || match.highest_round > 0 || match.status !== 'active');
|
||||||
|
let applied = false;
|
||||||
|
if (delayedStart) {
|
||||||
|
if (!match.start_observed) {
|
||||||
|
observeDelayedStart(handle, report, match);
|
||||||
|
applied = true;
|
||||||
|
}
|
||||||
|
} else if (stale) {
|
||||||
|
// Completion is forward progress, even if its sequence trails a report
|
||||||
|
// already seen. Do not rewind the newer roster or counters with it.
|
||||||
|
if (report.event === 'game_end' && match.status !== 'completed') {
|
||||||
|
completeDelayedMatch(handle, report, match);
|
||||||
|
applied = true;
|
||||||
|
}
|
||||||
|
} else if (match.status !== 'completed') {
|
||||||
|
applyReport(handle, report, match, signature);
|
||||||
|
applied = true;
|
||||||
|
const latest = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT id FROM leaderboard_matches
|
||||||
|
WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1`
|
||||||
|
)
|
||||||
|
.get(report.server) as { id: number } | undefined;
|
||||||
|
result.current = latest?.id === match.id && match.server_deleted_at === null;
|
||||||
|
}
|
||||||
|
if (applied && report.seq !== undefined) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'UPDATE leaderboard_match_reports SET applied = 1 WHERE match_id = ? AND seq = ?'
|
||||||
|
)
|
||||||
|
.run(match.id, report.seq);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completeDelayedMatch(
|
||||||
|
handle: Database.Database,
|
||||||
|
report: Extract<GameTelemetry, { round: number }>,
|
||||||
|
match: Match
|
||||||
|
): void {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE leaderboard_matches SET status = 'completed', ended_at = ?,
|
||||||
|
highest_round = MAX(highest_round, ?) WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(report.receivedAt, report.round, match.id);
|
||||||
|
const identity =
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO NOTHING`);
|
||||||
|
const endPlayer = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||||
|
(match_id, guid, highest_round, kills, headshots, downs, revives, best_score,
|
||||||
|
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 1, 1, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(match_id, guid) DO UPDATE SET
|
||||||
|
highest_round = MAX(highest_round, excluded.highest_round),
|
||||||
|
kills = MAX(kills, excluded.kills), headshots = MAX(headshots, excluded.headshots),
|
||||||
|
downs = MAX(downs, excluded.downs), revives = MAX(revives, excluded.revives),
|
||||||
|
best_score = MAX(best_score, excluded.best_score), has_stats = 1, completed = 1`);
|
||||||
|
for (const player of report.players) {
|
||||||
|
identity.run(match.game, player.guid, player.name, report.receivedAt, report.receivedAt);
|
||||||
|
endPlayer.run(
|
||||||
|
match.id,
|
||||||
|
player.guid,
|
||||||
|
report.round,
|
||||||
|
player.kills,
|
||||||
|
player.headshots,
|
||||||
|
player.downs,
|
||||||
|
player.revives,
|
||||||
|
player.score,
|
||||||
|
Math.max(0, report.round - 1),
|
||||||
|
report.receivedAt,
|
||||||
|
report.receivedAt,
|
||||||
|
report.round
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recover start evidence without replacing the latest roster or its progress. */
|
||||||
|
function observeDelayedStart(handle: Database.Database, report: GameTelemetry, match: Match): void {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`UPDATE leaderboard_matches SET start_observed = 1, start_players = ? WHERE id = ?`
|
||||||
|
)
|
||||||
|
.run(report.players.length, match.id);
|
||||||
|
const identity =
|
||||||
|
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO NOTHING`);
|
||||||
|
const playerStart = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||||
|
(match_id, guid, present, first_seen_at, last_seen_at, first_observed_round, present_at_start)
|
||||||
|
VALUES (?, ?, 0, ?, ?, 0, 1) ON CONFLICT(match_id, guid) DO UPDATE SET
|
||||||
|
present_at_start = 1, first_observed_round = 0`);
|
||||||
|
for (const player of report.players) {
|
||||||
|
identity.run(match.game, player.guid, player.name, match.started_at, match.started_at);
|
||||||
|
playerStart.run(match.id, player.guid, match.started_at, match.started_at);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Seed the single surviving scoreboard per server once; older games are gone. */
|
/** Seed the single surviving scoreboard per server once; older games are gone. */
|
||||||
export function backfillLeaderboard(handle: Database.Database): void {
|
export function backfillLeaderboard(handle: Database.Database): void {
|
||||||
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'leaderboard_v1'").get()) return;
|
if (handle.prepare("SELECT 1 FROM settings WHERE key = 'leaderboard_v1'").get()) return;
|
||||||
@@ -239,7 +419,7 @@ export function backfillLeaderboard(handle: Database.Database): void {
|
|||||||
} catch {
|
} catch {
|
||||||
continue;
|
continue;
|
||||||
} // Legacy malformed snapshots cannot establish player records.
|
} // Legacy malformed snapshots cannot establish player records.
|
||||||
recordLeaderboardTelemetry(handle, report);
|
recordLegacyTelemetry(handle, report);
|
||||||
}
|
}
|
||||||
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
|
handle.prepare("INSERT INTO settings (key, value) VALUES ('leaderboard_v1', '1')").run();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import {
|
|||||||
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
|
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
|
||||||
|
|
||||||
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
const matchSummary = `m.id, m.game, m.highest_round AS observedRound, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||||
|
m.reporter_match_key AS reporterMatchKey,
|
||||||
|
CASE WHEN m.last_seq > 0 THEN m.last_seq - m.report_count END AS missingReports,
|
||||||
m.last_received_at AS lastReportedAt,
|
m.last_received_at AS lastReportedAt,
|
||||||
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
||||||
m.start_players AS startPlayers,
|
m.start_players AS startPlayers,
|
||||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||||
CASE WHEN m.status = 'completed' THEN
|
CASE WHEN m.status = 'completed' THEN
|
||||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id AND p.present = 1)
|
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id
|
||||||
|
AND (CASE WHEN m.reporter_match_key IS NULL THEN p.present ELSE p.completed END) = 1)
|
||||||
END AS endPlayers,
|
END AS endPlayers,
|
||||||
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
|
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
|
||||||
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
|
||||||
@@ -112,7 +115,8 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
|||||||
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
|
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
|
||||||
(SELECT identity.name FROM leaderboard_players identity
|
(SELECT identity.name FROM leaderboard_players identity
|
||||||
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
||||||
p.has_stats AS hasStats, p.present AS presentAtEnd,
|
p.has_stats AS hasStats,
|
||||||
|
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
|
||||||
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
p.first_seen_at AS firstSeenAt, p.last_seen_at AS lastSeenAt,
|
||||||
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
CASE WHEN p.has_stats = 1 THEN p.highest_round END AS highestRound,
|
||||||
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
CASE WHEN p.has_stats = 1 THEN p.rounds_survived END AS roundsSurvived,
|
||||||
@@ -126,7 +130,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
|||||||
FROM leaderboard_match_players p WHERE p.match_id = ?
|
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||||
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`
|
||||||
)
|
)
|
||||||
.all(match.game, id) as Array<
|
.all(match.game, match.reporterMatchKey, id) as Array<
|
||||||
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||||
hasStats: number;
|
hasStats: number;
|
||||||
presentAtEnd: number;
|
presentAtEnd: number;
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export function listTelemetry(): GameTelemetry[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
||||||
|
return saveTelemetryWithOutcome(report).telemetry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Acknowledgements retain their match id even when the live snapshot must stay put. */
|
||||||
|
export function saveTelemetryWithOutcome(report: GameTelemetry): {
|
||||||
|
telemetry: GameTelemetry;
|
||||||
|
current: boolean;
|
||||||
|
} {
|
||||||
// Keep callers outside the HTTP route atomic too.
|
// Keep callers outside the HTTP route atomic too.
|
||||||
const handle = db();
|
const handle = db();
|
||||||
return handle.transaction(() => {
|
return handle.transaction(() => {
|
||||||
@@ -24,12 +32,16 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
|||||||
const previous: GameTelemetry | undefined = row?.payload
|
const previous: GameTelemetry | undefined = row?.payload
|
||||||
? JSON.parse(row.payload)
|
? JSON.parse(row.payload)
|
||||||
: undefined;
|
: undefined;
|
||||||
recordLeaderboardTelemetry(handle, report);
|
const recorded = recordLeaderboardTelemetry(handle, report);
|
||||||
|
if (!recorded.current) {
|
||||||
|
return {
|
||||||
|
telemetry: { ...report, timing: { matchId: recorded.matchId, players: {} } },
|
||||||
|
current: false
|
||||||
|
};
|
||||||
|
}
|
||||||
const match = handle
|
const match = handle
|
||||||
.prepare(
|
.prepare('SELECT id, started_at, start_observed FROM leaderboard_matches WHERE id = ?')
|
||||||
'SELECT id, started_at, start_observed FROM leaderboard_matches WHERE server = ? ORDER BY id DESC LIMIT 1'
|
.get(recorded.matchId) as { id: number; started_at: string; start_observed: number };
|
||||||
)
|
|
||||||
.get(report.server) as { id: number; started_at: string; start_observed: number };
|
|
||||||
const sameMatch = previous?.timing?.matchId === match.id;
|
const sameMatch = previous?.timing?.matchId === match.id;
|
||||||
const serverTiming = handle
|
const serverTiming = handle
|
||||||
.prepare(
|
.prepare(
|
||||||
@@ -128,6 +140,6 @@ export function saveTelemetry(report: GameTelemetry): GameTelemetry {
|
|||||||
)
|
)
|
||||||
.run(report.receivedAt, report.server, report.receivedAt);
|
.run(report.receivedAt, report.server, report.receivedAt);
|
||||||
}
|
}
|
||||||
return report;
|
return { telemetry: report, current: true };
|
||||||
})();
|
})();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ function policies(): Policy[] {
|
|||||||
id: 'id',
|
id: 'id',
|
||||||
clock: 'last_received_at',
|
clock: 'last_received_at',
|
||||||
days: 'matchKeepDays',
|
days: 'matchKeepDays',
|
||||||
eligible: "o.status IN ('completed','interrupted')",
|
eligible: "o.status IN ('completed','interrupted','end-report-missing')",
|
||||||
bytes: '0'
|
bytes: '0'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+21
-2
@@ -13,7 +13,14 @@ export interface TelemetryPlayerStats extends TelemetryPlayer {
|
|||||||
score: number;
|
score: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameEvent = { server: string; map: string } & (
|
export type GameEvent = {
|
||||||
|
server: string;
|
||||||
|
map: string;
|
||||||
|
/** Opaque identifier assigned by the reporter for one match on this server. */
|
||||||
|
match?: string;
|
||||||
|
/** Monotonically increasing report number within the reporter's match. */
|
||||||
|
seq?: number;
|
||||||
|
} & (
|
||||||
| { event: 'game_start'; players: TelemetryPlayer[] }
|
| { event: 'game_start'; players: TelemetryPlayer[] }
|
||||||
| {
|
| {
|
||||||
event: 'round_change' | 'heartbeat' | 'game_end';
|
event: 'round_change' | 'heartbeat' | 'game_end';
|
||||||
@@ -52,6 +59,13 @@ export function parseGameEvent(value: unknown): GameEvent {
|
|||||||
if (typeof body[key] !== 'string' || !body[key].trim() || body[key].length > 255)
|
if (typeof body[key] !== 'string' || !body[key].trim() || body[key].length > 255)
|
||||||
throw new Error(`${key} must be a nonempty string (up to 255 characters)`);
|
throw new Error(`${key} must be a nonempty string (up to 255 characters)`);
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
body.match !== undefined &&
|
||||||
|
(typeof body.match !== 'string' || !body.match.trim() || body.match.length > 64)
|
||||||
|
)
|
||||||
|
throw new Error('match must be a nonempty string (up to 64 characters)');
|
||||||
|
if (body.seq !== undefined && (!Number.isSafeInteger(body.seq) || (body.seq as number) < 1))
|
||||||
|
throw new Error('seq must be a positive safe integer');
|
||||||
if (!Array.isArray(body.players)) throw new Error('players must be an array');
|
if (!Array.isArray(body.players)) throw new Error('players must be an array');
|
||||||
const guids = new Set<string>();
|
const guids = new Set<string>();
|
||||||
const players = body.players.map((value, index) => {
|
const players = body.players.map((value, index) => {
|
||||||
@@ -89,7 +103,12 @@ export function parseGameEvent(value: unknown): GameEvent {
|
|||||||
}
|
}
|
||||||
return { ...identity, ...stats };
|
return { ...identity, ...stats };
|
||||||
});
|
});
|
||||||
const common = { server: body.server as string, map: body.map as string };
|
const common = {
|
||||||
|
server: body.server as string,
|
||||||
|
map: body.map as string,
|
||||||
|
...(body.match !== undefined ? { match: body.match as string } : {}),
|
||||||
|
...(body.seq !== undefined ? { seq: body.seq as number } : {})
|
||||||
|
};
|
||||||
if (body.event === 'game_start') return { ...common, event: 'game_start', players };
|
if (body.event === 'game_start') return { ...common, event: 'game_start', players };
|
||||||
if (!Number.isSafeInteger(body.round) || (body.round as number) < 0)
|
if (!Number.isSafeInteger(body.round) || (body.round as number) < 0)
|
||||||
throw new Error('round must be a nonnegative integer');
|
throw new Error('round must be a nonnegative integer');
|
||||||
|
|||||||
@@ -200,7 +200,7 @@
|
|||||||
>Completed</option
|
>Completed</option
|
||||||
><option value="active">In progress</option><option value="interrupted"
|
><option value="active">In progress</option><option value="interrupted"
|
||||||
>Interrupted</option
|
>Interrupted</option
|
||||||
></Select
|
><option value="end-report-missing">End report missing</option></Select
|
||||||
></Field
|
></Field
|
||||||
>
|
>
|
||||||
{#if suffix}
|
{#if suffix}
|
||||||
@@ -343,6 +343,8 @@
|
|||||||
? 'In progress'
|
? 'In progress'
|
||||||
: match.status === 'completed'
|
: match.status === 'completed'
|
||||||
? 'Completed'
|
? 'Completed'
|
||||||
|
: match.status === 'end-report-missing'
|
||||||
|
? 'End report missing'
|
||||||
: 'Interrupted'}
|
: 'Interrupted'}
|
||||||
{@const duration =
|
{@const duration =
|
||||||
match.status === 'active'
|
match.status === 'active'
|
||||||
@@ -383,6 +385,10 @@
|
|||||||
missing="Not recorded"
|
missing="Not recorded"
|
||||||
/></small
|
/></small
|
||||||
>{/if}
|
>{/if}
|
||||||
|
{#if match.missingReports !== null && match.missingReports > 0}<small
|
||||||
|
class="mobile-match-context"
|
||||||
|
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
|
||||||
|
>{/if}
|
||||||
{#if server === undefined}<div class="instance">
|
{#if server === undefined}<div class="instance">
|
||||||
<span title={match.server}>{matchInstanceLabel(match)}</span
|
<span title={match.server}>{matchInstanceLabel(match)}</span
|
||||||
>{#if match.server_deleted_at}<span>
|
>{#if match.server_deleted_at}<span>
|
||||||
@@ -395,12 +401,15 @@
|
|||||||
class="status"
|
class="status"
|
||||||
class:active={reporting}
|
class:active={reporting}
|
||||||
class:interrupted={match.status === 'interrupted' ||
|
class:interrupted={match.status === 'interrupted' ||
|
||||||
|
match.status === 'end-report-missing' ||
|
||||||
notReporting}>{status}</span
|
notReporting}>{status}</span
|
||||||
>{#if notReporting}<small
|
>{#if notReporting}<small
|
||||||
>Last report <RelativeTime
|
>Last report <RelativeTime
|
||||||
value={match.lastReportedAt}
|
value={match.lastReportedAt}
|
||||||
missing="Not recorded"
|
missing="Not recorded"
|
||||||
/></small
|
/></small
|
||||||
|
>{/if}{#if match.missingReports !== null && match.missingReports > 0}<small
|
||||||
|
>{`${match.missingReports} ${match.missingReports === 1 ? 'report missing' : 'reports missing'}`}</small
|
||||||
>{/if}</td
|
>{/if}</td
|
||||||
>
|
>
|
||||||
<td class="numeric">{match.uniquePlayers}</td>
|
<td class="numeric">{match.uniquePlayers}</td>
|
||||||
@@ -462,10 +471,12 @@
|
|||||||
Players counts unique players across all reports. A dash means the value is unavailable,
|
Players counts unique players across all reports. A dash means the value is unavailable,
|
||||||
not zero. In-progress matches show elapsed time since their recorded start and link to
|
not zero. In-progress matches show elapsed time since their recorded start and link to
|
||||||
the live server while reports are current. Otherwise, they show Not reporting with the
|
the live server while reports are current. Otherwise, they show Not reporting with the
|
||||||
last report time. Completed durations require a recorded start and completed ending.
|
last report time. Completed durations require a recorded start and completed ending. End
|
||||||
Round shows the final round for completed matches and the highest observed round for
|
report missing means a newer game was reported before this game's end report arrived.
|
||||||
unfinished matches. Open a completed match for exact timestamps and starting and ending
|
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
|
||||||
player counts.
|
reports arrive. Only completed games contribute to the leaderboard. Round shows the
|
||||||
|
final round for completed matches and the highest observed round for unfinished matches.
|
||||||
|
Open a completed match for exact timestamps and starting and ending player counts.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { jsonBody } from '$lib/server/request-body.js';
|
|||||||
import { isHttpError, json } from '@sveltejs/kit';
|
import { isHttpError, json } from '@sveltejs/kit';
|
||||||
import { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js';
|
import { parseGameEvent, type GameTelemetry } from '$lib/telemetry.js';
|
||||||
import { getServer } from '$lib/server/fleet/servers.js';
|
import { getServer } from '$lib/server/fleet/servers.js';
|
||||||
import { saveTelemetry } from '$lib/server/fleet/telemetry.js';
|
import { saveTelemetryWithOutcome } from '$lib/server/fleet/telemetry.js';
|
||||||
import { recordTelemetryRequest } from '$lib/server/fleet/telemetry-requests.js';
|
import { recordTelemetryRequest } from '$lib/server/fleet/telemetry-requests.js';
|
||||||
import { recordGameEvent } from '$lib/server/fleet/beta-tests.js';
|
import { recordGameEvent } from '$lib/server/fleet/beta-tests.js';
|
||||||
import { db } from '$lib/server/db/index.js';
|
import { db } from '$lib/server/db/index.js';
|
||||||
@@ -60,11 +60,13 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
const server = getServer(telemetry.server);
|
const server = getServer(telemetry.server);
|
||||||
if (!server || server.destroyed_at)
|
if (!server || server.destroyed_at)
|
||||||
return reject(404, 'Server is not registered or has been destroyed');
|
return reject(404, 'Server is not registered or has been destroyed');
|
||||||
db().transaction(() => {
|
const current = db().transaction(() => {
|
||||||
telemetry = saveTelemetry(telemetry);
|
const saved = saveTelemetryWithOutcome(telemetry);
|
||||||
recordGameEvent(telemetry);
|
telemetry = saved.telemetry;
|
||||||
|
if (saved.current) recordGameEvent(telemetry);
|
||||||
|
return saved.current;
|
||||||
})();
|
})();
|
||||||
emit({ type: 'server-telemetry', entries: [telemetry] });
|
if (current) emit({ type: 'server-telemetry', entries: [telemetry] });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Could not process telemetry', error);
|
console.error('Could not process telemetry', error);
|
||||||
log(
|
log(
|
||||||
|
|||||||
@@ -605,12 +605,13 @@
|
|||||||
appears here and on the player's profile, regardless of filters.
|
appears here and on the player's profile, regardless of filters.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Only completed games contribute to records. Games in progress and interrupted games
|
Only games with a received end report contribute to records. Games in progress,
|
||||||
stay out of totals and rankings. Players who left a completed game keep their
|
interrupted games, and games missing their end report stay out of totals and
|
||||||
recorded stats. Game, map, and starting squad-size filters choose which matches
|
rankings. Players who left a completed game keep their recorded stats. Game, map,
|
||||||
contribute; player search preserves ranks. Player totals count each GUID once. The
|
and starting squad-size filters choose which matches contribute; player search
|
||||||
top stats cover all recorded players in the selected game and map; player search and
|
preserves ranks. Player totals count each GUID once. The top stats cover all
|
||||||
ranking qualification do not change those totals.
|
recorded players in the selected game and map; player search and ranking
|
||||||
|
qualification do not change those totals.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Highest round includes mid-game joins. Total rounds counts round progression during
|
Highest round includes mid-game joins. Total rounds counts round progression during
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
{#if !profile.records}
|
{#if !profile.records}
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No qualifying match history yet"
|
title="No qualifying match history yet"
|
||||||
description="Stats appear after a recorded game ends, provided this player was observed at round 2 or later. Games in progress and interrupted games do not contribute."
|
description="Stats appear after a game's end report arrives, provided this player was observed at round 2 or later. Games without an end report do not contribute."
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<nav class="jump-links" aria-label="Player sections">
|
<nav class="jump-links" aria-label="Player sections">
|
||||||
@@ -394,10 +394,10 @@
|
|||||||
><span>Last seen <RelativeTime value={profile.lastSeen} /></span>
|
><span>Last seen <RelativeTime value={profile.lastSeen} /></span>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in
|
Lifetime stats combine completed games from Black Ops I and Black Ops II. Games without
|
||||||
progress and interrupted games do not contribute. Highest round includes mid-game joins;
|
an end report do not contribute. Highest round includes mid-game joins; total rounds
|
||||||
total rounds counts observed progression. Kills, headshots, revives and downs use each
|
counts observed progression. Kills, headshots, revives and downs use each match's
|
||||||
match's highest reported counters.
|
highest reported counters.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Highest reported score is the largest observed points balance, not total points earned.
|
Highest reported score is the largest observed points balance, not total points earned.
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { gameEvent, postTelemetry } from '../helpers/telemetry.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let received = 0;
|
||||||
|
const server = 't5_zombie_theater_1';
|
||||||
|
const otherServer = 't5_zombie_theater_2';
|
||||||
|
const player = (guid = 'ace', stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
|
||||||
|
guid,
|
||||||
|
name: guid,
|
||||||
|
kills: 0,
|
||||||
|
headshots: 0,
|
||||||
|
downs: 0,
|
||||||
|
revives: 0,
|
||||||
|
score: 0,
|
||||||
|
...stats
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
received = 0;
|
||||||
|
fixture = await newFixture([server, otherServer]);
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
});
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
async function report(
|
||||||
|
match: string,
|
||||||
|
seq: number | undefined,
|
||||||
|
event: GameEvent['event'],
|
||||||
|
round = 0,
|
||||||
|
players = [player()],
|
||||||
|
source = server
|
||||||
|
) {
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
const common = {
|
||||||
|
match,
|
||||||
|
...(seq === undefined ? {} : { seq }),
|
||||||
|
server: source,
|
||||||
|
map: 'zombie_theater',
|
||||||
|
receivedAt: new Date(Date.UTC(2026, 9, 10, 12, 0, received++)).toISOString()
|
||||||
|
};
|
||||||
|
return saveTelemetry(
|
||||||
|
event === 'game_start'
|
||||||
|
? { ...common, event, players: players.map(({ name, guid }) => ({ name, guid })) }
|
||||||
|
: { ...common, event, round, players }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function records() {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
return db().prepare('SELECT * FROM leaderboard_matches ORDER BY id').all() as Array<{
|
||||||
|
id: number;
|
||||||
|
server: string;
|
||||||
|
reporter_match_key: string;
|
||||||
|
status: string;
|
||||||
|
highest_round: number;
|
||||||
|
start_observed: number;
|
||||||
|
start_players: number | null;
|
||||||
|
last_event: string;
|
||||||
|
last_seq: number | null;
|
||||||
|
report_count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function participation(id: number) {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
return db()
|
||||||
|
.prepare('SELECT * FROM leaderboard_match_players WHERE match_id = ? ORDER BY guid')
|
||||||
|
.all(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function live() {
|
||||||
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
const state = getServer(server)!;
|
||||||
|
return {
|
||||||
|
telemetry: listTelemetry().find((entry) => entry.server === server),
|
||||||
|
timers: {
|
||||||
|
active: state.active_since,
|
||||||
|
inactive: state.inactive_since,
|
||||||
|
started: state.game_started_at,
|
||||||
|
ended: state.game_ended_at
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('deduplicates a retried sequence after intervening reports and a database reopen', async () => {
|
||||||
|
await report('one', 1, 'game_start');
|
||||||
|
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||||
|
await report('one', 3, 'heartbeat', 5, [player('ace', { kills: 30 })]);
|
||||||
|
const before = await records();
|
||||||
|
const beforePlayers = await participation(before[0].id);
|
||||||
|
const beforeLive = await live();
|
||||||
|
const { closeDb, db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const identities = db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all();
|
||||||
|
closeDb();
|
||||||
|
await report('one', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||||
|
expect(await records()).toEqual(before);
|
||||||
|
expect(await participation(before[0].id)).toEqual(beforePlayers);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
expect(db().prepare('SELECT * FROM leaderboard_players ORDER BY guid').all()).toEqual(
|
||||||
|
identities
|
||||||
|
);
|
||||||
|
expect(db().prepare('SELECT seq FROM leaderboard_match_reports ORDER BY seq').all()).toEqual([
|
||||||
|
{ seq: 1 },
|
||||||
|
{ seq: 2 },
|
||||||
|
{ seq: 3 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a lower sequence gap fill without rewinding rounds, presence, or live timers', async () => {
|
||||||
|
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
|
||||||
|
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||||
|
const before = (await records())[0];
|
||||||
|
const beforePlayers = await participation(before.id);
|
||||||
|
const beforeLive = await live();
|
||||||
|
await report('one', 2, 'round_change', 2, [player('departed', { kills: 2 })]);
|
||||||
|
const matches = await records();
|
||||||
|
expect(matches).toHaveLength(1);
|
||||||
|
expect(matches[0]).toMatchObject({
|
||||||
|
id: before.id,
|
||||||
|
status: 'active',
|
||||||
|
highest_round: 8,
|
||||||
|
last_event: 'heartbeat',
|
||||||
|
last_seq: 4,
|
||||||
|
report_count: 3
|
||||||
|
});
|
||||||
|
expect(await participation(before.id)).toEqual(beforePlayers);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
expect(
|
||||||
|
db().prepare('SELECT applied FROM leaderboard_match_reports WHERE seq = 2').get()
|
||||||
|
).toEqual({ applied: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens matches by reporter key when starts are missing and isolates late old-key heartbeats', async () => {
|
||||||
|
await report('old', 2, 'heartbeat', 8, [player('old-player', { kills: 80 })]);
|
||||||
|
const first = (await records())[0];
|
||||||
|
expect(first).toMatchObject({ reporter_match_key: 'old', start_observed: 0 });
|
||||||
|
await report('new', 2, 'heartbeat', 8, [player('new-player', { kills: 40 })]);
|
||||||
|
const before = await records();
|
||||||
|
expect(before).toHaveLength(2);
|
||||||
|
expect(before[0]).toMatchObject({ status: 'end-report-missing' });
|
||||||
|
expect(before[1]).toMatchObject({
|
||||||
|
reporter_match_key: 'new',
|
||||||
|
status: 'active',
|
||||||
|
start_observed: 0
|
||||||
|
});
|
||||||
|
const beforeLive = await live();
|
||||||
|
const beforePlayers = await participation(before[1].id);
|
||||||
|
const receipt = await report('old', 3, 'heartbeat', 9, []);
|
||||||
|
expect(receipt.timing?.matchId).toBe(first.id);
|
||||||
|
expect(await records()).toHaveLength(2);
|
||||||
|
expect((await records())[1]).toEqual(before[1]);
|
||||||
|
expect(await participation(before[1].id)).toEqual(beforePlayers);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies a delayed start only to start observation and the original roster', async () => {
|
||||||
|
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||||
|
const before = (await records())[0];
|
||||||
|
const beforeLive = await live();
|
||||||
|
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
|
||||||
|
expect(await records()).toHaveLength(1);
|
||||||
|
expect((await records())[0]).toMatchObject({
|
||||||
|
id: before.id,
|
||||||
|
start_observed: 1,
|
||||||
|
start_players: 2,
|
||||||
|
highest_round: 8,
|
||||||
|
last_event: 'heartbeat',
|
||||||
|
status: 'active',
|
||||||
|
last_seq: 4,
|
||||||
|
report_count: 2
|
||||||
|
});
|
||||||
|
expect(await participation(before.id)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
guid: 'ace',
|
||||||
|
highest_round: 8,
|
||||||
|
last_round: 7,
|
||||||
|
present: 1,
|
||||||
|
has_stats: 1,
|
||||||
|
kills: 80,
|
||||||
|
present_at_start: 1
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
guid: 'departed',
|
||||||
|
present: 0,
|
||||||
|
has_stats: 0,
|
||||||
|
kills: 0,
|
||||||
|
present_at_start: 1
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks a missing end when the next key starts and gives a late end credit only to its own match', async () => {
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
const { serverMatchHistory, getMatchDetail } =
|
||||||
|
await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
await report('old', 1, 'game_start');
|
||||||
|
await report('old', 2, 'round_change', 2, [player('ace', { kills: 10 })]);
|
||||||
|
await report('old', 3, 'heartbeat', 8, [player('ace', { kills: 70 })]);
|
||||||
|
await report('new', 1, 'game_start', 0, [player('other')]);
|
||||||
|
await report('new', 2, 'heartbeat', 3, [player('other', { kills: 15 })]);
|
||||||
|
const before = await records();
|
||||||
|
const beforeLive = await live();
|
||||||
|
expect(before[0].status).toBe('end-report-missing');
|
||||||
|
expect(serverMatchHistory().matches).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
id: before[0].id,
|
||||||
|
status: 'end-report-missing',
|
||||||
|
reporterMatchKey: 'old'
|
||||||
|
})
|
||||||
|
])
|
||||||
|
);
|
||||||
|
expect(getLeaderboard().entries).toEqual([]);
|
||||||
|
await report('old', 4, 'game_end', 9, [player('ace', { kills: 90 })]);
|
||||||
|
const after = await records();
|
||||||
|
expect(after).toHaveLength(2);
|
||||||
|
expect(after[0]).toMatchObject({ status: 'completed', highest_round: 9 });
|
||||||
|
expect(after[1]).toEqual(before[1]);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
expect(getMatchDetail(before[0].id)).toMatchObject({
|
||||||
|
status: 'completed',
|
||||||
|
reporterMatchKey: 'old',
|
||||||
|
missingReports: 0,
|
||||||
|
finalRound: 9
|
||||||
|
});
|
||||||
|
expect(getLeaderboard().entries).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', kills: 90, gamesCompleted: 1 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts missing unique sequences and reduces gaps when out-of-order reports arrive', async () => {
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
await report('one', 1, 'game_start');
|
||||||
|
await report('one', 5, 'heartbeat', 8);
|
||||||
|
expect(serverMatchHistory().matches[0]).toMatchObject({
|
||||||
|
reporterMatchKey: 'one',
|
||||||
|
missingReports: 3
|
||||||
|
});
|
||||||
|
await report('one', 3, 'heartbeat', 5);
|
||||||
|
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 });
|
||||||
|
await report('one', 3, 'heartbeat', 5);
|
||||||
|
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 2 });
|
||||||
|
await report('one', 2, 'round_change', 2);
|
||||||
|
await report('one', 4, 'heartbeat', 7);
|
||||||
|
expect(serverMatchHistory().matches[0]).toMatchObject({ missingReports: 0 });
|
||||||
|
expect((await records())[0]).toMatchObject({ last_seq: 5, report_count: 5 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not claim gap knowledge for a keyed reporter that omits sequence numbers', async () => {
|
||||||
|
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
await report('one', undefined, 'game_start');
|
||||||
|
await report('one', undefined, 'heartbeat', 4);
|
||||||
|
expect(serverMatchHistory().matches[0]).toMatchObject({
|
||||||
|
reporterMatchKey: 'one',
|
||||||
|
missingReports: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a lower-round key-only report without inflating observed rounds survived', async () => {
|
||||||
|
await report('one', undefined, 'game_start');
|
||||||
|
await report('one', undefined, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||||
|
const match = (await records())[0];
|
||||||
|
const beforePlayers = await participation(match.id);
|
||||||
|
const beforeLive = await live();
|
||||||
|
await report('one', undefined, 'heartbeat', 4, [player('ace', { kills: 40 })]);
|
||||||
|
expect(await participation(match.id)).toEqual(beforePlayers);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
await report('one', undefined, 'game_end', 9, [player('ace', { kills: 90 })]);
|
||||||
|
expect(await records()).toHaveLength(1);
|
||||||
|
expect(await participation(match.id)).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 })
|
||||||
|
]);
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
expect(getLeaderboard().entries).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', roundsSurvived: 8 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not recount completed rounds when a newer sequence reports a lower round', async () => {
|
||||||
|
await report('one', 1, 'game_start');
|
||||||
|
await report('one', 2, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||||
|
await report('one', 3, 'heartbeat', 4, [player('ace', { kills: 40 })]);
|
||||||
|
const match = (await records())[0];
|
||||||
|
expect(await participation(match.id)).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', rounds_survived: 7, last_round: 7 })
|
||||||
|
]);
|
||||||
|
await report('one', 4, 'game_end', 9, [player('ace', { kills: 90 })]);
|
||||||
|
expect(await records()).toHaveLength(1);
|
||||||
|
expect(await participation(match.id)).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', rounds_survived: 8, last_round: 8 })
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks both existing and new start-roster players after an initial round-zero heartbeat', async () => {
|
||||||
|
await report('one', 1, 'heartbeat', 0);
|
||||||
|
const match = (await records())[0];
|
||||||
|
expect(match.start_observed).toBe(0);
|
||||||
|
await report('one', 2, 'game_start', 0, [player('ace'), player('new-player')]);
|
||||||
|
expect(await records()).toHaveLength(1);
|
||||||
|
expect((await records())[0]).toMatchObject({
|
||||||
|
id: match.id,
|
||||||
|
start_observed: 1,
|
||||||
|
start_players: 2
|
||||||
|
});
|
||||||
|
expect(await participation(match.id)).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', present_at_start: 1, first_observed_round: 0 }),
|
||||||
|
expect.objectContaining({
|
||||||
|
guid: 'new-player',
|
||||||
|
present_at_start: 1,
|
||||||
|
first_observed_round: 0
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('scopes opaque reporter match keys and sequences to the reporting server', async () => {
|
||||||
|
await report('same-key', 1, 'game_start');
|
||||||
|
await report('same-key', 1, 'game_start', 0, [player('other')], otherServer);
|
||||||
|
await report('same-key', 2, 'game_end', 4, [player('ace', { kills: 10 })]);
|
||||||
|
await report('same-key', 2, 'heartbeat', 6, [player('other')], otherServer);
|
||||||
|
const matches = await records();
|
||||||
|
expect(matches).toHaveLength(2);
|
||||||
|
expect(matches[0]).toMatchObject({ server, status: 'completed', report_count: 2 });
|
||||||
|
expect(matches[1]).toMatchObject({
|
||||||
|
server: otherServer,
|
||||||
|
status: 'active',
|
||||||
|
highest_round: 6,
|
||||||
|
report_count: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a delayed lower-sequence end to complete a match without rewinding its latest roster', async () => {
|
||||||
|
await report('one', 1, 'game_start', 0, [player('ace'), player('departed')]);
|
||||||
|
await report('one', 4, 'heartbeat', 8, [player('ace', { kills: 80 })]);
|
||||||
|
const before = (await records())[0];
|
||||||
|
const beforeLive = await live();
|
||||||
|
await report('one', 3, 'game_end', 7, [
|
||||||
|
player('departed', { kills: 5 }),
|
||||||
|
player('final-new', { kills: 20 })
|
||||||
|
]);
|
||||||
|
expect(await records()).toHaveLength(1);
|
||||||
|
expect((await records())[0]).toMatchObject({
|
||||||
|
id: before.id,
|
||||||
|
highest_round: 8,
|
||||||
|
status: 'completed',
|
||||||
|
last_seq: 4,
|
||||||
|
report_count: 3
|
||||||
|
});
|
||||||
|
expect(await participation(before.id)).toEqual([
|
||||||
|
expect.objectContaining({ guid: 'ace', present: 1, highest_round: 8, kills: 80 }),
|
||||||
|
expect.objectContaining({ guid: 'departed', present: 0, completed: 1 }),
|
||||||
|
expect.objectContaining({
|
||||||
|
guid: 'final-new',
|
||||||
|
present: 0,
|
||||||
|
completed: 1,
|
||||||
|
has_stats: 1,
|
||||||
|
highest_round: 7,
|
||||||
|
rounds_survived: 0,
|
||||||
|
kills: 20
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
expect(getMatchDetail(before.id)).toMatchObject({
|
||||||
|
endPlayers: 2,
|
||||||
|
players: expect.arrayContaining([
|
||||||
|
expect.objectContaining({ guid: 'ace', presentAtEnd: false }),
|
||||||
|
expect.objectContaining({ guid: 'departed', presentAtEnd: true }),
|
||||||
|
expect.objectContaining({ guid: 'final-new', presentAtEnd: true })
|
||||||
|
])
|
||||||
|
});
|
||||||
|
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||||
|
expect(getLeaderboard().entries).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ guid: 'final-new', kills: 20, gamesCompleted: 1 })
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('acknowledges retries and old keys with their own match ID without broadcasting stale telemetry', async () => {
|
||||||
|
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||||
|
const listener = vi.fn();
|
||||||
|
const unsubscribe = subscribe(listener);
|
||||||
|
try {
|
||||||
|
const oldStart = { ...gameEvent('game_start'), match: 'old', seq: 1 };
|
||||||
|
const oldHeartbeat = { ...gameEvent('heartbeat', 5), match: 'old', seq: 2 };
|
||||||
|
const started = await (await postTelemetry(oldStart)).json();
|
||||||
|
await postTelemetry(oldHeartbeat);
|
||||||
|
await postTelemetry({ ...gameEvent('game_start'), match: 'new', seq: 1 });
|
||||||
|
await postTelemetry({ ...gameEvent('heartbeat', 3), match: 'new', seq: 2 });
|
||||||
|
const beforeLive = await live();
|
||||||
|
listener.mockClear();
|
||||||
|
for (const stale of [oldStart, oldHeartbeat, { ...oldHeartbeat, seq: 3 }]) {
|
||||||
|
const response = await postTelemetry(stale);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
telemetry: {
|
||||||
|
match: 'old',
|
||||||
|
seq: stale.seq,
|
||||||
|
timing: { matchId: started.telemetry.timing.matchId }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(await live()).toEqual(beforeLive);
|
||||||
|
} finally {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it } from 'vitest';
|
||||||
|
import { migrateLeaderboardReports } from '../../src/lib/server/db/leaderboard-reports.js';
|
||||||
|
import { runMigrations } from '../../src/lib/server/db/migration-runner.js';
|
||||||
|
import { migrations } from '../../src/lib/server/db/migrations.js';
|
||||||
|
|
||||||
|
let handle: Database.Database;
|
||||||
|
const migration = migrations.find(
|
||||||
|
(entry) => entry.name === 'durable leaderboard report identity and sequences'
|
||||||
|
)!;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
handle = new Database(':memory:');
|
||||||
|
handle.pragma('foreign_keys = ON');
|
||||||
|
runMigrations(handle, migrations.slice(0, migration.version - 1));
|
||||||
|
handle.exec(`
|
||||||
|
INSERT INTO leaderboard_matches
|
||||||
|
(id,server,server_deleted_at,game,map,map_key,map_name,started_at,last_received_at,
|
||||||
|
status,last_event,last_fingerprint,highest_round)
|
||||||
|
VALUES (1,'old-server','2026-10-01T15:00:00.000Z','t5','zombie_moon','zombie_moon','Moon',
|
||||||
|
'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z','completed','game_end','old',32);
|
||||||
|
INSERT INTO leaderboard_match_players
|
||||||
|
(match_id,guid,kills,first_seen_at,last_seen_at)
|
||||||
|
VALUES (1,'player',1000,'2026-10-01T12:00:00.000Z','2026-10-01T14:00:00.000Z');
|
||||||
|
INSERT INTO match_chat(match_id,message_key,occurrence,entry)
|
||||||
|
VALUES (1,'message',1,'{"message":"Saved chat"}');
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => handle.close());
|
||||||
|
|
||||||
|
function insertMatch(server: string, key: string | null): number {
|
||||||
|
return Number(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_matches
|
||||||
|
(server,game,map,map_key,map_name,started_at,last_received_at,last_event,
|
||||||
|
last_fingerprint,reporter_match_key)
|
||||||
|
VALUES (?,'t5','zombie_moon','zombie_moon','Moon',
|
||||||
|
'2026-10-12T12:00:00.000Z','2026-10-12T12:00:00.000Z','game_start','start',?)`
|
||||||
|
)
|
||||||
|
.run(server, key).lastInsertRowid
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('upgrades version 17 without inventing reporter identity or changing historical matches', () => {
|
||||||
|
const original = handle.prepare('SELECT * FROM leaderboard_matches').get();
|
||||||
|
const players = handle.prepare('SELECT * FROM leaderboard_match_players').all();
|
||||||
|
const chat = handle.prepare('SELECT * FROM match_chat').all();
|
||||||
|
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
|
||||||
|
expect(handle.pragma('user_version', { simple: true })).toBe(migrations.length);
|
||||||
|
expect(handle.prepare('SELECT * FROM leaderboard_matches').get()).toEqual({
|
||||||
|
...original!,
|
||||||
|
reporter_match_key: null,
|
||||||
|
last_seq: 0,
|
||||||
|
report_count: 0
|
||||||
|
});
|
||||||
|
expect(handle.prepare('SELECT * FROM leaderboard_match_players').all()).toEqual(players);
|
||||||
|
expect(handle.prepare('SELECT * FROM match_chat').all()).toEqual(chat);
|
||||||
|
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
|
||||||
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces reporter keys per server while preserving unkeyed legacy history', () => {
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
const first = insertMatch('server-a', 'cycle-1');
|
||||||
|
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
|
||||||
|
expect(() => insertMatch('server-b', 'cycle-1')).not.toThrow();
|
||||||
|
expect(() => insertMatch('server-a', 'cycle-2')).not.toThrow();
|
||||||
|
expect(() => insertMatch('server-a', null)).not.toThrow();
|
||||||
|
expect(() => insertMatch('server-a', null)).not.toThrow();
|
||||||
|
|
||||||
|
handle
|
||||||
|
.prepare('UPDATE leaderboard_matches SET server_deleted_at=? WHERE id=?')
|
||||||
|
.run('2026-10-12T13:00:00.000Z', first);
|
||||||
|
expect(() => insertMatch('server-a', 'cycle-1')).toThrow('UNIQUE constraint failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores received and applied sequence receipts once and removes them with retained matches', () => {
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
const id = insertMatch('server-a', 'cycle-1');
|
||||||
|
const receipt = handle.prepare(
|
||||||
|
'INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,?,?)'
|
||||||
|
);
|
||||||
|
receipt.run(id, 3, 1);
|
||||||
|
receipt.run(id, 1, 0);
|
||||||
|
expect(() => receipt.run(id, 3, 0)).toThrow('UNIQUE constraint failed');
|
||||||
|
for (const seq of [0, -1, 1.5])
|
||||||
|
expect(() => receipt.run(id, seq, 1)).toThrow('CHECK constraint failed');
|
||||||
|
expect(() => receipt.run(id, 2, 2)).toThrow('CHECK constraint failed');
|
||||||
|
expect(() => receipt.run(id + 100, 1, 1)).toThrow('FOREIGN KEY constraint failed');
|
||||||
|
expect(
|
||||||
|
handle.prepare('SELECT seq,applied FROM leaderboard_match_reports ORDER BY seq').all()
|
||||||
|
).toEqual([
|
||||||
|
{ seq: 1, applied: 0 },
|
||||||
|
{ seq: 3, applied: 1 }
|
||||||
|
]);
|
||||||
|
|
||||||
|
handle.prepare('DELETE FROM leaderboard_matches WHERE id=?').run(id);
|
||||||
|
expect(handle.prepare('SELECT * FROM leaderboard_match_reports').all()).toEqual([]);
|
||||||
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves reporter keys, counters and receipts when migration 18 is replayed', () => {
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
const id = insertMatch('server-a', 'cycle-1');
|
||||||
|
handle.prepare('UPDATE leaderboard_matches SET last_seq=3,report_count=2 WHERE id=?').run(id);
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,3,1)')
|
||||||
|
.run(id);
|
||||||
|
handle
|
||||||
|
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,0)')
|
||||||
|
.run(id);
|
||||||
|
const matches = handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all();
|
||||||
|
const reports = handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all();
|
||||||
|
const schemaVersion = handle.pragma('schema_version', { simple: true });
|
||||||
|
|
||||||
|
migrateLeaderboardReports(handle);
|
||||||
|
handle.pragma(`user_version = ${migration.version - 1}`);
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
|
||||||
|
expect(handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all()).toEqual(matches);
|
||||||
|
expect(handle.prepare('SELECT * FROM leaderboard_match_reports ORDER BY seq').all()).toEqual(
|
||||||
|
reports
|
||||||
|
);
|
||||||
|
expect(handle.pragma('schema_version', { simple: true })).toBe(schemaVersion);
|
||||||
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
});
|
||||||
@@ -104,6 +104,44 @@ it('keeps unknown durations last in either direction and retains historical and
|
|||||||
});
|
});
|
||||||
expect(data.matches.find((m) => m.id === 5)?.uniquePlayers).toBe(0);
|
expect(data.matches.find((m) => m.id === 5)?.uniquePlayers).toBe(0);
|
||||||
});
|
});
|
||||||
|
it('filters missing end reports and exposes sequence gaps without completed results', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE leaderboard_matches
|
||||||
|
SET status = 'end-report-missing', reporter_match_key = 'game-a', last_seq = 8, report_count = 6
|
||||||
|
WHERE id = 4`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
const { serverMatchHistory, getMatchDetail } =
|
||||||
|
await import('../../src/lib/server/fleet/match-history.js');
|
||||||
|
const data = serverMatchHistory(undefined, 25, query('status=end-report-missing'));
|
||||||
|
expect(data).toMatchObject({
|
||||||
|
total: 1,
|
||||||
|
matches: [
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
status: 'end-report-missing',
|
||||||
|
reporterMatchKey: 'game-a',
|
||||||
|
missingReports: 2,
|
||||||
|
observedRound: 5,
|
||||||
|
finalRound: null,
|
||||||
|
durationMs: null,
|
||||||
|
endedAt: null,
|
||||||
|
endPlayers: null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
expect(getMatchDetail(4)).toBeNull();
|
||||||
|
expect(serverMatchHistory().matches[0]).toMatchObject({
|
||||||
|
reporterMatchKey: null,
|
||||||
|
missingReports: null
|
||||||
|
});
|
||||||
|
db().prepare('UPDATE leaderboard_matches SET report_count = 8 WHERE id = 4').run();
|
||||||
|
expect(
|
||||||
|
serverMatchHistory(undefined, 25, query('status=end-report-missing')).matches[0]
|
||||||
|
).toMatchObject({ missingReports: 0 });
|
||||||
|
});
|
||||||
it('validates both API routes and loads the same filtered page on a direct page visit', async () => {
|
it('validates both API routes and loads the same filtered page on a direct page visit', async () => {
|
||||||
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
const { GET } = await import('../../src/routes/api/matches/+server.js');
|
||||||
const { GET: instanceGET } =
|
const { GET: instanceGET } =
|
||||||
|
|||||||
@@ -255,15 +255,19 @@ it('prunes metrics and diagnostics while leaving unlimited histories intact', as
|
|||||||
expect(a.db.prepare('SELECT count(*) AS n FROM metric_samples').get()).toEqual({ n: 0 });
|
expect(a.db.prepare('SELECT count(*) AS n FROM metric_samples').get()).toEqual({ n: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prunes closed matches and audit events without deleting active matches or protected audit records', async () => {
|
it('prunes closed matches and their receipts without deleting active matches or protected audit records', async () => {
|
||||||
const a = await api();
|
const a = await api();
|
||||||
for (const status of ['active', 'completed', 'interrupted'])
|
for (const status of ['active', 'completed', 'interrupted', 'end-report-missing']) {
|
||||||
a.db
|
const match = a.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
|
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,status,last_event,last_fingerprint)
|
||||||
VALUES('s','t5','kino','kino','Kino',?,?,?,'game_end','test')`
|
VALUES('s','t5','kino','kino','Kino',?,?,?,'game_end','test')`
|
||||||
)
|
)
|
||||||
.run(old, old, status);
|
.run(old, old, status);
|
||||||
|
a.db
|
||||||
|
.prepare('INSERT INTO leaderboard_match_reports(match_id,seq,applied) VALUES(?,1,1)')
|
||||||
|
.run(match.lastInsertRowid);
|
||||||
|
}
|
||||||
// Preview initializes the optional authentication schema.
|
// Preview initializes the optional authentication schema.
|
||||||
a.retentionPreview();
|
a.retentionPreview();
|
||||||
a.db
|
a.db
|
||||||
@@ -274,11 +278,14 @@ it('prunes closed matches and audit events without deleting active matches or pr
|
|||||||
.run(Date.parse(old));
|
.run(Date.parse(old));
|
||||||
a.setRetentionControl({ category: 'audit', id: '1', protected: true });
|
a.setRetentionControl({ category: 'audit', id: '1', protected: true });
|
||||||
a.saveFleetSettings({ ...a.fleetSettings(), matchKeepDays: 1, auditKeepDays: 1 });
|
a.saveFleetSettings({ ...a.fleetSettings(), matchKeepDays: 1, auditKeepDays: 1 });
|
||||||
expect(a.pruneHistory('match')).toBe(2);
|
expect(a.pruneHistory('match')).toBe(3);
|
||||||
expect(a.pruneHistory('audit')).toBe(1);
|
expect(a.pruneHistory('audit')).toBe(1);
|
||||||
expect(a.db.prepare('SELECT status FROM leaderboard_matches').all()).toEqual([
|
expect(a.db.prepare('SELECT status FROM leaderboard_matches').all()).toEqual([
|
||||||
{ status: 'active' }
|
{ status: 'active' }
|
||||||
]);
|
]);
|
||||||
|
expect(a.db.prepare('SELECT match_id,seq FROM leaderboard_match_reports').all()).toEqual([
|
||||||
|
{ match_id: 1, seq: 1 }
|
||||||
|
]);
|
||||||
expect(a.db.pragma('foreign_key_check')).toEqual([]);
|
expect(a.db.pragma('foreign_key_check')).toEqual([]);
|
||||||
});
|
});
|
||||||
it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => {
|
it('keeps active beta evidence while expiring closed snapshots and then closed history', async () => {
|
||||||
|
|||||||
@@ -18,6 +18,54 @@ afterEach(async () => fixture.cleanup());
|
|||||||
|
|
||||||
const payload = gameEvent('round_change', 17);
|
const payload = gameEvent('round_change', 17);
|
||||||
|
|
||||||
|
it.each(['game_start', 'round_change', 'heartbeat', 'game_end'] as const)(
|
||||||
|
'preserves optional reporter identity and sequence for %s',
|
||||||
|
async (event) => {
|
||||||
|
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
|
||||||
|
const report = gameEvent(event);
|
||||||
|
const match = ' opaque/Match:123 ';
|
||||||
|
expect(parseGameEvent({ ...report, match, seq: 7 })).toEqual({
|
||||||
|
...report,
|
||||||
|
match,
|
||||||
|
seq: 7
|
||||||
|
});
|
||||||
|
expect(parseGameEvent({ ...report, match })).toEqual({ ...report, match });
|
||||||
|
expect(parseGameEvent({ ...report, seq: 7 })).toEqual({ ...report, seq: 7 });
|
||||||
|
expect(parseGameEvent(report)).toEqual(report);
|
||||||
|
const response = await post({ ...report, match, seq: 7 });
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await response.json()).telemetry).toMatchObject({ match, seq: 7 });
|
||||||
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
expect(listTelemetry()[0]).toMatchObject({ match, seq: 7 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{ match: '' },
|
||||||
|
{ match: ' \t\n' },
|
||||||
|
{ match: 'x'.repeat(65) },
|
||||||
|
{ match: null },
|
||||||
|
{ match: 123 },
|
||||||
|
{ seq: 0 },
|
||||||
|
{ seq: -1 },
|
||||||
|
{ seq: 1.5 },
|
||||||
|
{ seq: Number.MAX_SAFE_INTEGER + 1 },
|
||||||
|
{ seq: null },
|
||||||
|
{ seq: '1' }
|
||||||
|
])('rejects malformed optional reporter metadata: %j', async (metadata) => {
|
||||||
|
const response = await post({ ...payload, ...metadata });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
expect(listTelemetry()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the maximum reporter key length and safe sequence number', async () => {
|
||||||
|
const { parseGameEvent } = await import('../../src/lib/telemetry.js');
|
||||||
|
expect(
|
||||||
|
parseGameEvent({ ...payload, match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER })
|
||||||
|
).toMatchObject({ match: 'x'.repeat(64), seq: Number.MAX_SAFE_INTEGER });
|
||||||
|
});
|
||||||
|
|
||||||
it.each(['game_end', 'round reset', 'map change'])(
|
it.each(['game_end', 'round reset', 'map change'])(
|
||||||
'drops the previous match timing when game_start is missed after %s',
|
'drops the previous match timing when game_start is missed after %s',
|
||||||
async (boundary) => {
|
async (boundary) => {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ const match: MatchDetail = {
|
|||||||
observedRound: 4,
|
observedRound: 4,
|
||||||
finalRound: 4,
|
finalRound: 4,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
|
reporterMatchKey: null,
|
||||||
|
missingReports: null,
|
||||||
startObserved: true,
|
startObserved: true,
|
||||||
startedAt: '2026-10-07T00:00:00Z',
|
startedAt: '2026-10-07T00:00:00Z',
|
||||||
lastReportedAt: '2026-10-07T00:01:00Z',
|
lastReportedAt: '2026-10-07T00:01:00Z',
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ const match: ServerMatch = {
|
|||||||
observedRound: 20,
|
observedRound: 20,
|
||||||
finalRound: 20,
|
finalRound: 20,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
|
reporterMatchKey: null,
|
||||||
|
missingReports: null,
|
||||||
startedAt: '2026-10-06T17:26:00Z',
|
startedAt: '2026-10-06T17:26:00Z',
|
||||||
lastReportedAt: '2026-10-06T19:59:59Z',
|
lastReportedAt: '2026-10-06T19:59:59Z',
|
||||||
endedAt: '2026-10-06T18:04:00Z',
|
endedAt: '2026-10-06T18:04:00Z',
|
||||||
@@ -154,6 +156,39 @@ it('labels unfinished rounds and missing durations without inventing final resul
|
|||||||
expect(body).toContain('>—</span>');
|
expect(body).toContain('>—</span>');
|
||||||
expect(body).toContain('>0</td>');
|
expect(body).toContain('>0</td>');
|
||||||
});
|
});
|
||||||
|
it('labels and filters missing end reports and shows sequence gaps on desktop and mobile', () => {
|
||||||
|
scope('?matchStatus=end-report-missing');
|
||||||
|
const data = report();
|
||||||
|
data.matches = [
|
||||||
|
{
|
||||||
|
...match,
|
||||||
|
status: 'end-report-missing',
|
||||||
|
reporterMatchKey: 'game-a',
|
||||||
|
missingReports: 2,
|
||||||
|
finalRound: null,
|
||||||
|
durationMs: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
const body = render(MatchHistory, { props: { initial: data } }).body.replaceAll(
|
||||||
|
/<!--.*?-->/g,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
for (const suffix of ['', '-mobile']) {
|
||||||
|
const select = body.match(
|
||||||
|
new RegExp(`<select\\b[^>]*id="match-status${suffix}"[^>]*>([\\s\\S]*?)</select>`)
|
||||||
|
)?.[1];
|
||||||
|
expect(select).toContain(
|
||||||
|
'<option value="end-report-missing" selected="">End report missing</option>'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const rows = body.slice(body.indexOf('<tbody'), body.indexOf('</tbody>'));
|
||||||
|
expect(rows.match(/End report missing/g)).toHaveLength(2);
|
||||||
|
expect(rows.match(/2 reports missing/g)).toHaveLength(2);
|
||||||
|
expect(rows).toContain('Highest observed');
|
||||||
|
expect(rows).not.toContain('<a');
|
||||||
|
expect(rows).not.toContain('In progress');
|
||||||
|
expect(rows).not.toContain('38m');
|
||||||
|
});
|
||||||
it.each([
|
it.each([
|
||||||
'2026-10-06T19:58:29Z',
|
'2026-10-06T19:58:29Z',
|
||||||
'2026-10-05T18:00:00Z',
|
'2026-10-05T18:00:00Z',
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ it('renders a known player without qualifying history with identity and unavaila
|
|||||||
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
const text = body.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
|
||||||
expect(text).toContain('New player');
|
expect(text).toContain('New player');
|
||||||
expect(text).toContain('No qualifying match history yet');
|
expect(text).toContain('No qualifying match history yet');
|
||||||
expect(text).toContain('Stats appear after a recorded game ends');
|
expect(text).toContain("Stats appear after a game's end report arrives");
|
||||||
expect(text).toContain('observed at round 2 or later');
|
expect(text).toContain('observed at round 2 or later');
|
||||||
expect(text).toContain('Lifetime best round —');
|
expect(text).toContain('Lifetime best round —');
|
||||||
expect(text).toContain('Best game kills —');
|
expect(text).toContain('Best game kills —');
|
||||||
|
|||||||
Reference in New Issue
Block a user