Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
221c4c10cf | ||
|
|
f10345ae79 | ||
|
|
eac9ad4fbc | ||
|
|
47ebe83106 | ||
|
|
c7d71c63ba | ||
|
|
13963cba1c | ||
|
|
0cb48c435e | ||
|
|
a3cd3ee585 |
@@ -95,6 +95,9 @@ Maintenance decides when work is needed and verifies the result. Provisioning
|
||||
executes every lifecycle operation using the shared safety checks and queues.
|
||||
Health recovery uses one restart followed by one rebuild if needed; drift repair
|
||||
requests a rebuild with a linked Campaign. **Rebuild deletes all instance files.**
|
||||
Maintenance skips observations and new automatic work on servers with active
|
||||
Provisioning operations or retained recovery capacity. Other servers continue
|
||||
normally, and existing maintenance still tracks its own operation's outcome.
|
||||
|
||||
Plutonium updates are applied by Plutainer's startup updater
|
||||
(`PLUTAINER_AUTO_UPDATE` must be enabled). Check versions and choose **Update on
|
||||
@@ -109,6 +112,7 @@ Optional daily restarts are independent of updates. Schedules use the saved
|
||||
timezone, follow daylight saving changes, and coalesce missed days into one run.
|
||||
Intentionally stopped servers are excluded. Existing schedules, pending jobs,
|
||||
operation IDs, and history survive the move from Settings and Watchdog.
|
||||
Servers owned by Provisioning are skipped for that scheduled occurrence.
|
||||
|
||||
**Pause all maintenance** holds new and queued maintenance while checks and
|
||||
submitted recovery continue. Each capability retains its own policy. Automatic
|
||||
@@ -371,7 +375,15 @@ and how to interpret missing or rejected reports.
|
||||
The **Leaderboard** records persistent player totals and map records from these
|
||||
same reports, including highest round, cumulative kills, rounds survived,
|
||||
headshots, revives, downs, and games played. See [Leaderboard](docs/leaderboard.md)
|
||||
for counting rules, history import, and the read API.
|
||||
for counting rules, history import, and the read API. Only completed games count;
|
||||
in-progress and interrupted games remain in Match History without contributing
|
||||
to player rankings or profile totals.
|
||||
|
||||
In **Match History**, select **View match** on a completed match to open its
|
||||
shareable results page. It shows the map, server, final round, timing, and every
|
||||
recorded player's stats, including players absent from the final report. Completed
|
||||
matches also link from server history and player profiles. Results survive server
|
||||
deletion; missing starts and unrecorded stats remain explicitly unknown.
|
||||
|
||||
## Running tests
|
||||
|
||||
|
||||
+122
-13
@@ -5,9 +5,11 @@ with one entry per GUID combining Black Ops I (T5) and Black Ops II (T6). Game
|
||||
and map filters limit which matches contribute to each player's statistics and
|
||||
rank. Choose a ranking, search a name or GUID, and share the page URL with its
|
||||
filters intact. Results refresh while the page is visible.
|
||||
Matches only appear in server/global history and leaderboard map options once
|
||||
they reach round 2. Player records only contribute to leaderboards, summaries,
|
||||
profiles and player history once that player is observed at round 2 or later.
|
||||
Matches only appear in server/global history once they reach round 2.
|
||||
Leaderboards, summaries, map options, profiles and player history include only
|
||||
completed matches with a received `game_end`. Active and interrupted matches do
|
||||
not contribute. A player must also be observed at round 2 or later; players who
|
||||
left before a completed game's end retain their recorded stats.
|
||||
This also excludes existing round 1 records. Early telemetry is retained so
|
||||
qualifying games keep their start time, roster and cumulative counters.
|
||||
|
||||
@@ -21,15 +23,51 @@ records; map records and match history identify their game. Match history shows
|
||||
individual counters, server, date and completion credit in pages of 25 matches
|
||||
across both games. Map names link back to that map's leaderboard in its game.
|
||||
Use **Refresh stats** to load the latest reports. Ratios with a zero denominator
|
||||
display a dash. Profiles always cover all recorded maps across both games.
|
||||
display a dash. Lifetime statistics and per-map records always cover all recorded
|
||||
maps across both games.
|
||||
|
||||
The leaderboard uses the existing three telemetry events; no reporter change is
|
||||
Opening a player from the leaderboard preserves the originating game, map,
|
||||
metric, search and page in the **Leaderboard** return link. The profile's
|
||||
**Selected leaderboard** controls initialize from that game/map/metric and show
|
||||
the player's rank within that competition, independent of the original search.
|
||||
These controls also filter match history; lifetime totals stay unchanged.
|
||||
History pagination retains the selection and return link. Map links preserve
|
||||
the selected ranking metric. A player without qualifying matches in a selection
|
||||
can still view their lifetime profile and choose another scope.
|
||||
|
||||
On phones, per-map records and match history use compact cards with the map,
|
||||
game, highest round and kills up front. Expand **All stats and record runs**
|
||||
or **More match stats** for the remaining counters and source evidence. Desktop
|
||||
profiles retain the complete tables; both views use the same history filters
|
||||
and pagination. History dates say **First observed**, since a missing start
|
||||
report can make the first recorded observation later than the actual game start.
|
||||
|
||||
Highest-round, best-game-kill and highest-score records link to the completed
|
||||
match that earned them on the leaderboard and in lifetime/per-map profile stats.
|
||||
Each source shows its map, game, completion time, server, and whether the match
|
||||
start was recorded. **Run details** shows when the player was first observed:
|
||||
at the recorded start, at a reported round, or unknown for older stored records.
|
||||
Presence is not proof of continuous survival. Equal personal bests link to the
|
||||
earliest recorded completion, then the lowest match ID when times tie.
|
||||
|
||||
Completed matches link to their own results page at `/matches/[id]` from global
|
||||
Match History, server history, and player profiles. The page shows the recorded
|
||||
map and server, final round, start/end times, duration when the start was observed,
|
||||
player counts, and the full saved roster. Players missing from the final report
|
||||
retain their earlier counters; players with no stats display dashes. Counters and
|
||||
score are the highest reported values within the match, not necessarily its final
|
||||
scoreboard. Active and interrupted matches remain listed without results links.
|
||||
Match pages remain available after the original server is removed.
|
||||
|
||||
The leaderboard uses the existing telemetry events; no reporter change is
|
||||
required. The leaderboard and profiles identify a player by **GUID**, displaying
|
||||
the latest reported name across both games, regardless of game or map filters.
|
||||
Different names for the same GUID do not split their leaderboard entry or
|
||||
profile; identical names with different GUIDs remain separate. Stored player
|
||||
records remain keyed by **game + GUID** for telemetry ingestion; the combined
|
||||
leaderboard and profiles require no storage migration. GUIDs stay strings.
|
||||
records remain keyed by **game + GUID** for telemetry ingestion; combining their
|
||||
identity requires no rekeying. The record-source upgrade automatically adds
|
||||
nullable participation-start fields; existing rows keep an unknown start instead
|
||||
of inferring one. GUIDs stay strings.
|
||||
|
||||
## Recorded statistics
|
||||
|
||||
@@ -38,7 +76,7 @@ leaderboard and profiles require no storage migration. GUIDs stay strings.
|
||||
| Highest round | Highest round reported with that player present, including players who joined mid-game. |
|
||||
| Total kills, headshots, revives, downs | Sum of each player's highest reported counter in each game. Round snapshots are cumulative, so they are never added together within a game. |
|
||||
| Total rounds | Round progression during observed participation, added across games. A player observed at game start and then round 8 receives seven rounds. A player first seen at round 8 gets a highest-round record of 8, but zero total rounds; reaching round 9 then adds one. |
|
||||
| Games played | Games in which a stats report at round 2 or later contains the player. Start-only and round 1 participation do not count. |
|
||||
| Games played | Completed games in which a stats report at round 2 or later contains the player, even if they left before the end. Start-only and round 1 participation do not count. |
|
||||
| Games completed | Games whose `game_end` roster contains the player. Disconnected players retain earlier stats without receiving completion credit. |
|
||||
| Best game kills | Highest kill count recorded in any one game. |
|
||||
| Highest reported score | Highest observed score balance. Spending points can lower the score, so this is not cumulative points earned. |
|
||||
@@ -50,8 +88,23 @@ across both games when unfiltered. Summary cards describe the same selection,
|
||||
regardless of the search text. Player counts include each qualifying GUID once,
|
||||
including when they have records in both games. Summary rounds are
|
||||
player-rounds: four players progressing from game start to round 8 contribute 28.
|
||||
Highest-round records always use the round reached, including the final round
|
||||
and players who joined mid-game.
|
||||
Highest-round records always use the round reached in a completed match,
|
||||
including the final round and players who joined mid-game.
|
||||
|
||||
Efficiency rankings are also available for **Headshots / kills**, **Kills per
|
||||
down**, **Average kills**, **Average rounds**, and **Completion rate**. Each
|
||||
requires at least five completed games in the selected game/map scope. Headshot
|
||||
rate also requires 100 kills; kills per down requires five downs. The board shows
|
||||
the numerator, denominator and game count beside each ratio. Ratios are computed
|
||||
from aggregate counters, not an average of individual match percentages.
|
||||
Completion rate is the share of those ended games whose final report contained
|
||||
the player; it is not a win rate. Active and interrupted games do not enter any
|
||||
denominator. Zero-denominator ratios remain unknown and are not ranked.
|
||||
|
||||
Qualification is applied before ranking, search and pagination. Summary cards
|
||||
still describe all players in the game/map selection, including those below the
|
||||
threshold. Profile metrics show their values and whether the player qualifies
|
||||
for the combined lifetime board; a scoped board can have a smaller sample.
|
||||
|
||||
Round totals require the player to appear in consecutive observations. If a
|
||||
report omits them, their return starts a new participation interval and the gap
|
||||
@@ -101,8 +154,64 @@ key returned in `maps`), `metric` (default `highestRound`), `q` (name/GUID searc
|
||||
available `maps`, the matching player `total`, pagination values, and `summary`.
|
||||
Each entry represents one GUID and combines its qualifying matches within the
|
||||
game/map filters. Entries have no game field; map options retain their game
|
||||
identity. Both `total` and `summary.players` count unique GUIDs; only `total` is
|
||||
limited by the player search.
|
||||
identity. Both `total` and `summary.players` count unique GUIDs; `total` applies
|
||||
the selected metric's qualification rules and player search, while the summary
|
||||
covers all completed qualifying participation in the game/map scope.
|
||||
|
||||
Metric keys are `highestRound`, `kills`, `roundsSurvived`, `headshots`, `revives`,
|
||||
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, and `bestScore`.
|
||||
`downs`, `gamesPlayed`, `gamesCompleted`, `bestKills`, `bestScore`, `headshotRate`,
|
||||
`killsPerDown`, `averageKills`, `averageRounds`, and `completionRate`.
|
||||
Efficiency values in entries are unrounded ratios (for example, `0.5` means 50%)
|
||||
or `null` when their denominator is zero. UI percentages multiply by 100.
|
||||
Entries also include `records`, keyed by `highestRound`, `bestKills`, and
|
||||
`bestScore`, with the completed match ID, value, map/game/server, timing and
|
||||
observed participation behind each personal best.
|
||||
|
||||
## Query performance
|
||||
|
||||
Leaderboard reads materialize the selected completed participation once, reuse
|
||||
one player aggregate for ranking and summary totals, and reuse the ranked/search
|
||||
result for pagination and its matching count. Empty and out-of-range pages still
|
||||
return accurate totals. Record evidence is fetched only for players on the page.
|
||||
Exact player-rank reads share the ranking rules but skip unused fleet summaries
|
||||
and map lists. No response cache delays visibility of a newly completed game.
|
||||
|
||||
Run the deterministic, isolated benchmark with:
|
||||
|
||||
```sh
|
||||
node scripts/benchmark-leaderboard.mjs --scenario all --baseline-ref eac9ad4 --runs 3 --json /tmp/leaderboard-benchmark.json
|
||||
```
|
||||
|
||||
The script creates temporary SQLite databases, loads the actual query modules,
|
||||
checks complete-response parity against the selected Git revision, and reports
|
||||
timings, source hashes and query plans. It never opens the application database.
|
||||
The small fixture has 2,000 matches and 8,000 participation rows; the large fixture
|
||||
has 50,000 matches and 200,000 participation rows. Both include completed,
|
||||
active, interrupted and nonqualifying records.
|
||||
|
||||
On the local Intel i5-8279U / Node 25.9.0 / SQLite 3.49.2 test environment, three
|
||||
measured runs after warmup gave these median query times on the large fixture:
|
||||
|
||||
| Request | Before | After |
|
||||
| --- | ---: | ---: |
|
||||
| Highest round, all games | 1,717 ms | 829 ms |
|
||||
| Total kills, all games | 1,555 ms | 858 ms |
|
||||
| One game | 899 ms | 491 ms |
|
||||
| One map | 345 ms | 224 ms |
|
||||
| Player search | 1,515 ms | 757 ms |
|
||||
| Deep page | 1,421 ms | 752 ms |
|
||||
| Headshot percentage | 1,444 ms | 760 ms |
|
||||
| Exact player rank | 1,524 ms | 554 ms |
|
||||
|
||||
The small fixture's unfiltered request fell from 41 to 30 ms. All 150 additional
|
||||
small-fixture parity cases and 22 timed small/large request cases matched the
|
||||
baseline's complete responses, including ratios and record evidence. Timings
|
||||
include SQL preparation and evidence lookup, exclude HTTP/browser rendering, and
|
||||
use a warm filesystem cache with alternating implementation order. These are
|
||||
local synthetic measurements, not production latency guarantees. Queries still
|
||||
run synchronously, so larger histories remain a capacity consideration.
|
||||
|
||||
Validation for this change passed 1,019 unit tests, Svelte checking with no errors
|
||||
or warnings, and a production build. Isolated Firefox walkthroughs at 1440px and
|
||||
390px checked completed-only totals, record links, efficiency qualification,
|
||||
scoped ranks/history, preserved navigation, empty scopes and mobile expansion.
|
||||
|
||||
+10
-1
@@ -41,6 +41,13 @@ future runs but does not cancel requests it already created.
|
||||
All capabilities respect intentionally stopped servers. Start them explicitly
|
||||
through Provisioning before expecting maintenance to act on them.
|
||||
|
||||
While Provisioning owns a server, Maintenance skips new health/drift observations,
|
||||
version probes, automatic updates, and scheduled restart requests for that server.
|
||||
Queued, waiting, running, and recovering operations count, including failed
|
||||
operations that retain recovery capacity. Other servers continue normally. Existing
|
||||
maintenance tracks its own operation to completion; revision verification waits
|
||||
until any later operation releases the server.
|
||||
|
||||
## Plutonium updates
|
||||
|
||||
The published revision comes from the official production updater manifest.
|
||||
@@ -78,7 +85,9 @@ that same revision; an operator can request an update explicitly.
|
||||
|
||||
Daily restarts are independent of update availability. At the saved queue time,
|
||||
Maintenance requests a restart for each server whose desired state is running.
|
||||
These requests have no revision requirement. Busy servers can restart later.
|
||||
These requests have no revision requirement. Servers owned by Provisioning are
|
||||
skipped for that occurrence; the schedule advances normally. Requests already
|
||||
queued can wait for players, campaign work, or beta work to finish.
|
||||
|
||||
Saving a schedule uses the selected timezone and follows daylight saving changes.
|
||||
Skipped times run at the first valid minute after the gap; repeated times run once
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
|
||||
"seed:fixture": "tsx scripts/seed-fixture.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Benchmark actual leaderboard modules against deterministic, isolated SQLite histories.
|
||||
* node scripts/benchmark-leaderboard.mjs --scenario all --baseline-ref <commit> --runs 3
|
||||
* Uses a TypeScript module loader to replace only db(); it never opens the application DB.
|
||||
*/
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { createRequire } from 'node:module';
|
||||
import { arch, cpus, platform, tmpdir } from 'node:os';
|
||||
import { dirname, join, relative, resolve } from 'node:path';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const repo = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const require = createRequire(join(repo, 'package.json'));
|
||||
const Database = require('better-sqlite3');
|
||||
const ts = require('typescript');
|
||||
const arguments_ = process.argv.slice(2);
|
||||
const options = { scenario: 'small', runs: 3 };
|
||||
const allowed = new Set(['--scenario', '--runs', '--baseline-ref', '--json']);
|
||||
for (let index = 0; index < arguments_.length; index += 2) {
|
||||
const key = arguments_[index];
|
||||
const value = arguments_[index + 1];
|
||||
if (!allowed.has(key) || !value) throw new Error('Usage: --scenario small|large|all --runs N --baseline-ref COMMIT --json PATH');
|
||||
options[key.slice(2)] = value;
|
||||
}
|
||||
options.runs = Number(options.runs);
|
||||
if (!Number.isInteger(options.runs) || options.runs < 1 || options.runs > 20) throw new Error('--runs must be an integer from 1 to 20');
|
||||
if (!['small', 'large', 'all'].includes(options.scenario)) throw new Error('--scenario must be small, large, or all');
|
||||
const baseline = options['baseline-ref']
|
||||
? execFileSync('git', ['rev-parse', '--verify', '--end-of-options', `${options['baseline-ref']}^{commit}`], { cwd: repo, encoding: 'utf8' }).trim()
|
||||
: null;
|
||||
const definitions = [
|
||||
{ name: 'small', matches: 2000, players: 500 },
|
||||
{ name: 'large', matches: 50000, players: 10000 }
|
||||
].filter(item => options.scenario === 'all' || item.name === options.scenario);
|
||||
const temporary = mkdtempSync(join(tmpdir(), 'cherry-leaderboard-benchmark-'));
|
||||
const source = (file, ref) => ref
|
||||
? execFileSync('git', ['show', `${ref}:${file}`], { cwd: repo, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 })
|
||||
: readFileSync(join(repo, file), 'utf8');
|
||||
const percentile = (values, fraction) => [...values].sort((a, b) => a - b)[Math.ceil(values.length * fraction) - 1];
|
||||
|
||||
function seed(handle, definition) {
|
||||
handle.exec(source('src/lib/server/db/schema.sql'));
|
||||
const match = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, ended_at, last_received_at,
|
||||
status, start_observed, start_players, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 4, ?, ?, ?)`);
|
||||
const identity = handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?) ON CONFLICT(game, guid) DO UPDATE SET
|
||||
name = CASE WHEN excluded.last_seen_at >= last_seen_at THEN excluded.name ELSE name END,
|
||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||
const participation = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, rounds_survived, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
let state = 20260918;
|
||||
const random = () => { state = (Math.imul(state, 1664525) + 1013904223) >>> 0; return state / 4294967296; };
|
||||
handle.transaction(() => {
|
||||
for (let id = 1; id <= definition.matches; id++) {
|
||||
const mapIndex = Math.floor(random() * 12);
|
||||
const game = mapIndex < 6 ? 't5' : 't6';
|
||||
const map = `map_${mapIndex}`;
|
||||
const status = id % 20 === 0 ? 'active' : id % 20 === 1 ? 'interrupted' : 'completed';
|
||||
const started = new Date(Date.UTC(2023, 0, 1) + Math.floor(id / definition.matches * 3 * 365 * 86400000)).toISOString();
|
||||
const ended = new Date(Date.parse(started) + (20 + Math.floor(random() * 130)) * 60000).toISOString();
|
||||
const round = id % 101 === 0 ? 1 : 2 + Math.floor(random() * 60);
|
||||
match.run(id, `server_${id % 24}`, game, map, `${game}:${map}`, `Map ${mapIndex}`,
|
||||
started, status === 'completed' ? ended : null, ended, status, round,
|
||||
status === 'completed' ? 'game_end' : 'heartbeat', `event_${id}`);
|
||||
const roster = new Set();
|
||||
while (roster.size < 4) roster.add(Math.floor(random() ** 2 * definition.players));
|
||||
// Reserve one completed participation for an explicitly ineligible player.
|
||||
if (id === definition.matches - 2) roster.delete([...roster][3]);
|
||||
for (const player of roster) {
|
||||
const guid = String(10000000000000000n + BigInt(player));
|
||||
const name = `${game === 't6' ? 'Alias' : 'Player'} ${String(player).padStart(5, '0')}`;
|
||||
identity.run(game, guid, name, started, ended);
|
||||
const kills = Math.floor(round * 18 + random() * 800);
|
||||
const presentAtStart = random() > 0.1;
|
||||
const firstRound = presentAtStart ? 0 : Math.max(1, Math.floor(random() * round));
|
||||
participation.run(id, guid, round, Math.max(0, round - Math.max(1, firstRound)), kills,
|
||||
Math.floor(kills * random()), Math.floor(random() * 8), Math.floor(random() * 12),
|
||||
Math.floor(random() * 100000), Math.max(0, round - 1), Number(status === 'active'),
|
||||
Number(random() > 0.01), Number(status === 'completed' && random() > 0.15),
|
||||
started, ended, firstRound, Number(presentAtStart));
|
||||
}
|
||||
if (id === definition.matches - 2) {
|
||||
identity.run(game, 'benchmark-low-sample', 'Low sample', started, ended);
|
||||
participation.run(id, 'benchmark-low-sample', 2, 1, 20, 5, 0, 0, 1000,
|
||||
1, 0, 1, 1, started, ended, 0, 1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
handle.pragma('wal_checkpoint(TRUNCATE)');
|
||||
}
|
||||
|
||||
function loadImplementation(handle, ref) {
|
||||
const modules = new Map();
|
||||
const hashes = {};
|
||||
let recorded = null;
|
||||
const database = { prepare(sql) {
|
||||
const statement = handle.prepare(sql);
|
||||
return Object.fromEntries(['all', 'get'].map(method => [method, (...parameters) => {
|
||||
const start = performance.now();
|
||||
const result = statement[method](...parameters);
|
||||
if (recorded) recorded.push({ sql, parameters, milliseconds: performance.now() - start });
|
||||
return result;
|
||||
}]));
|
||||
} };
|
||||
function load(file) {
|
||||
if (file === 'src/lib/server/db/index.ts') return { db: () => database };
|
||||
if (modules.has(file)) return modules.get(file);
|
||||
const text = source(file, ref);
|
||||
hashes[file] = createHash('sha256').update(text).digest('hex');
|
||||
const compiled = ts.transpileModule(text, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText;
|
||||
const exports = {};
|
||||
modules.set(file, exports);
|
||||
const localRequire = specifier => {
|
||||
if (!specifier.startsWith('.')) return require(specifier);
|
||||
const target = relative(repo, resolve(repo, dirname(file), specifier)).replace(/\.js$/, '.ts');
|
||||
if (!target.startsWith('src/lib/')) throw new Error(`Unexpected benchmark dependency: ${target}`);
|
||||
return load(target);
|
||||
};
|
||||
new Function('require', 'exports', compiled)(localRequire, exports);
|
||||
return exports;
|
||||
}
|
||||
const api = load('src/lib/server/fleet/leaderboard.ts');
|
||||
return {
|
||||
name: ref ? 'baseline' : 'current', ref, hashes,
|
||||
metrics: load('src/lib/leaderboard.ts').leaderboardMetrics.map(metric => metric.key),
|
||||
run(test) {
|
||||
return test.guid ? api.getPlayerLeaderboardEntry(test.guid, test.options) : api.getLeaderboard(test.options);
|
||||
},
|
||||
measure(test) {
|
||||
recorded = [];
|
||||
const start = performance.now();
|
||||
const result = this.run(test);
|
||||
const milliseconds = performance.now() - start;
|
||||
const statements = recorded;
|
||||
recorded = null;
|
||||
return { result, milliseconds, statements };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const report = {
|
||||
environment: { node: process.version, sqlite: null, platform: platform(), arch: arch(), cpu: cpus()[0]?.model },
|
||||
baselineRef: baseline, runs: options.runs, seed: 20260918,
|
||||
notes: 'Synthetic file-backed SQLite, one warmup per case, rotating execution order, warm OS cache. Includes SQL preparation and source-record lookup; excludes HTTP and browser rendering. Shared local CPU; timings are not production latency. Schema and indexes are current for both implementations.',
|
||||
scenarios: []
|
||||
};
|
||||
try {
|
||||
for (const definition of definitions) {
|
||||
const handle = new Database(join(temporary, `${definition.name}.sqlite`));
|
||||
try {
|
||||
report.environment.sqlite = handle.prepare('SELECT sqlite_version() AS version').get().version;
|
||||
seed(handle, definition);
|
||||
const implementations = [...(baseline ? [loadImplementation(handle, baseline)] : []), loadImplementation(handle)];
|
||||
const cases = [
|
||||
{ name: 'unfiltered', options: {} },
|
||||
{ name: 'kills', options: { metric: 'kills' } },
|
||||
{ name: 'game', options: { game: 't5', metric: 'kills' } },
|
||||
{ name: 'map', options: { game: 't6', map: 't6:map_8', metric: 'kills' } },
|
||||
{ name: 'search', options: { q: '00042', metric: 'kills' } },
|
||||
{ name: 'deep-page', options: { metric: 'kills', offset: Math.floor(definition.players / 2) } },
|
||||
{ name: 'efficiency', options: { metric: 'headshotRate' } },
|
||||
{ name: 'exact-player', guid: '10000000000000042', options: { metric: 'kills' } },
|
||||
{ name: 'empty-search', options: { q: 'No such player exists' } },
|
||||
{ name: 'past-end', options: { offset: 999999 } },
|
||||
{ name: 'empty-map', options: { map: 't5:missing' } }
|
||||
];
|
||||
const counts = handle.prepare(`SELECT COUNT(*) AS participationRows,
|
||||
COUNT(DISTINCT guid) AS uniquePlayers FROM leaderboard_match_players`).get();
|
||||
const scenario = { ...definition, ...counts, maps: 12, servers: 24, completedShare: 0.9, results: [] };
|
||||
report.scenarios.push(scenario);
|
||||
// Broad parity is separate from timings so every ratio and edge case is
|
||||
// checked without multiplying the larger fixture's timed request matrix.
|
||||
if (baseline && definition.name === 'small') {
|
||||
const scopes = [{}, { game: 't5' }, { game: 't6', map: 't6:map_8' }, { q: '00042' },
|
||||
{ q: 'No such player exists' }, { offset: 999999 }, { map: 't5:missing' }];
|
||||
let checked = 0;
|
||||
for (const metric of implementations[1].metrics) {
|
||||
for (const scope of scopes) {
|
||||
const test = { options: { ...scope, metric } };
|
||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${JSON.stringify(scope)}: full-response parity`);
|
||||
checked++;
|
||||
}
|
||||
for (const guid of ['10000000000000042', 'benchmark-low-sample', 'benchmark-missing']) {
|
||||
const test = { guid, options: { metric } };
|
||||
assert.deepStrictEqual(implementations[1].run(test), implementations[0].run(test),
|
||||
`${definition.name}/${metric}/${guid}: exact-player parity`);
|
||||
checked++;
|
||||
}
|
||||
}
|
||||
scenario.parityCases = checked;
|
||||
console.log(JSON.stringify({ scenario: definition.name, parityCases: checked, parity: true }));
|
||||
}
|
||||
for (const test of cases) {
|
||||
const expected = implementations[0].run(test);
|
||||
for (const implementation of implementations.slice(1)) {
|
||||
assert.deepStrictEqual(implementation.run(test), expected, `${definition.name}/${test.name}: baseline parity`);
|
||||
}
|
||||
const samples = Object.fromEntries(implementations.map(item => [item.name, []]));
|
||||
const latestStatements = {};
|
||||
for (let index = 0; index < options.runs; index++) {
|
||||
const rotation = index % implementations.length;
|
||||
for (const implementation of [...implementations.slice(rotation), ...implementations.slice(0, rotation)]) {
|
||||
const measured = implementation.measure(test);
|
||||
samples[implementation.name].push(measured.milliseconds);
|
||||
latestStatements[implementation.name] = measured.statements;
|
||||
}
|
||||
}
|
||||
const result = { ...test, parity: baseline ? true : null, implementations: implementations.map(implementation => ({
|
||||
name: implementation.name, sourceHashes: implementation.hashes,
|
||||
medianMs: percentile(samples[implementation.name], 0.5),
|
||||
p95Ms: percentile(samples[implementation.name], 0.95), samplesMs: samples[implementation.name],
|
||||
statements: latestStatements[implementation.name].map(statement => ({ ...statement,
|
||||
plan: handle.prepare(`EXPLAIN QUERY PLAN ${statement.sql}`).all(...statement.parameters)
|
||||
}))
|
||||
})) };
|
||||
scenario.results.push(result);
|
||||
console.log(JSON.stringify({ scenario: definition.name, case: test.name, parity: result.parity,
|
||||
medianMs: Object.fromEntries(result.implementations.map(item => [item.name, Math.round(item.medianMs)])) }));
|
||||
}
|
||||
} finally { handle.close(); }
|
||||
}
|
||||
if (options.json) {
|
||||
const target = resolve(options.json);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, JSON.stringify(report, null, 2));
|
||||
console.log(`Detailed results: ${target}`);
|
||||
}
|
||||
} finally { rmSync(temporary, { recursive: true, force: true }); }
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { PlayerStats } from './leaderboard.js';
|
||||
|
||||
export const EFFICIENCY_MIN_GAMES = 5;
|
||||
export const EFFICIENCY_MIN_KILLS = 100;
|
||||
export const EFFICIENCY_MIN_DOWNS = 5;
|
||||
|
||||
export const efficiencyMetrics = [
|
||||
{ key: 'headshotRate', label: 'Headshots / kills', format: 'percent',
|
||||
description: `Total headshots divided by total kills in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_KILLS} kills.` },
|
||||
{ key: 'killsPerDown', label: 'Kills per down', format: 'number',
|
||||
description: `Total kills divided by total downs in ended games. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games and ${EFFICIENCY_MIN_DOWNS} downs.` },
|
||||
{ key: 'averageKills', label: 'Average kills', format: 'number',
|
||||
description: `Total kills divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` },
|
||||
{ key: 'averageRounds', label: 'Average rounds', format: 'number',
|
||||
description: `Total observed rounds divided by games played, including games the player left before they ended. Ranking requires at least ${EFFICIENCY_MIN_GAMES} ended games.` },
|
||||
{ key: 'completionRate', label: 'Completion rate', format: 'percent',
|
||||
description: `Games with the player in the final report divided by games played. Only ended games count, including games the player left early. Ranking requires at least ${EFFICIENCY_MIN_GAMES} games.` }
|
||||
] as const;
|
||||
|
||||
export type EfficiencyMetric = typeof efficiencyMetrics[number]['key'];
|
||||
const metrics = new Set<string>(efficiencyMetrics.map(metric => metric.key));
|
||||
|
||||
export function isEfficiencyMetric(metric: string): metric is EfficiencyMetric {
|
||||
return metrics.has(metric);
|
||||
}
|
||||
|
||||
function sample(stats: PlayerStats, metric: EfficiencyMetric): [number, number] {
|
||||
switch (metric) {
|
||||
case 'headshotRate': return [stats.headshots, stats.kills];
|
||||
case 'killsPerDown': return [stats.kills, stats.downs];
|
||||
case 'averageKills': return [stats.kills, stats.gamesPlayed];
|
||||
case 'averageRounds': return [stats.roundsSurvived, stats.gamesPlayed];
|
||||
case 'completionRate': return [stats.gamesCompleted, stats.gamesPlayed];
|
||||
}
|
||||
}
|
||||
|
||||
/** Calculate from aggregate counters; qualification never changes a recorded value. */
|
||||
export function efficiencyValue(stats: PlayerStats, metric: EfficiencyMetric): number | null {
|
||||
const [numerator, denominator] = sample(stats, metric);
|
||||
return denominator === 0 ? null : numerator / denominator;
|
||||
}
|
||||
|
||||
export function efficiencyEligible(stats: PlayerStats, metric: EfficiencyMetric): boolean {
|
||||
if (stats.gamesPlayed < EFFICIENCY_MIN_GAMES) return false;
|
||||
if (metric === 'headshotRate') return stats.kills >= EFFICIENCY_MIN_KILLS;
|
||||
if (metric === 'killsPerDown') return stats.downs >= EFFICIENCY_MIN_DOWNS;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function efficiencyRequirement(metric: EfficiencyMetric): string {
|
||||
const games = `At least ${EFFICIENCY_MIN_GAMES} ended games`;
|
||||
if (metric === 'headshotRate') return `${games} and ${EFFICIENCY_MIN_KILLS} kills`;
|
||||
if (metric === 'killsPerDown') return `${games} and ${EFFICIENCY_MIN_DOWNS} downs`;
|
||||
return games;
|
||||
}
|
||||
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const count = (value: number, singular: string, plural = `${singular}s`) =>
|
||||
`${number.format(value)} ${value === 1 ? singular : plural}`;
|
||||
|
||||
export function efficiencySample(stats: PlayerStats, metric: EfficiencyMetric): string {
|
||||
switch (metric) {
|
||||
case 'headshotRate': return `${count(stats.headshots, 'headshot')} / ${count(stats.kills, 'kill')}`;
|
||||
case 'killsPerDown': return `${count(stats.kills, 'kill')} / ${count(stats.downs, 'down')}`;
|
||||
case 'averageKills': return `${count(stats.kills, 'kill')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||
case 'averageRounds': return `${count(stats.roundsSurvived, 'observed round')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||
case 'completionRate': return `${count(stats.gamesCompleted, 'finish', 'finishes')} / ${count(stats.gamesPlayed, 'ended game')}`;
|
||||
}
|
||||
}
|
||||
|
||||
const decimal = new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 });
|
||||
export function formatEfficiencyValue(value: number | null, metric: EfficiencyMetric): string {
|
||||
if (value === null) return '—';
|
||||
const percent = metric === 'headshotRate' || metric === 'completionRate';
|
||||
return `${decimal.format(value * (percent ? 100 : 1))}${percent ? '%' : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { leaderboardMetrics } from './leaderboard.js';
|
||||
|
||||
const origin = 'https://electric-cherry.invalid';
|
||||
const contextKeys = ['game', 'map', 'metric', 'q', 'offset'] as const;
|
||||
|
||||
/** Only retain a local leaderboard destination and its supported view state. */
|
||||
export function leaderboardReturnHref(value: string | null | undefined): string {
|
||||
if (!value?.startsWith('/leaderboard')) return '/leaderboard';
|
||||
try {
|
||||
const url = new URL(value, origin);
|
||||
if (url.origin !== origin || url.pathname !== '/leaderboard') return '/leaderboard';
|
||||
const params = new URLSearchParams();
|
||||
for (const key of contextKeys) {
|
||||
const entry = url.searchParams.get(key);
|
||||
if (entry) params.set(key, entry);
|
||||
}
|
||||
return `/leaderboard${params.size ? `?${params}` : ''}`;
|
||||
} catch { return '/leaderboard'; }
|
||||
}
|
||||
|
||||
export function playerContextHref(guid: string, source: string): string {
|
||||
const returnTo = leaderboardReturnHref(source);
|
||||
const original = new URL(returnTo, origin).searchParams;
|
||||
const params = new URLSearchParams({ returnTo });
|
||||
// History pagination is independent of the originating leaderboard page.
|
||||
const game = original.get('game');
|
||||
const map = original.get('map');
|
||||
const metric = original.get('metric');
|
||||
// Match the leaderboard's displayed defaults for older or hand-edited URLs.
|
||||
if (game === 't5' || game === 't6') params.set('game', game);
|
||||
if (map && map.length <= 1024) params.set('map', map);
|
||||
if (metric && leaderboardMetrics.some(item => item.key === metric)) params.set('metric', metric);
|
||||
return `/leaderboard/players/${encodeURIComponent(guid)}?${params}`;
|
||||
}
|
||||
|
||||
export function profileViewHref(current: URL, changes: Record<string, string>): string {
|
||||
const params = new URLSearchParams(current.search);
|
||||
params.delete('offset');
|
||||
for (const [key, value] of Object.entries(changes)) {
|
||||
if (value) params.set(key, value);
|
||||
else params.delete(key);
|
||||
}
|
||||
return `${current.pathname}${params.size ? `?${params}` : ''}`;
|
||||
}
|
||||
+26
-3
@@ -1,4 +1,6 @@
|
||||
export const leaderboardMetrics = [
|
||||
import { efficiencyMetrics, type EfficiencyMetric } from './leaderboard-efficiency.js';
|
||||
|
||||
export const playerStatMetrics = [
|
||||
{ key: 'highestRound', label: 'Highest round' },
|
||||
{ key: 'kills', label: 'Total kills' },
|
||||
{ key: 'roundsSurvived', label: 'Total rounds' },
|
||||
@@ -11,14 +13,32 @@ export const leaderboardMetrics = [
|
||||
{ key: 'bestScore', label: 'Highest reported score' }
|
||||
] as const;
|
||||
|
||||
export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const;
|
||||
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key'];
|
||||
export type PlayerStatMetric = typeof playerStatMetrics[number]['key'];
|
||||
|
||||
export type PlayerStats = Record<LeaderboardMetric, number>;
|
||||
export type PlayerStats = Record<PlayerStatMetric, number>;
|
||||
export type PersonalBestMetric = 'highestRound' | 'bestKills' | 'bestScore';
|
||||
export interface RecordEvidence {
|
||||
matchId: number;
|
||||
game: string;
|
||||
mapKey: string;
|
||||
mapName: string;
|
||||
server: string;
|
||||
startedAt: string;
|
||||
endedAt: string | null;
|
||||
startObserved: boolean;
|
||||
presentAtStart: boolean | null;
|
||||
firstObservedRound: number | null;
|
||||
value: number;
|
||||
}
|
||||
export type PersonalBestRecords = Record<PersonalBestMetric, RecordEvidence>;
|
||||
export interface PlayerMapRecord extends PlayerStats {
|
||||
game: string;
|
||||
key: string;
|
||||
name: string;
|
||||
lastSeen: string;
|
||||
records: PersonalBestRecords;
|
||||
}
|
||||
export interface PlayerMatch {
|
||||
id: number;
|
||||
@@ -44,8 +64,10 @@ export interface PlayerProfile {
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
stats: PlayerStats;
|
||||
records: PersonalBestRecords;
|
||||
maps: PlayerMapRecord[];
|
||||
matches: PlayerMatch[];
|
||||
matchesTotal: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
@@ -54,7 +76,7 @@ export function playerProfileHref(guid: string): string {
|
||||
return `/leaderboard/players/${encodeURIComponent(guid)}`;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntry {
|
||||
export interface LeaderboardEntry extends Record<EfficiencyMetric, number | null> {
|
||||
rank: number;
|
||||
guid: string;
|
||||
name: string;
|
||||
@@ -69,6 +91,7 @@ export interface LeaderboardEntry {
|
||||
bestKills: number;
|
||||
bestScore: number;
|
||||
lastSeen: string;
|
||||
records: PersonalBestRecords;
|
||||
}
|
||||
|
||||
export interface LeaderboardMap {
|
||||
|
||||
@@ -15,3 +15,30 @@ export interface ServerMatch {
|
||||
durationMs: number | null;
|
||||
finalRound: number | null;
|
||||
}
|
||||
|
||||
export interface MatchPlayer {
|
||||
guid: string;
|
||||
name: string;
|
||||
hasStats: boolean;
|
||||
presentAtEnd: boolean;
|
||||
canViewProfile: boolean;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
// Combat counters and score are the highest reported values; rounds survived
|
||||
// counts observed progression. These are not a final scoreboard snapshot.
|
||||
// A player seen only at game_start has no observed gameplay stats.
|
||||
highestRound: number | null;
|
||||
roundsSurvived: number | null;
|
||||
kills: number | null;
|
||||
headshots: number | null;
|
||||
downs: number | null;
|
||||
revives: number | null;
|
||||
bestScore: number | null;
|
||||
}
|
||||
|
||||
export interface MatchDetail extends ServerMatch {
|
||||
status: 'completed';
|
||||
game: string;
|
||||
startObserved: boolean;
|
||||
players: MatchPlayer[];
|
||||
}
|
||||
|
||||
@@ -360,6 +360,10 @@ export function db(): Database.Database {
|
||||
assertSnapshotSchemeSafe(handle);
|
||||
migrateGameTelemetry(handle);
|
||||
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]);
|
||||
migrateTable(handle, 'leaderboard_match_players', [
|
||||
{ name: 'first_observed_round', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN first_observed_round INTEGER' },
|
||||
{ name: 'present_at_start', ddl: 'ALTER TABLE leaderboard_match_players ADD COLUMN present_at_start INTEGER' }
|
||||
]);
|
||||
backfillLeaderboard(handle);
|
||||
initializeFleetSettings(handle);
|
||||
} catch (err) {
|
||||
|
||||
@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS leaderboard_match_players (
|
||||
completed INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
first_observed_round INTEGER, -- null for legacy participation
|
||||
present_at_start INTEGER, -- null for legacy participation; one only when first seen in game_start
|
||||
PRIMARY KEY (match_id, guid)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id);
|
||||
|
||||
@@ -91,8 +91,8 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
|
||||
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score,
|
||||
last_round, present, has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?)
|
||||
last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(match_id, guid) DO UPDATE SET
|
||||
highest_round = MAX(highest_round, excluded.highest_round),
|
||||
rounds_survived = rounds_survived + excluded.rounds_survived,
|
||||
@@ -113,7 +113,7 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
|
||||
const stats = 'kills' in player ? player : null;
|
||||
savePlayer.run(match.id, player.guid, round, survived,
|
||||
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0,
|
||||
completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt);
|
||||
completedRounds, Number(!isStart), Number(report.event === 'game_end'), report.receivedAt, report.receivedAt, round, Number(isStart));
|
||||
}
|
||||
handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
|
||||
last_received_at = ?, last_event = ?, last_fingerprint = ?,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { PersonalBestMetric, PersonalBestRecords, RecordEvidence } from '../../leaderboard.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
|
||||
interface RecordRequest extends Record<PersonalBestMetric, number> {
|
||||
guid: string;
|
||||
game?: string;
|
||||
map?: string;
|
||||
}
|
||||
|
||||
/** Fetch evidence only for requested players and scopes, never rank the whole fleet's history. */
|
||||
export function getPersonalBestRecords(handle: Database.Database, requests: RecordRequest[]): PersonalBestRecords[] {
|
||||
const result: PersonalBestRecords[] = [];
|
||||
// Page requests are at most 100 players; chunk unusually large profile map collections too.
|
||||
for (let offset = 0; offset < requests.length; offset += 100) {
|
||||
const batch = requests.slice(offset, offset + 100);
|
||||
const parameters = batch.flatMap((request, index) => [index, request.guid, request.game ?? null,
|
||||
request.map ?? null, request.highestRound, request.bestKills, request.bestScore]);
|
||||
const rows = handle.prepare(`WITH requested (requestId, guid, game, map, highestRound, bestKills, bestScore) AS (
|
||||
VALUES ${batch.map(() => '(?, ?, ?, ?, ?, ?, ?)').join(', ')}
|
||||
), candidates AS (
|
||||
SELECT r.requestId, m.id AS matchId, m.game, m.map_key AS mapKey, m.map_name AS mapName,
|
||||
m.server, m.started_at AS startedAt, m.ended_at AS endedAt, m.start_observed AS startObserved,
|
||||
mp.first_observed_round AS firstObservedRound, mp.present_at_start AS presentAtStart,
|
||||
COALESCE(m.ended_at, m.last_received_at) AS completionOrder,
|
||||
mp.highest_round AS highestRound, mp.kills AS bestKills, mp.best_score AS bestScore,
|
||||
r.highestRound AS targetRound, r.bestKills AS targetKills, r.bestScore AS targetScore
|
||||
FROM requested r JOIN leaderboard_match_players mp ON mp.guid = r.guid
|
||||
JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.has_stats = 1
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}
|
||||
AND (r.game IS NULL OR m.game = r.game) AND (r.map IS NULL OR m.map_key = r.map)
|
||||
AND (mp.highest_round = r.highestRound OR mp.kills = r.bestKills OR mp.best_score = r.bestScore)
|
||||
), valuesByMetric AS (
|
||||
SELECT *, 'highestRound' AS metric, highestRound AS value FROM candidates WHERE highestRound = targetRound
|
||||
UNION ALL SELECT *, 'bestKills', bestKills FROM candidates WHERE bestKills = targetKills
|
||||
UNION ALL SELECT *, 'bestScore', bestScore FROM candidates WHERE bestScore = targetScore
|
||||
), ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY requestId, metric ORDER BY completionOrder, matchId) AS choice
|
||||
FROM valuesByMetric
|
||||
) SELECT requestId, metric, value, matchId, game, mapKey, mapName, server, startedAt, endedAt,
|
||||
startObserved, firstObservedRound, presentAtStart FROM ranked WHERE choice = 1`)
|
||||
.all(...parameters) as Array<Omit<RecordEvidence, 'startObserved' | 'presentAtStart'> & {
|
||||
requestId: number; metric: PersonalBestMetric; startObserved: number; presentAtStart: number | null;
|
||||
}>;
|
||||
const records = batch.map(() => ({} as PersonalBestRecords));
|
||||
for (const { requestId, metric, startObserved, presentAtStart, ...record } of rows) {
|
||||
records[requestId][metric] = { ...record, startObserved: Boolean(startObserved),
|
||||
presentAtStart: presentAtStart === null ? null : Boolean(presentAtStart) };
|
||||
}
|
||||
result.push(...records);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,10 +1,27 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import { EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS, EFFICIENCY_MIN_DOWNS,
|
||||
isEfficiencyMetric } from '../../leaderboard-efficiency.js';
|
||||
import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
|
||||
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
|
||||
|
||||
const metrics = new Set<string>(leaderboardMetrics.map(metric => metric.key));
|
||||
export const MAX_LEADERBOARD_OFFSET = 1_000_000;
|
||||
type RankedEntry = Omit<LeaderboardEntry, 'records'>;
|
||||
|
||||
interface LeaderboardRow extends Omit<RankedEntry, 'guid'> {
|
||||
// A left join keeps metadata available when the requested page has no players.
|
||||
guid: string | null;
|
||||
matchingTotal: number;
|
||||
summaryPlayers: number;
|
||||
summaryMatches: number;
|
||||
summaryKills: number;
|
||||
summaryRounds: number;
|
||||
summaryHighestRound: number;
|
||||
summaryFirstTrackedAt: string | null;
|
||||
summaryUpdatedAt: string | null;
|
||||
}
|
||||
|
||||
export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
|
||||
return metrics.has(value);
|
||||
@@ -12,50 +29,113 @@ export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
|
||||
|
||||
/** Rank each GUID once within the selected matches; player search keeps their actual rank. */
|
||||
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse {
|
||||
return readLeaderboard(options);
|
||||
}
|
||||
|
||||
/** Select the exact player after ranking everyone eligible in the requested scope. */
|
||||
export function getPlayerLeaderboardEntry(guid: string,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map' | 'metric'> = {}): LeaderboardEntry | null {
|
||||
return readLeaderboard({ game: options.game, map: options.map, metric: options.metric, limit: 1 }, guid);
|
||||
}
|
||||
|
||||
function addRecordSources(handle: ReturnType<typeof db>, entries: RankedEntry[], options: LeaderboardOptions): LeaderboardEntry[] {
|
||||
const records = getPersonalBestRecords(handle, entries.map(entry => ({
|
||||
...entry, game: options.game || undefined, map: options.map || undefined
|
||||
})));
|
||||
return entries.map((entry, index) => ({ ...entry, records: records[index] }));
|
||||
}
|
||||
|
||||
function readLeaderboard(options: LeaderboardOptions): LeaderboardResponse;
|
||||
function readLeaderboard(options: LeaderboardOptions, exactGuid: string): LeaderboardEntry | null;
|
||||
function readLeaderboard(options: LeaderboardOptions, exactGuid?: string): LeaderboardResponse | LeaderboardEntry | null {
|
||||
const handle = db();
|
||||
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound';
|
||||
const limit = Number.isSafeInteger(options.limit) ? Math.min(100, Math.max(1, options.limit!)) : 50;
|
||||
const offset = Number.isSafeInteger(options.offset) ? Math.min(MAX_LEADERBOARD_OFFSET, Math.max(0, options.offset!)) : 0;
|
||||
const conditions = ["m.game IN ('t5', 't6')", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
|
||||
const conditions = ["m.game IN ('t5', 't6')", "m.status = 'completed'", 'mp.has_stats = 1', `m.highest_round >= ${MIN_RECORDED_ROUND}`,
|
||||
`mp.highest_round >= ${MIN_RECORDED_ROUND}`];
|
||||
const parameters: string[] = [];
|
||||
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
|
||||
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
|
||||
const scoped = `FROM leaderboard_match_players mp
|
||||
JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||
WHERE ${conditions.join(' AND ')}`;
|
||||
const aggregated = `WITH totals AS (
|
||||
SELECT mp.guid, MAX(mp.highest_round) AS highestRound,
|
||||
SUM(mp.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived,
|
||||
SUM(mp.headshots) AS headshots, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs,
|
||||
COUNT(*) AS gamesPlayed, SUM(mp.completed) AS gamesCompleted,
|
||||
MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore, MAX(mp.last_seen_at) AS lastSeen
|
||||
${scoped} GROUP BY mp.guid
|
||||
const qualification = isEfficiencyMetric(metric)
|
||||
? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
|
||||
+ (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '')
|
||||
+ (metric === 'killsPerDown' ? ` AND measures.downs >= ${EFFICIENCY_MIN_DOWNS}` : '')
|
||||
: '';
|
||||
// A board reuses observations for player totals and distinct match metadata.
|
||||
// Exact-player requests only consume the totals, so they can stream the join once.
|
||||
const observationStorage = exactGuid === undefined ? 'MATERIALIZED' : 'NOT MATERIALIZED';
|
||||
const aggregated = `WITH observations AS ${observationStorage} (
|
||||
SELECT mp.match_id, mp.guid, mp.highest_round, mp.kills, mp.rounds_survived,
|
||||
mp.headshots, mp.revives, mp.downs, mp.completed, mp.best_score, mp.last_seen_at,
|
||||
m.started_at AS matchStartedAt, m.last_received_at AS matchUpdatedAt
|
||||
FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||
WHERE ${conditions.join(' AND ')}
|
||||
), totals AS MATERIALIZED (
|
||||
SELECT guid, MAX(highest_round) AS highestRound,
|
||||
SUM(kills) AS kills, SUM(rounds_survived) AS roundsSurvived,
|
||||
SUM(headshots) AS headshots, SUM(revives) AS revives, SUM(downs) AS downs,
|
||||
COUNT(*) AS gamesPlayed, SUM(completed) AS gamesCompleted,
|
||||
MAX(kills) AS bestKills, MAX(best_score) AS bestScore, MAX(last_seen_at) AS lastSeen
|
||||
FROM observations GROUP BY guid
|
||||
), measures AS (
|
||||
SELECT totals.*, 1.0 * headshots / NULLIF(kills, 0) AS headshotRate,
|
||||
1.0 * kills / NULLIF(downs, 0) AS killsPerDown,
|
||||
1.0 * kills / NULLIF(gamesPlayed, 0) AS averageKills,
|
||||
1.0 * roundsSurvived / NULLIF(gamesPlayed, 0) AS averageRounds,
|
||||
1.0 * gamesCompleted / NULLIF(gamesPlayed, 0) AS completionRate FROM totals
|
||||
), identities AS (
|
||||
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS recency
|
||||
FROM leaderboard_players WHERE game IN ('t5', 't6')
|
||||
), ranked AS (
|
||||
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank
|
||||
FROM totals JOIN identities p ON p.guid = totals.guid AND p.recency = 1
|
||||
SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank
|
||||
FROM measures JOIN identities p ON p.guid = measures.guid AND p.recency = 1 ${qualification}
|
||||
)`;
|
||||
if (exactGuid !== undefined) {
|
||||
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked WHERE guid = ?`)
|
||||
.all(...parameters, exactGuid) as RankedEntry[];
|
||||
return addRecordSources(handle, entries, options)[0] ?? null;
|
||||
}
|
||||
|
||||
const query = options.q?.trim() ?? '';
|
||||
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
|
||||
const searchedParameters = query ? [...parameters, query, query] : parameters;
|
||||
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search}
|
||||
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`)
|
||||
.all(...searchedParameters, limit, offset) as LeaderboardEntry[];
|
||||
const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`)
|
||||
.get(...searchedParameters) as { total: number };
|
||||
const summary = handle.prepare(`SELECT
|
||||
COUNT(DISTINCT mp.guid) AS players,
|
||||
COUNT(DISTINCT m.id) AS matches, COALESCE(SUM(mp.kills), 0) AS kills,
|
||||
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived,
|
||||
COALESCE(MAX(mp.highest_round), 0) AS highestRound,
|
||||
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt
|
||||
${scoped}`).get(...parameters) as LeaderboardResponse['summary'];
|
||||
// Reuse one ranked set for the page and matching count. Summaries deliberately
|
||||
// cover the full game/map scope, including players below an efficiency threshold.
|
||||
const rows = handle.prepare(`${aggregated}, searched AS MATERIALIZED (
|
||||
SELECT * FROM ranked ${search}
|
||||
), page AS (
|
||||
SELECT * FROM searched ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?
|
||||
), playerSummary AS (
|
||||
SELECT COUNT(*) AS players, COALESCE(SUM(kills), 0) AS kills,
|
||||
COALESCE(SUM(roundsSurvived), 0) AS roundsSurvived,
|
||||
COALESCE(MAX(highestRound), 0) AS highestRound FROM totals
|
||||
), matchSummary AS (
|
||||
SELECT COUNT(DISTINCT match_id) AS matches, MIN(matchStartedAt) AS firstTrackedAt,
|
||||
MAX(matchUpdatedAt) AS updatedAt FROM observations
|
||||
), metadata AS (
|
||||
SELECT (SELECT COUNT(*) FROM searched) AS total, playerSummary.*, matchSummary.*
|
||||
FROM playerSummary CROSS JOIN matchSummary
|
||||
) SELECT page.*, metadata.total AS matchingTotal, metadata.players AS summaryPlayers,
|
||||
metadata.matches AS summaryMatches, metadata.kills AS summaryKills,
|
||||
metadata.roundsSurvived AS summaryRounds, metadata.highestRound AS summaryHighestRound,
|
||||
metadata.firstTrackedAt AS summaryFirstTrackedAt, metadata.updatedAt AS summaryUpdatedAt
|
||||
FROM metadata LEFT JOIN page ON 1
|
||||
ORDER BY page.${metric} DESC, page.name COLLATE NOCASE, page.name, page.guid`)
|
||||
.all(...parameters, ...(query ? [query, query] : []), limit, offset) as LeaderboardRow[];
|
||||
const rankedEntries = rows.filter((row): row is LeaderboardRow & { guid: string } => row.guid !== null)
|
||||
.map(({ matchingTotal, summaryPlayers, summaryMatches, summaryKills, summaryRounds,
|
||||
summaryHighestRound, summaryFirstTrackedAt, summaryUpdatedAt, ...entry }) => entry);
|
||||
const entries = addRecordSources(handle, rankedEntries, options);
|
||||
const metadata = rows[0];
|
||||
const total = metadata.matchingTotal;
|
||||
const summary: LeaderboardResponse['summary'] = {
|
||||
players: metadata.summaryPlayers, matches: metadata.summaryMatches, kills: metadata.summaryKills,
|
||||
roundsSurvived: metadata.summaryRounds, highestRound: metadata.summaryHighestRound,
|
||||
firstTrackedAt: metadata.summaryFirstTrackedAt, updatedAt: metadata.summaryUpdatedAt
|
||||
};
|
||||
// Keep qualifying map variants available, even after their server is removed.
|
||||
const maps = handle.prepare(`SELECT map_key AS key, game, map, map_name AS name
|
||||
FROM leaderboard_matches WHERE game IN ('t5', 't6')
|
||||
FROM leaderboard_matches WHERE game IN ('t5', 't6') AND status = 'completed'
|
||||
AND highest_round >= ${MIN_RECORDED_ROUND}${options.game ? ' AND game = ?' : ''}
|
||||
GROUP BY game, map_key HAVING id = MAX(id)
|
||||
ORDER BY game, name COLLATE NOCASE, map_key`)
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND, type ServerMatch } from '../../match-history.js';
|
||||
import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type ServerMatch } from '../../match-history.js';
|
||||
|
||||
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
|
||||
const handle = db();
|
||||
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
|
||||
const parameters = server === undefined ? [] : [server];
|
||||
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
|
||||
const matches = handle.prepare(`SELECT m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||
const matchSummary = `m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
|
||||
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
|
||||
m.start_players AS startPlayers,
|
||||
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
|
||||
@@ -15,8 +10,59 @@ export function serverMatchHistory(server?: string, limit = 25): { matches: Serv
|
||||
END AS endPlayers,
|
||||
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,
|
||||
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound
|
||||
FROM leaderboard_matches m ${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
|
||||
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound`;
|
||||
|
||||
export function serverMatchHistory(server?: string, limit = 25): { matches: ServerMatch[]; total: number } {
|
||||
const handle = db();
|
||||
const where = `WHERE m.highest_round >= ${MIN_RECORDED_ROUND}${server === undefined ? '' : ' AND m.server = ?'}`;
|
||||
const parameters = server === undefined ? [] : [server];
|
||||
const total = (handle.prepare(`SELECT COUNT(*) AS total FROM leaderboard_matches m ${where}`).get(...parameters) as { total: number }).total;
|
||||
const matches = handle.prepare(`SELECT ${matchSummary} FROM leaderboard_matches m
|
||||
${where} ORDER BY m.started_at DESC, m.id DESC LIMIT ?`)
|
||||
.all(...parameters, limit) as ServerMatch[];
|
||||
return { matches, total };
|
||||
}
|
||||
|
||||
/** Read durable match records without depending on a current server or scoreboard. */
|
||||
export function getMatchDetail(id: number): MatchDetail | null {
|
||||
if (!Number.isSafeInteger(id) || id < 1) return null;
|
||||
const handle = db();
|
||||
const match = handle.prepare(`SELECT ${matchSummary}, m.game, m.start_observed AS startObserved
|
||||
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND}`)
|
||||
.get(id) as (Omit<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
|
||||
if (!match) return null;
|
||||
|
||||
const players = handle.prepare(`SELECT p.guid,
|
||||
COALESCE((SELECT identity.name FROM leaderboard_players identity
|
||||
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6')
|
||||
ORDER BY identity.last_seen_at DESC, identity.game LIMIT 1),
|
||||
(SELECT identity.name FROM leaderboard_players identity
|
||||
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
|
||||
p.has_stats AS hasStats, p.present AS presentAtEnd,
|
||||
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.rounds_survived END AS roundsSurvived,
|
||||
CASE WHEN p.has_stats = 1 THEN p.kills END AS kills,
|
||||
CASE WHEN p.has_stats = 1 THEN p.headshots END AS headshots,
|
||||
CASE WHEN p.has_stats = 1 THEN p.downs END AS downs,
|
||||
CASE WHEN p.has_stats = 1 THEN p.revives END AS revives,
|
||||
CASE WHEN p.has_stats = 1 THEN p.best_score END AS bestScore,
|
||||
(EXISTS (SELECT 1 FROM leaderboard_players identity
|
||||
WHERE identity.guid = p.guid AND identity.game IN ('t5', 't6'))
|
||||
AND EXISTS (SELECT 1 FROM leaderboard_match_players recorded
|
||||
JOIN leaderboard_matches history ON history.id = recorded.match_id
|
||||
WHERE recorded.guid = p.guid AND history.game IN ('t5', 't6') AND recorded.has_stats = 1
|
||||
AND history.status = 'completed'
|
||||
AND history.highest_round >= ${MIN_RECORDED_ROUND}
|
||||
AND recorded.highest_round >= ${MIN_RECORDED_ROUND})) AS canViewProfile
|
||||
FROM leaderboard_match_players p WHERE p.match_id = ?
|
||||
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid`)
|
||||
.all(match.game, id) as Array<Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||
hasStats: number; presentAtEnd: number; canViewProfile: number;
|
||||
}>;
|
||||
return { ...match, startObserved: Boolean(match.startObserved), players: players.map(player => ({
|
||||
...player, hasStats: Boolean(player.hasStats), presentAtEnd: Boolean(player.presentAtEnd),
|
||||
canViewProfile: Boolean(player.canViewProfile)
|
||||
})) };
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { MIN_RECORDED_ROUND } from '../../match-history.js';
|
||||
import type { PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
||||
import { getPersonalBestRecords } from './leaderboard-records.js';
|
||||
import type { LeaderboardOptions, PlayerProfile, PlayerStats, PlayerMapRecord, PlayerMatch } from '../../leaderboard.js';
|
||||
|
||||
const totals = `MAX(mp.highest_round) AS highestRound, SUM(mp.kills) AS kills,
|
||||
SUM(mp.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
|
||||
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed,
|
||||
SUM(mp.completed) AS gamesCompleted, MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore`;
|
||||
const scope = `FROM leaderboard_match_players mp JOIN leaderboard_matches m ON m.id = mp.match_id
|
||||
WHERE m.game IN ('t5', 't6') AND mp.guid = ? AND mp.has_stats = 1
|
||||
WHERE m.game IN ('t5', 't6') AND m.status = 'completed' AND mp.guid = ? AND mp.has_stats = 1
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND} AND mp.highest_round >= ${MIN_RECORDED_ROUND}`;
|
||||
|
||||
export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfile | null {
|
||||
export function getPlayerProfile(guid: string, requestedOffset = 0,
|
||||
options: Pick<LeaderboardOptions, 'game' | 'map'> = {}): PlayerProfile | null {
|
||||
const handle = db();
|
||||
// Keep game-specific ingestion identities, but show one identity with the latest reported name.
|
||||
const player = handle.prepare(`SELECT guid, name, MIN(first_seen_at) OVER () AS firstSeen,
|
||||
@@ -20,18 +22,28 @@ export function getPlayerProfile(guid: string, requestedOffset = 0): PlayerProfi
|
||||
if (!player) return null;
|
||||
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
|
||||
if (!stats.gamesPlayed) return null;
|
||||
const historyParameters = [guid];
|
||||
let historyScope = scope;
|
||||
if (options.game) { historyScope += ' AND m.game = ?'; historyParameters.push(options.game); }
|
||||
if (options.map) { historyScope += ' AND m.map_key = ?'; historyParameters.push(options.map); }
|
||||
const matchesTotal = options.game || options.map
|
||||
? (handle.prepare(`SELECT COUNT(*) AS total ${historyScope}`).get(...historyParameters) as { total: number }).total
|
||||
: stats.gamesPlayed;
|
||||
const limit = 25;
|
||||
const lastOffset = Math.floor((stats.gamesPlayed - 1) / limit) * limit;
|
||||
const lastOffset = Math.max(0, Math.floor((matchesTotal - 1) / limit) * limit);
|
||||
const offset = Number.isSafeInteger(requestedOffset) ? Math.min(lastOffset, Math.max(0, requestedOffset)) : 0;
|
||||
const maps = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name,
|
||||
const mapStats = handle.prepare(`SELECT m.game, m.map_key AS key, m.map_name AS name,
|
||||
MAX(mp.last_seen_at) AS lastSeen, ${totals} ${scope}
|
||||
GROUP BY m.game, m.map_key ORDER BY highestRound DESC, kills DESC, name COLLATE NOCASE, m.game, key`)
|
||||
.all(guid) as PlayerMapRecord[];
|
||||
.all(guid) as Array<Omit<PlayerMapRecord, 'records'>>;
|
||||
const [records, ...mapRecords] = getPersonalBestRecords(handle, [{ guid, ...stats },
|
||||
...mapStats.map(map => ({ guid, ...map, map: map.key }))]);
|
||||
const maps = mapStats.map((map, index) => ({ ...map, records: mapRecords[index] }));
|
||||
const matches = handle.prepare(`SELECT m.id, m.game, m.server, m.server_deleted_at, m.map_name AS mapName,
|
||||
m.started_at AS startedAt, mp.last_seen_at AS lastSeen, m.status, mp.completed,
|
||||
mp.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
|
||||
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore
|
||||
${scope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
|
||||
.all(guid, limit, offset) as PlayerMatch[];
|
||||
return { ...player, stats, maps, matches, offset, limit };
|
||||
${historyScope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
|
||||
.all(...historyParameters, limit, offset) as PlayerMatch[];
|
||||
return { ...player, stats, records, maps, matches, matchesTotal, offset, limit };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import { db } from '../db/index.js';
|
||||
|
||||
/** Maintenance must leave pending lifecycle work and retained recovery to Provisioning. */
|
||||
export function hasActiveProvisioning(server: string, exceptOperationId = ''): boolean {
|
||||
return !!db().prepare(`SELECT 1 FROM server_deployments
|
||||
WHERE server = ? AND server_deleted_at IS NULL AND id != ?
|
||||
AND state IN ('queued', 'waiting', 'running', 'awaiting-health') LIMIT 1`).get(server, exceptOperationId)
|
||||
|| !!db().prepare(`SELECT 1 FROM restart_rollout WHERE server = ? AND healthy_at IS NULL
|
||||
AND (operation_id IS NULL OR operation_id != ?)`).get(server, exceptOperationId);
|
||||
}
|
||||
|
||||
/** Only the latest lifecycle operation can describe the current server. */
|
||||
export function isProvisioning(server: string): boolean {
|
||||
const operation = db().prepare(`SELECT action, state FROM server_deployments
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { getServer, listServers } from '../fleet/servers.js';
|
||||
import { hasActiveProvisioning } from '../fleet/provisioning.js';
|
||||
import { latestGameSubmission } from '../fleet/status-evidence.js';
|
||||
import { rconPlutoniumVersion } from './rcon.js';
|
||||
import { komodoInspectHealthy } from './komodo.js';
|
||||
@@ -34,15 +35,21 @@ export function runningVersions(): PlutoniumVersion[] {
|
||||
: revision < target ? 'outdated' : revision > target ? 'ahead' : 'current' };
|
||||
});
|
||||
}
|
||||
export async function probeVersion(server: string): Promise<number | null> {
|
||||
export async function probeVersion(server: string, options: { maintenance?: boolean } = {}): Promise<number | null> {
|
||||
// Startup verification calls this directly; only maintenance observers yield
|
||||
// ownership, without replacing provisioning's version evidence with an error.
|
||||
const suppressed = () => options.maintenance && hasActiveProvisioning(server);
|
||||
if (suppressed()) return null;
|
||||
const original = getServer(server);
|
||||
if (!original) return null;
|
||||
const epoch = latestGameSubmission(server);
|
||||
const recovering = restartRecoveryPending(server);
|
||||
const health = !recovering && original.desired_state !== 'stopped'
|
||||
? await komodoInspectHealthy(server).catch(() => ({ ok: false })) : { ok: false };
|
||||
if (suppressed()) return null;
|
||||
let result = !health.ok ? { revision: null, error: 'Waiting for a healthy running server.' }
|
||||
: await rconPlutoniumVersion(server).catch(() => ({ revision: null, error: 'Version check failed.' }));
|
||||
if (suppressed()) return null;
|
||||
const current = getServer(server);
|
||||
// An older probe must not clear evidence already recorded for a new process
|
||||
// or replacement registration, including replacements with no submission yet.
|
||||
@@ -72,7 +79,7 @@ async function check(): Promise<void> {
|
||||
await checkRelease();
|
||||
const servers = listServers();
|
||||
for (let i = 0; i < servers.length; i += 4) {
|
||||
await Promise.all(servers.slice(i, i + 4).map(s => probeVersion(s.name)));
|
||||
await Promise.all(servers.slice(i, i + 4).map(s => probeVersion(s.name, { maintenance: true })));
|
||||
}
|
||||
writeSetting('plutonium_versions_checked_at', nowIso());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { komodoConfigured } from '../config.js';
|
||||
import { getServer, listServers } from '../fleet/servers.js';
|
||||
import { hasActiveProvisioning } from '../fleet/provisioning.js';
|
||||
import { komodoInspectHealthy } from '../integrations/komodo.js';
|
||||
import { rconStatusResult } from '../integrations/rcon.js';
|
||||
import { telemetryEvidence } from '../fleet/status-evidence.js';
|
||||
@@ -73,8 +74,17 @@ export function watchdogDetail(id: string): WatchdogDetail | null {
|
||||
const row = incident(id);
|
||||
return row ? { incident: row, actions: actions(id), events: db().prepare('SELECT id,created_at,message FROM watchdog_events WHERE incident_id=? ORDER BY id').all(id) as WatchdogDetail['events'] } : null;
|
||||
}
|
||||
function hasPending(server: string) {
|
||||
return !!db().prepare("SELECT 1 FROM server_deployments WHERE server=? AND server_deleted_at IS NULL AND state IN ('queued','waiting','running','awaiting-health') LIMIT 1").get(server);
|
||||
function scanSuppressed(server: string, kind?: 'health' | 'drift'): boolean {
|
||||
// An explicit retry can reclaim its own earlier attempt's recovery slot.
|
||||
// Keep other findings and every active lifecycle operation suppressed.
|
||||
const retained = db().prepare(`SELECT r.operation_id FROM restart_rollout r
|
||||
JOIN server_deployments d ON d.id = r.operation_id AND d.server_deleted_at IS NULL
|
||||
JOIN watchdog_actions a ON a.operation_id = d.id
|
||||
JOIN watchdog_incidents i ON i.id = a.incident_id
|
||||
WHERE r.server = ? AND r.healthy_at IS NULL AND d.state IN ('failed','interrupted','cancelled')
|
||||
AND i.server = r.server AND i.server_deleted_at IS NULL AND i.state = 'open' AND a.cycle < i.cycle
|
||||
AND (? IS NULL OR i.kind = ?) LIMIT 1`).get(server, kind ?? null, kind ?? null) as { operation_id: string } | undefined;
|
||||
return hasActiveProvisioning(server, retained?.operation_id);
|
||||
}
|
||||
function protectedWork(server: string): boolean {
|
||||
return !!db().prepare(`SELECT 1 FROM beta_servers bs JOIN betas b ON b.id=bs.beta_id WHERE bs.server=? AND bs.server_deleted_at IS NULL AND b.state IN ('running','ready-promote') LIMIT 1`).get(server)
|
||||
@@ -111,6 +121,9 @@ async function reconcileActions(): Promise<void> {
|
||||
for (const row of rows) {
|
||||
const action = actions(row.id).filter(a => a.cycle === row.cycle).at(-1);
|
||||
if (!action) continue;
|
||||
// Continue observing our own recovery, but do not verify or escalate
|
||||
// against files and health owned by another lifecycle operation.
|
||||
if (hasActiveProvisioning(row.server, action.operation_id)) continue;
|
||||
const operation = db().prepare('SELECT state,submitted_at,started_at,stage,error FROM server_deployments WHERE id=?').get(action.operation_id) as
|
||||
{ state: string; submitted_at: string | null; started_at: string; stage: string; error: string | null } | undefined;
|
||||
if (!operation) { transition(row.id, 'attention', 'Provisioning history is missing; inspect the server before retrying'); continue; }
|
||||
@@ -118,9 +131,13 @@ async function reconcileActions(): Promise<void> {
|
||||
if (row.kind === 'drift') {
|
||||
try {
|
||||
const plan = await previewDrift([row.server]);
|
||||
if (incident(row.id)?.cycle !== row.cycle) continue;
|
||||
if (incident(row.id)?.cycle !== row.cycle || hasActiveProvisioning(row.server, action.operation_id)) continue;
|
||||
if (plan.files.length) { transition(row.id, 'attention', 'Rebuild completed but drift remains; inspect the campaign and server files'); continue; }
|
||||
} catch (err) { transition(row.id, 'attention', `Cannot verify rebuilt files: ${(err as Error).message}`); continue; }
|
||||
} catch (err) {
|
||||
if (!hasActiveProvisioning(row.server, action.operation_id))
|
||||
transition(row.id, 'attention', `Cannot verify rebuilt files: ${(err as Error).message}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
transition(row.id, 'resolved', action.action === 'restart' ? 'Server recovered after restart' : 'Server recovered after rebuild');
|
||||
continue;
|
||||
@@ -150,8 +167,9 @@ async function reconcileActions(): Promise<void> {
|
||||
}
|
||||
|
||||
interface Observation { healthy: boolean; actionable: boolean; reason: string; containerState: string | null }
|
||||
async function observe(server: string): Promise<Observation> {
|
||||
async function observe(server: string): Promise<Observation | null> {
|
||||
const health = await komodoInspectHealthy(server);
|
||||
if (scanSuppressed(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return null;
|
||||
const state = health.state?.toLowerCase() ?? null;
|
||||
if (!health.ok) return { healthy: false, actionable: !!state && ['stopped','exited','created','dead','unhealthy','restarting','starting'].includes(state), reason: health.reason, containerState: state };
|
||||
if (telemetryEvidence(server)) return { healthy: true, actionable: false, reason: 'Komodo healthy with fresh telemetry', containerState: state };
|
||||
@@ -162,14 +180,14 @@ async function observe(server: string): Promise<Observation> {
|
||||
async function scanServer(server: string): Promise<void> {
|
||||
const row = getServer(server);
|
||||
if (!row || row.desired_state === 'stopped') return;
|
||||
if (hasPending(server) || protectedWork(server)) return;
|
||||
if (scanSuppressed(server) || protectedWork(server)) return;
|
||||
const observation = await observe(server);
|
||||
// Probes can overlap an API request; recheck ownership before queuing anything.
|
||||
if (hasPending(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
|
||||
if (observation.healthy) {
|
||||
if (!observation || scanSuppressed(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
|
||||
if (!scanSuppressed(server, 'health') && observation.healthy) {
|
||||
const current = active(server, 'health');
|
||||
if (current) transition(current.id, 'resolved', 'Fresh game status and Komodo health verified');
|
||||
} else {
|
||||
} else if (!scanSuppressed(server, 'health')) {
|
||||
const problem = detect(server, 'health', observation.reason, observation);
|
||||
const startup = ['starting','restarting'].includes(observation.containerState ?? '');
|
||||
const oldEnough = Date.now() - Date.parse(problem.first_seen_at) >= (startup ? WATCHDOG_RECOVERY_MS : WATCHDOG_FAILURE_MS);
|
||||
@@ -181,11 +199,11 @@ async function scanServer(server: string): Promise<void> {
|
||||
})();
|
||||
if (!result.ok && result.status !== 409 && result.status !== 503) transition(problem.id, 'attention', result.error);
|
||||
}
|
||||
// Unknown container health is an observation failure, not proof that a rebuild is safe.
|
||||
if (!observation.actionable || hasPending(server)) return;
|
||||
}
|
||||
// Unknown container health is an observation failure, not proof that a rebuild is safe.
|
||||
if ((!observation.healthy && !observation.actionable) || scanSuppressed(server, 'drift')) return;
|
||||
const prepared = await prepareDriftCampaign(server);
|
||||
if (hasPending(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
|
||||
if (scanSuppressed(server, 'drift') || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
|
||||
if (!prepared.plan.files.length) {
|
||||
const current = active(server, 'drift');
|
||||
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup');
|
||||
@@ -225,14 +243,16 @@ async function run(forceScan: boolean): Promise<void> {
|
||||
while (cursor < servers.length) {
|
||||
const server = servers[cursor++];
|
||||
try { await scanServer(server.name); }
|
||||
catch (err) { errors.push(`${server.name}: ${(err as Error).message}`); }
|
||||
catch (err) {
|
||||
if (!hasActiveProvisioning(server.name)) errors.push(`${server.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, pump));
|
||||
// Preserve incidents for temporarily absent rebuild instances, but close retired registrations.
|
||||
const abandoned = db().prepare("SELECT * FROM watchdog_incidents WHERE server_deleted_at IS NULL AND state != 'resolved'").all() as WatchdogIncident[];
|
||||
for (const row of abandoned) {
|
||||
if (getServer(row.server) || hasPending(row.server)) continue;
|
||||
if (getServer(row.server) || hasActiveProvisioning(row.server)) continue;
|
||||
const last = db().prepare('SELECT action,state FROM server_deployments WHERE server=? ORDER BY rowid DESC LIMIT 1').get(row.server) as { action: string; state: string } | undefined;
|
||||
// An absent registry can also mean a failed rebuild. Only a completed
|
||||
// destruction retires that failure without requiring inspection.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { getServer, listServers } from '../fleet/servers.js';
|
||||
import { hasActiveProvisioning } from '../fleet/provisioning.js';
|
||||
import { requestRestart, cancelProvisioning } from '../provisioning/index.js';
|
||||
import { nextMaintenanceAt } from '../../plutonium.js';
|
||||
import type { MaintenanceJob, MaintenanceSchedule, PlutoniumStatus } from '../../plutonium.js';
|
||||
@@ -37,6 +38,7 @@ export function queueMaintenance(servers: string[], kind: MaintenanceJob['kind']
|
||||
let added = 0;
|
||||
for (const server of new Set(servers)) {
|
||||
if (getServer(server)?.desired_state === 'stopped') continue;
|
||||
if (origin !== 'manual' && hasActiveProvisioning(server)) continue;
|
||||
added += db().prepare(`INSERT OR IGNORE INTO maintenance_jobs (server, target_revision, requested_at, kind, origin)
|
||||
VALUES (?, ?, ?, ?, ?)`).run(server, target, nowIso(), kind, origin).changes;
|
||||
const job = db().prepare("SELECT id FROM maintenance_jobs WHERE server = ? AND server_deleted_at IS NULL AND state IN ('queued','restarting')").get(server) as { id: number };
|
||||
@@ -125,6 +127,9 @@ async function runTick(): Promise<void> {
|
||||
async function processJob(job: MaintenanceJob): Promise<void> {
|
||||
const server = getServer(job.server);
|
||||
if (job.server_deleted_at || !server || server.destroyed_at) return finish(job, 'cancelled', 'Server removed.');
|
||||
if (!job.provisioning_id && !job.restarted_at && hasActiveProvisioning(job.server)) {
|
||||
return hold(job, 'Waiting for another provisioning operation.');
|
||||
}
|
||||
const operation = requestRestart(job.server, { kind: 'maintenance', id: String(job.id) });
|
||||
if (['failed','interrupted','cancelled'].includes(operation.state)) {
|
||||
return finish(job, operation.state === 'cancelled' ? 'cancelled' : 'failed', operation.error ?? operation.stage);
|
||||
@@ -136,7 +141,13 @@ async function processJob(job: MaintenanceJob): Promise<void> {
|
||||
}
|
||||
return hold(job, operation.stage);
|
||||
}
|
||||
const revision = await probeVersion(job.server);
|
||||
if (hasActiveProvisioning(job.server, operation.id)) {
|
||||
return hold(job, 'Waiting for another provisioning operation before verifying the running revision.');
|
||||
}
|
||||
const revision = await probeVersion(job.server, { maintenance: true });
|
||||
if (hasActiveProvisioning(job.server, operation.id)) {
|
||||
return hold(job, 'Waiting for another provisioning operation before verifying the running revision.');
|
||||
}
|
||||
if (job.target_revision && (revision === null || revision < job.target_revision)) {
|
||||
return finish(job, 'failed', `Server recovered, but r${job.target_revision} was not verified (${revision === null ? 'version unknown' : `running r${revision}`}). Check Plutainer auto-update settings.`);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
<Card padding="lg">
|
||||
<h2>Match history</h2>
|
||||
<p>Newest matches first. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
|
||||
<p>Newest matches first. Open a completed match to see its player results. Unique players are counted across all match reports. — means the start or end wasn’t recorded, or the match is still in progress.</p>
|
||||
{#if error}
|
||||
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
|
||||
{/if}
|
||||
@@ -57,11 +57,17 @@
|
||||
<thead><tr><th>Started</th><th>Ended</th>{#if server === undefined}<th>Server</th>{/if}<th>Map</th><th>Status</th><th>Players at start</th><th>Unique players</th><th>Players at end</th><th>Game time</th><th>Final round</th></tr></thead>
|
||||
<tbody>
|
||||
{#each matches as match (match.id)}
|
||||
<tr>
|
||||
<tr class:completed={match.status === 'completed'}>
|
||||
<td><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></td>
|
||||
<td>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</td>
|
||||
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
|
||||
<td>{match.map}</td>
|
||||
<td>
|
||||
{#if match.status === 'completed'}
|
||||
<a class="match-link" href={`/matches/${match.id}`} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
|
||||
{match.map}<small>View match →</small>
|
||||
</a>
|
||||
{:else}{match.map}{/if}
|
||||
</td>
|
||||
<td>{match.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
|
||||
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</td>
|
||||
<td>{match.durationMs === null ? '—' : formatDuration(match.durationMs)}</td><td>{match.finalRound ?? '—'}</td>
|
||||
@@ -84,4 +90,9 @@
|
||||
th, td { padding: var(--space-3); text-align: left; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||
th { color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
.completed:hover, .completed:focus-within { background: var(--surface-raised); }
|
||||
.match-link { display: block; color: var(--accent-primary-hover); text-decoration: none; }
|
||||
.match-link:hover { text-decoration: underline; }
|
||||
.match-link:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||||
.match-link small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { playerStatMetrics, type PlayerMapRecord, type PersonalBestMetric, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
|
||||
let { maps, metric = 'highestRound' }: { maps: PlayerMapRecord[]; metric?: LeaderboardMetric } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
const otherMetrics = playerStatMetrics.filter(metric => metric.key !== 'highestRound' && metric.key !== 'kills');
|
||||
const recordMetrics = ['highestRound', 'bestKills', 'bestScore'] as const satisfies readonly PersonalBestMetric[];
|
||||
</script>
|
||||
|
||||
<div class="map-cards">
|
||||
{#each maps as map (`${map.game}:${map.key}`)}
|
||||
<article class="map-card">
|
||||
<header>
|
||||
<p class="game">{gameName(map.game)}</p>
|
||||
<h3><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric })}`}>{map.name}</a></h3>
|
||||
<p class="last-played">Last played <time datetime={map.lastSeen}>{$timeFormat.dateTime(map.lastSeen)}</time></p>
|
||||
</header>
|
||||
<dl class="primary-stats">
|
||||
<div><dt>Highest round</dt><dd class="best">{number.format(map.highestRound)}</dd></div>
|
||||
<div><dt>Total kills</dt><dd>{number.format(map.kills)}</dd></div>
|
||||
</dl>
|
||||
<details class="more-stats">
|
||||
<summary aria-label={`All stats and record runs for ${map.name}, ${gameName(map.game)}`}>All stats and record runs</summary>
|
||||
<dl class="additional-stats">
|
||||
{#each otherMetrics as metric (metric.key)}
|
||||
<div><dt>{metric.label}</dt><dd>{number.format(map[metric.key])}</dd></div>
|
||||
{/each}
|
||||
</dl>
|
||||
<div class="record-runs">
|
||||
{#each recordMetrics as key (key)}
|
||||
<div class="record-run">
|
||||
<h4>{playerStatMetrics.find(metric => metric.key === key)!.label} · {number.format(map[key])}</h4>
|
||||
<RecordSource evidence={map.records[key]} />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</details>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.map-cards { min-width: 0; }
|
||||
.map-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, h4, p, dl, dd { margin: 0; }
|
||||
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
|
||||
h4 { font-size: var(--text-sm); line-height: 1.5; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.game, .last-played { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.last-played { margin-top: var(--space-2); }
|
||||
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.primary-stats { margin-top: var(--space-4); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
dd { margin-top: var(--space-1); font-family: var(--font-mono); font-size: var(--text-sm); font-variant-numeric: tabular-nums; }
|
||||
.primary-stats dd { font-size: var(--text-xl); }
|
||||
.best { color: var(--accent-primary-hover); }
|
||||
.more-stats { margin-top: var(--space-2); }
|
||||
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
|
||||
.record-runs { margin-top: var(--space-4); }
|
||||
.record-run { padding-top: var(--space-3); border-top: 1px solid var(--border-subtle); }
|
||||
.record-run + .record-run { margin-top: var(--space-4); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { playerStatMetrics, type PlayerMatch } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
|
||||
let { matches }: { matches: PlayerMatch[] } = $props();
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
const otherMetrics = ['roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const;
|
||||
</script>
|
||||
|
||||
<div class="match-cards">
|
||||
{#each matches as match (match.id)}
|
||||
<article class="match-card">
|
||||
<header>
|
||||
<p class="game">{gameName(match.game)}</p>
|
||||
<h3>{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${$timeFormat.dateTime(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}</h3>
|
||||
<p class="server">{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</p>
|
||||
</header>
|
||||
<dl class="match-info">
|
||||
<div><dt>First observed</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
|
||||
<div><dt>Match status</dt><dd><span class="status">{match.status}</span> · {match.completed ? 'Completion credited' : 'No completion credit'}</dd></div>
|
||||
</dl>
|
||||
<dl class="primary-stats">
|
||||
<div><dt>Highest round</dt><dd class="best">{number.format(match.highestRound)}</dd></div>
|
||||
<div><dt>Kills</dt><dd>{number.format(match.kills)}</dd></div>
|
||||
</dl>
|
||||
<details class="more-stats">
|
||||
<summary aria-label={`More match stats for ${match.mapName} from ${$timeFormat.dateTime(match.startedAt)}`}>More match stats</summary>
|
||||
<dl class="additional-stats">
|
||||
{#each otherMetrics as key (key)}
|
||||
<div><dt>{playerStatMetrics.find(metric => metric.key === key)!.label}</dt><dd>{number.format(match[key])}</dd></div>
|
||||
{/each}
|
||||
</dl>
|
||||
<p class="observation">Last observed <time datetime={match.lastSeen}>{$timeFormat.dateTime(match.lastSeen)}</time></p>
|
||||
<p class="match-id">Match #{match.id}</p>
|
||||
</details>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.match-cards { min-width: 0; }
|
||||
.match-card { min-width: 0; padding: var(--space-4); border-top: 1px solid var(--border-subtle); overflow-wrap: anywhere; }
|
||||
h3, p, dl, dd { margin: 0; }
|
||||
h3 { margin-top: var(--space-1); font-size: var(--text-md); line-height: 1.5; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.game, .server, .observation, .match-id { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
.server { margin-top: var(--space-2); }
|
||||
.match-info { display: grid; gap: var(--space-2); margin-top: var(--space-3); }
|
||||
dt { color: var(--text-secondary); font-size: var(--text-xs); line-height: 1.5; }
|
||||
dd { margin-top: var(--space-1); font-size: var(--text-sm); line-height: 1.5; }
|
||||
.status { text-transform: capitalize; }
|
||||
.primary-stats, .additional-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.primary-stats { margin-top: var(--space-4); }
|
||||
.primary-stats dd, .additional-stats dd { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.primary-stats dd { font-size: var(--text-xl); }
|
||||
.best { color: var(--accent-primary-hover); }
|
||||
.more-stats { margin-top: var(--space-2); }
|
||||
summary { min-height: 44px; padding: var(--space-3) 0; box-sizing: border-box; color: var(--accent-primary-hover); font-size: var(--text-sm); line-height: 1.5; cursor: pointer; }
|
||||
.observation { margin-top: var(--space-4); }
|
||||
.match-id { margin-top: var(--space-1); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { RecordEvidence } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
|
||||
let { evidence }: { evidence: RecordEvidence } = $props();
|
||||
const gameName = $derived(evidence.game === 't5' ? 'Black Ops I' : 'Black Ops II');
|
||||
</script>
|
||||
|
||||
<div class="record-source">
|
||||
<a href={`/matches/${evidence.matchId}`}>{evidence.mapName} · {gameName}</a>
|
||||
<span>{#if evidence.endedAt}Completed <time datetime={evidence.endedAt}>{$timeFormat.dateTime(evidence.endedAt)}</time>{:else}Completed · End time unknown{/if}</span>
|
||||
<details>
|
||||
<summary>Run details</summary>
|
||||
<dl>
|
||||
<div><dt>Server</dt><dd>{evidence.server}</dd></div>
|
||||
<div><dt>{evidence.startObserved ? 'Match started' : 'Match first observed'}</dt><dd><time datetime={evidence.startedAt}>{$timeFormat.dateTime(evidence.startedAt)}</time></dd></div>
|
||||
<div><dt>Player participation</dt><dd>{evidence.presentAtStart ? 'Present at recorded start' : evidence.firstObservedRound === null ? 'Participation start unknown' : `First observed at round ${evidence.firstObservedRound}`}</dd></div>
|
||||
</dl>
|
||||
<p>Recorded presence does not establish continuous survival.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.record-source { margin-top: var(--space-2); max-width: 24rem; white-space: normal; overflow-wrap: anywhere; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; line-height: 1.5; color: var(--text-secondary); }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
span { display: block; margin-top: 3px; color: var(--text-tertiary); }
|
||||
details { margin-top: var(--space-1); }
|
||||
summary { cursor: pointer; }
|
||||
dl { margin: var(--space-2) 0; }
|
||||
dl > div + div { margin-top: var(--space-2); }
|
||||
dt { color: var(--text-tertiary); }
|
||||
dd { margin: 0; }
|
||||
p { margin: var(--space-2) 0 0; color: var(--text-tertiary); }
|
||||
a:focus-visible, summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||
</style>
|
||||
@@ -3,28 +3,35 @@
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { leaderboardMetrics, playerProfileHref, type LeaderboardMetric, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
|
||||
import { leaderboardMetrics, type LeaderboardMetric, type PlayerStatMetric, type LeaderboardEntry, type LeaderboardMap, type LeaderboardResponse } from '$lib/leaderboard.js';
|
||||
import { playerContextHref } from '$lib/leaderboard-navigation.js';
|
||||
import { efficiencyMetrics, isEfficiencyMetric, efficiencyRequirement, efficiencySample, formatEfficiencyValue } from '$lib/leaderboard-efficiency.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const pageSize = 50;
|
||||
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }];
|
||||
const defaultColumns: LeaderboardMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
|
||||
const descriptions: Record<LeaderboardMetric, string> = {
|
||||
const defaultColumns: PlayerStatMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
|
||||
const descriptions: Record<PlayerStatMetric, string> = {
|
||||
highestRound: 'The highest round reached in a recorded game.',
|
||||
kills: 'Cumulative kills across recorded games.',
|
||||
roundsSurvived: 'Round progression during observed participation, added across games.',
|
||||
headshots: 'Cumulative headshots across recorded games.',
|
||||
revives: 'Cumulative teammates revived across recorded games.',
|
||||
downs: 'Cumulative downs across recorded games, ranked from most to fewest.',
|
||||
gamesPlayed: 'Games with recorded player stats, including games in progress.',
|
||||
gamesPlayed: 'Finished games with recorded player stats, including players who left before the end.',
|
||||
gamesCompleted: 'Games where the player appears in the final game-end report.',
|
||||
bestKills: 'The most kills reported for a player in a single game.',
|
||||
bestScore: 'The highest reported points balance. Spending points reduces this balance; it is not total points earned.'
|
||||
};
|
||||
const description = (key: LeaderboardMetric) => isEfficiencyMetric(key)
|
||||
? efficiencyMetrics.find(item => item.key === key)!.description : descriptions[key];
|
||||
const formatValue = (entry: LeaderboardEntry, key: LeaderboardMetric) => isEfficiencyMetric(key)
|
||||
? formatEfficiencyValue(entry[key], key) : number.format(entry[key]);
|
||||
|
||||
let data = $state<LeaderboardResponse | null>(null);
|
||||
let maps = $state<LeaderboardMap[]>([]);
|
||||
@@ -179,14 +186,14 @@
|
||||
<div class="record"><span>Highest round</span><strong>{number.format(data.summary.highestRound)}</strong><small>Fleet record</small></div>
|
||||
<div><span>Total kills</span><strong>{number.format(data.summary.kills)}</strong><small>Across all players</small></div>
|
||||
<div><span>Total rounds</span><strong>{number.format(data.summary.roundsSurvived)}</strong><small>Total player rounds</small></div>
|
||||
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Includes active games</small></div>
|
||||
<div><span>Games tracked</span><strong>{number.format(data.summary.matches)}</strong><small>Completed games only</small></div>
|
||||
<div><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Each player counted once</small></div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
|
||||
<div class="rankings-header">
|
||||
<div><h2 id="rankings-title">{metricLabel}</h2><p>{descriptions[metric]}</p></div>
|
||||
<div><h2 id="rankings-title">{metricLabel}</h2><p>{description(metric)}</p>{#if isEfficiencyMetric(metric)}<p>Qualification is measured within the selected game and map. Fleet totals include all recorded players.</p>{/if}</div>
|
||||
<div class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
|
||||
</div>
|
||||
{#if data.entries.length}
|
||||
@@ -200,7 +207,7 @@
|
||||
<th scope="col" class="player">Player</th>
|
||||
{#each columns as key (key)}
|
||||
<th scope="col" class="stat" class:selected={key === metric} aria-sort={key === metric ? 'descending' : 'none'}>
|
||||
<button onclick={() => updateFilters({ metric: key })} title={descriptions[key]}>{leaderboardMetrics.find(item => item.key === key)!.label}{#if key === metric}<span aria-hidden="true"> ↓</span>{/if}</button>
|
||||
<button onclick={() => updateFilters({ metric: key })} title={description(key)}>{leaderboardMetrics.find(item => item.key === key)!.label}{#if key === metric}<span aria-hidden="true"> ↓</span>{/if}</button>
|
||||
</th>
|
||||
{/each}
|
||||
</tr></thead>
|
||||
@@ -209,25 +216,29 @@
|
||||
<tr>
|
||||
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
|
||||
<th scope="row" class="player">
|
||||
<a class="player-name" href={playerProfileHref(entry.guid)}>{entry.name}</a>
|
||||
<a class="player-name" href={playerContextHref(entry.guid, `${$page.url.pathname}${$page.url.search}`)}>{entry.name}</a>
|
||||
<div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
|
||||
</th>
|
||||
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{number.format(entry[key])}</td>{/each}
|
||||
{#each columns as key (key)}<td class="stat" class:selected={key === metric}>{formatValue(entry, key)}{#if isEfficiencyMetric(key)}<small class="sample">{efficiencySample(entry, key)} · {number.format(entry.gamesPlayed)} games</small>{/if}{#if key === 'highestRound' || key === 'bestKills' || key === 'bestScore'}<RecordSource evidence={entry.records[key]} />{/if}</td>{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination">
|
||||
<span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
|
||||
<span>{number.format(data.offset + 1)}–{number.format(data.offset + data.entries.length)} of {number.format(data.total)} {isEfficiencyMetric(metric) ? 'qualifying ' : ''}{data.total === 1 ? 'player' : 'players'}{query ? ` matching “${query}”` : ''}</span>
|
||||
<div><Button size="sm" disabled={data.offset === 0 || loading} onclick={() => updateFilters({ offset: String(Math.max(0, offset - pageSize)) })}>Previous</Button><Button size="sm" disabled={data.offset + data.entries.length >= data.total || loading} onclick={() => updateFilters({ offset: String(offset + pageSize) })}>Next</Button></div>
|
||||
</div>
|
||||
{:else if isEfficiencyMetric(metric)}
|
||||
<EmptyState spacious title="No qualifying players found" description={`${efficiencyRequirement(metric)} in this game/map selection. Try another scope or player search.`}>
|
||||
{#snippet actions()}<Button size="sm" onclick={() => updateFilters({ metric: 'highestRound' })}>Rank by highest round</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else if query || game || map || offset}
|
||||
<EmptyState spacious title="No players found" description="Try a different game, map, or player name.">
|
||||
{#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
<EmptyState spacious title="Your first records start here" description="Player stats will appear as your servers report games and rounds. Active games count toward the leaderboard." />
|
||||
<EmptyState spacious title="Your first records start here" description="Player stats appear after a game ends, once the player has been observed at round 2 or later." />
|
||||
{/if}
|
||||
</section>
|
||||
{:else if loading}
|
||||
@@ -238,7 +249,7 @@
|
||||
<summary>How these records work</summary>
|
||||
<div>
|
||||
<p>Each GUID has one leaderboard entry across Black Ops I and Black Ops II, so name changes keep a player's records together. The latest reported name across both games appears here and on the player's profile, regardless of filters.</p>
|
||||
<p>Records include active and completed games. Game and map filters choose which matches contribute to totals and rankings. Player search filters the ranked entries without changing their ranks. Player totals count each GUID once.</p>
|
||||
<p>Only completed games contribute to records. Games in progress and interrupted games stay out of totals and rankings. Players who left a completed game keep their recorded stats. Game and map filters choose which matches contribute; player search preserves ranks. Player totals count each GUID once.</p>
|
||||
<p>Highest round includes mid-game joins. Total rounds counts round progression during observed participation, excluding earlier rounds. Joining at round 8 records a highest round of 8; reaching round 9 adds one to total rounds. The fleet total adds all players’ rounds.</p>
|
||||
<p>Kills, headshots, revives, and downs accumulate across recorded games. Highest reported score is the largest observed points balance, not the total points a player has earned. Equal values share a rank.</p>
|
||||
<p>Records cover telemetry saved since tracking began; older unrecorded games cannot be included.{#if data?.summary.firstTrackedAt}{' '}Tracking began <time datetime={data.summary.firstTrackedAt}>{$timeFormat.dateTime(data.summary.firstTrackedAt)}</time> for this selection.{/if}{#if data?.summary.updatedAt}{' '}Latest report: <time datetime={data.summary.updatedAt}>{$timeFormat.dateTime(data.summary.updatedAt)}</time>.{/if}</p>
|
||||
@@ -291,6 +302,7 @@
|
||||
.stat { text-align: right; white-space: nowrap; }
|
||||
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||
.stat.selected { color: var(--accent-primary-hover); }
|
||||
.sample { display: block; max-width: 22rem; margin-top: var(--space-2); white-space: normal; font-family: var(--font-body); font-size: var(--text-xs); font-weight: normal; color: var(--text-secondary); }
|
||||
td.selected { font-weight: var(--weight-semibold); }
|
||||
th button { display: inline-flex; gap: 6px; align-items: center; background: transparent; border: 0; padding: 3px 0; cursor: pointer; color: inherit; white-space: nowrap; }
|
||||
th button:hover { color: var(--accent-primary-hover); }
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js';
|
||||
import { getPlayerLeaderboardEntry, isLeaderboardMetric } from '$lib/server/fleet/leaderboard.js';
|
||||
import { leaderboardReturnHref } from '$lib/leaderboard-navigation.js';
|
||||
|
||||
export function load({ params, url }: { params: { guid: string }; url: URL }) {
|
||||
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0));
|
||||
const game = url.searchParams.get('game');
|
||||
const map = url.searchParams.get('map');
|
||||
const requestedMetric = url.searchParams.get('metric');
|
||||
if ((game !== null && game !== 't5' && game !== 't6') || (map !== null && map.length > 1024)
|
||||
|| (requestedMetric !== null && !isLeaderboardMetric(requestedMetric))) {
|
||||
error(400, 'Use game t5 or t6, a supported metric, and a map key up to 1024 characters.');
|
||||
}
|
||||
const metric = requestedMetric !== null && isLeaderboardMetric(requestedMetric) ? requestedMetric : 'highestRound';
|
||||
const options = { game: game || undefined, map: map || undefined, metric };
|
||||
const profile = getPlayerProfile(params.guid, Number(url.searchParams.get('offset') ?? 0), options);
|
||||
if (!profile) error(404, 'No recorded stats found for this player');
|
||||
return { profile };
|
||||
return { profile, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
|
||||
scope: { game: game ?? '', map: map ?? '', metric, entry: getPlayerLeaderboardEntry(params.guid, options) } };
|
||||
}
|
||||
|
||||
@@ -1,22 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { leaderboardMetrics, playerStatMetrics, type PlayerProfile, type PlayerStatMetric, type LeaderboardMetric, type LeaderboardEntry } from '$lib/leaderboard.js';
|
||||
import { efficiencyMetrics, efficiencyValue, efficiencyEligible, efficiencyRequirement, efficiencySample, formatEfficiencyValue, isEfficiencyMetric } from '$lib/leaderboard-efficiency.js';
|
||||
import { profileViewHref } from '$lib/leaderboard-navigation.js';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
import CopyValue from '$ui/components/CopyValue.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import RecordSource from '$ui/components/RecordSource.svelte';
|
||||
import PlayerMapCards from '$ui/components/PlayerMapCards.svelte';
|
||||
import PlayerMatchCards from '$ui/components/PlayerMatchCards.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
|
||||
let { data }: { data: { profile: PlayerProfile } } = $props();
|
||||
let { data }: { data: { profile: PlayerProfile; returnTo: string;
|
||||
scope: { game: string; map: string; metric: LeaderboardMetric; entry: LeaderboardEntry | null } } } = $props();
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
const profile = $derived(data.profile);
|
||||
const stats = $derived(profile.stats);
|
||||
const number = new Intl.NumberFormat('en-US', { maximumFractionDigits: 1 });
|
||||
const scope = $derived(data.scope);
|
||||
const filteredMaps = $derived(profile.maps.filter(map => !scope.game || map.game === scope.game));
|
||||
const selectedMetric = $derived(leaderboardMetrics.find(metric => metric.key === scope.metric)!);
|
||||
const selectedValue = $derived(scope.entry ? isEfficiencyMetric(scope.metric)
|
||||
? formatEfficiencyValue(scope.entry[scope.metric], scope.metric) : number.format(scope.entry[scope.metric]) : '—');
|
||||
const scopedLeaderboard = $derived.by(() => {
|
||||
const params = new URLSearchParams({ metric: scope.metric });
|
||||
if (scope.game) params.set('game', scope.game);
|
||||
if (scope.map) params.set('map', scope.map);
|
||||
return `/leaderboard?${params}`;
|
||||
});
|
||||
const date = (value: string) => $timeFormat.dateTime(value);
|
||||
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase();
|
||||
const ratio = (value: number, total: number, percent = false) => total ? `${number.format(value / total * (percent ? 100 : 1))}${percent ? '%' : ''}` : '—';
|
||||
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
|
||||
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly PlayerStatMetric[];
|
||||
let refreshing = $state(false);
|
||||
let refreshError = $state('');
|
||||
let filtering = $state(false);
|
||||
let pendingUrl: URL | undefined;
|
||||
let navigationId = 0;
|
||||
async function updateScope(changes: Record<string, string>) {
|
||||
const url = new URL(profileViewHref(pendingUrl ?? $page.url, changes), $page.url);
|
||||
pendingUrl = url;
|
||||
const id = ++navigationId;
|
||||
filtering = true;
|
||||
refreshError = '';
|
||||
try { await goto(`${url.pathname}${url.search}`, { keepFocus: true, noScroll: true }); }
|
||||
catch { refreshError = 'Unable to update the player filters. Please try again.'; }
|
||||
finally { if (id === navigationId) { pendingUrl = undefined; filtering = false; } }
|
||||
}
|
||||
async function refresh() {
|
||||
refreshing = true;
|
||||
refreshError = '';
|
||||
@@ -30,17 +61,32 @@
|
||||
|
||||
<section class="profile">
|
||||
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats">
|
||||
{#snippet crumbs()}<a href="/leaderboard">← Leaderboard</a>{/snippet}
|
||||
{#snippet crumbs()}<a href={data.returnTo}>← Leaderboard</a>{/snippet}
|
||||
{#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
|
||||
</SectionHeader>
|
||||
<div class="identity"><CopyValue value={profile.guid} label="Player GUID" /><span>First seen {date(profile.firstSeen)}</span><span>Last seen {date(profile.lastSeen)}</span></div>
|
||||
{#if refreshError}<p role="alert">{refreshError}</p>{/if}
|
||||
|
||||
<section class="panel selection" aria-labelledby="selection-title" aria-busy={filtering}>
|
||||
<div class="panel-heading"><h2 id="selection-title">Selected leaderboard</h2><p>Choose the competition and match history to explore. Lifetime stats below always cover all games and maps.</p></div>
|
||||
<div class="scope-controls">
|
||||
<label>Game<select value={scope.game} onchange={event => void updateScope({ game: event.currentTarget.value, map: '' })}><option value="">All games</option><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option></select></label>
|
||||
<label>Map<select value={scope.map} onchange={event => void updateScope({ map: event.currentTarget.value })}><option value="">All maps</option>{#if scope.map && !filteredMaps.some(map => map.key === scope.map)}<option value={scope.map}>{scope.map}</option>{/if}{#each filteredMaps as map (`${map.game}:${map.key}`)}<option value={map.key}>{map.name}{!scope.game ? ` · ${gameName(map.game)}` : ''}</option>{/each}</select></label>
|
||||
<label>Rank by<select value={scope.metric} onchange={event => void updateScope({ metric: event.currentTarget.value })}>{#each leaderboardMetrics as metric (metric.key)}<option value={metric.key}>{metric.label}</option>{/each}</select></label>
|
||||
</div>
|
||||
<div class="scope-rank">
|
||||
{#if scope.entry}<p><strong>Rank #{number.format(scope.entry.rank)}</strong> · {selectedMetric.label}: <strong>{selectedValue}</strong>{#if isEfficiencyMetric(scope.metric)}<small>{efficiencySample(scope.entry, scope.metric)} · {number.format(scope.entry.gamesPlayed)} games</small>{/if}</p>
|
||||
{:else if profile.matchesTotal === 0}<p>No completed matches in this selection.</p>
|
||||
{:else if isEfficiencyMetric(scope.metric)}<p>Not yet qualified for this ranking.<small>{efficiencyRequirement(scope.metric)} in this selection.</small></p>{/if}
|
||||
<a href={scopedLeaderboard}>Open selected leaderboard</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="totals-title">
|
||||
<h2 id="totals-title">Lifetime stats</h2>
|
||||
<div class="stats">
|
||||
{#each leaderboardMetrics as metric (metric.key)}
|
||||
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong></div>
|
||||
{#each playerStatMetrics as metric (metric.key)}
|
||||
<div class="stat" class:record={metric.key === 'highestRound'}><span>{metric.label}</span><strong>{number.format(stats[metric.key])}</strong>{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={profile.records[metric.key]} />{/if}</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
@@ -48,49 +94,51 @@
|
||||
<section aria-labelledby="metrics-title">
|
||||
<h2 id="metrics-title">Player metrics</h2>
|
||||
<div class="stats metrics">
|
||||
<div class="stat"><span>Headshots / kills</span><strong>{ratio(stats.headshots, stats.kills, true)}</strong></div>
|
||||
<div class="stat"><span>Kills per down</span><strong>{ratio(stats.kills, stats.downs)}</strong></div>
|
||||
<div class="stat"><span>Average kills per game</span><strong>{ratio(stats.kills, stats.gamesPlayed)}</strong></div>
|
||||
<div class="stat"><span>Average rounds per game</span><strong>{ratio(stats.roundsSurvived, stats.gamesPlayed)}</strong></div>
|
||||
<div class="stat"><span>Completion rate</span><strong>{ratio(stats.gamesCompleted, stats.gamesPlayed, true)}</strong></div>
|
||||
{#each efficiencyMetrics as metric (metric.key)}
|
||||
<div class="stat"><span>{metric.label}</span><strong>{formatEfficiencyValue(efficiencyValue(stats, metric.key), metric.key)}</strong><small>{efficiencySample(stats, metric.key)}</small><small>{efficiencyEligible(stats, metric.key) ? 'Qualifies for lifetime ranking' : `${efficiencyRequirement(metric.key)} to rank`}</small></div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="note">Averages include games in progress. A dash means there is no denominator for that metric.</p>
|
||||
<p class="note">Only completed games contribute. Completion rate measures how often the player appeared in the final report. A dash means there is no denominator for that metric.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="maps-title">
|
||||
<div class="panel-heading"><h2 id="maps-title">Per-map records</h2><p>Personal best rounds and every tracked stat across both games, ordered by highest round.</p></div>
|
||||
<div class="mobile-records"><PlayerMapCards maps={profile.maps} metric={scope.metric} /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Per-map player records" tabindex="0">
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Per-map player records" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Map</th>{#each leaderboardMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
||||
<thead><tr><th scope="col">Map</th>{#each playerStatMetrics as metric}<th scope="col">{metric.label}</th>{/each}</tr></thead>
|
||||
<tbody>{#each profile.maps as map (`${map.game}:${map.key}`)}
|
||||
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
|
||||
{#each leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}</td>{/each}</tr>
|
||||
<tr><th scope="row"><a href={`/leaderboard?${new URLSearchParams({ game: map.game, map: map.key, metric: scope.metric })}`}>{map.name}</a><small>{gameName(map.game)}</small><small>Last played {date(map.lastSeen)}</small></th>
|
||||
{#each playerStatMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}{#if metric.key === 'highestRound' || metric.key === 'bestKills' || metric.key === 'bestScore'}<RecordSource evidence={map.records[metric.key]} />{/if}</td>{/each}</tr>
|
||||
{/each}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel" aria-labelledby="history-title">
|
||||
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Matches from both games, most recently observed first. Stats reflect this player's participation.</p></div>
|
||||
<div class="panel-heading"><h2 id="history-title">Match history</h2><p>Completed matches in the selected game and map, most recently observed first. Stats reflect this player's participation.</p></div>
|
||||
{#if profile.matches.length}
|
||||
<div class="mobile-records"><PlayerMatchCards matches={profile.matches} /></div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Player match history" tabindex="0">
|
||||
<div class="table-scroll desktop-records" role="region" aria-label="Player match history" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Map / server</th><th scope="col">Match started</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{leaderboardMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
|
||||
<thead><tr><th scope="col">Map / server</th><th scope="col">First observed</th><th scope="col">Match status</th>{#each historyColumns as key}<th scope="col">{playerStatMetrics.find(metric => metric.key === key)!.label}</th>{/each}</tr></thead>
|
||||
<tbody>{#each profile.matches as match (match.id)}
|
||||
<tr><th scope="row">{match.mapName}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
|
||||
<tr><th scope="row">{#if match.status === 'completed'}<a href={`/matches/${match.id}`} aria-label={`View ${match.mapName} match from ${date(match.startedAt)}`}>{match.mapName}</a>{:else}{match.mapName}{/if}<small>{gameName(match.game)}</small><small>{match.server} {#if match.server_deleted_at}<Badge>Deleted</Badge>{/if}</small></th><td>{date(match.startedAt)}</td>
|
||||
<td class="status">{match.status}<small>{match.completed ? 'Completion credited' : 'No completion credit'}</small></td>
|
||||
{#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
|
||||
</tr>
|
||||
{/each}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(stats.gamesPlayed)} games</span>
|
||||
<div><Button size="sm" href={`?offset=${Math.max(0, profile.offset - profile.limit)}`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`?offset=${profile.offset + profile.limit}`} disabled={profile.offset + profile.limit >= stats.gamesPlayed}>Next</Button></div>
|
||||
<div class="pagination"><span>{number.format(profile.offset + 1)}–{number.format(profile.offset + profile.matches.length)} of {number.format(profile.matchesTotal)} games</span>
|
||||
<div><Button size="sm" href={`${profileViewHref($page.url, { offset: String(Math.max(0, profile.offset - profile.limit)) })}#history-title`} disabled={profile.offset === 0}>Previous</Button><Button size="sm" href={`${profileViewHref($page.url, { offset: String(profile.offset + profile.limit) })}#history-title`} disabled={profile.offset + profile.limit >= profile.matchesTotal}>Next</Button></div>
|
||||
</div>
|
||||
{:else}<EmptyState title="No completed matches in this selection" description="Choose another game or map to explore this player's history.">{#snippet actions()}<Button size="sm" onclick={() => void updateScope({ game: '', map: '' })}>Show all completed matches</Button>{/snippet}</EmptyState>{/if}
|
||||
</section>
|
||||
<details class="note"><summary>How these stats work</summary>
|
||||
<p>Lifetime stats combine saved telemetry from Black Ops I and Black Ops II, including active and interrupted games. Highest round includes mid-game joins; total rounds only counts observed round progression. Kills, headshots, revives and downs use each match's highest reported counters.</p>
|
||||
<p>Lifetime stats combine completed games from Black Ops I and Black Ops II. Games in progress and interrupted games do not contribute. Highest round includes mid-game joins; total rounds only counts observed round progression. Kills, headshots, revives and downs use each match's highest reported counters.</p>
|
||||
<p>Highest reported score is the largest observed points balance, not total points earned. Completion requires the player to appear in the final report. Records stay with the same GUID across both games when a player changes names, and the latest reported name is displayed.</p>
|
||||
</details>
|
||||
</section>
|
||||
@@ -98,7 +146,7 @@
|
||||
<style>
|
||||
.profile { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; }
|
||||
.identity { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-3); color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); }
|
||||
h2 { font-size: var(--text-lg); margin: 0 0 var(--space-3); scroll-margin-top: 6rem; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); }
|
||||
.stat { display: flex; flex-direction: column; gap: var(--space-2); padding: var(--space-5); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface); }
|
||||
@@ -110,7 +158,13 @@
|
||||
.panel { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||
.panel-heading { padding: var(--space-5); }
|
||||
.panel-heading p, .note { margin: 0; color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
|
||||
.scope-controls { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-3); padding: 0 var(--space-5) var(--space-4); }
|
||||
.scope-controls label { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
.scope-controls select { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--border-subtle); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-primary); font: inherit; }
|
||||
.scope-rank { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); padding: 0 var(--space-5) var(--space-5); font-size: var(--text-sm); }
|
||||
.scope-rank p { margin: 0; }
|
||||
section > .note { margin-top: var(--space-3); }
|
||||
.mobile-records { display: none; }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: var(--space-4); border-bottom: 1px solid var(--border-subtle); text-align: right; white-space: nowrap; }
|
||||
@@ -123,5 +177,5 @@
|
||||
.pagination { padding: var(--space-4); justify-content: space-between; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
|
||||
@media (max-width: 640px) { .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } }
|
||||
@media (max-width: 640px) { .mobile-records { display: block; } .desktop-records { display: none; } .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .stat { padding: var(--space-3); } .stat strong { font-size: var(--text-xl); } .scope-controls { grid-template-columns: minmax(0, 1fr); } .scope-rank { align-items: flex-start; flex-direction: column; } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
|
||||
|
||||
export function load({ params }: { params: { id: string } }) {
|
||||
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
|
||||
const match = getMatchDetail(Number(params.id));
|
||||
if (!match) error(404, 'Match not found');
|
||||
return { match };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { MatchDetail } from '$lib/match-history.js';
|
||||
import { playerProfileHref } from '$lib/leaderboard.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { formatDuration } from '$ui/duration.js';
|
||||
import HistoricalServerIdentity from '$ui/components/HistoricalServerIdentity.svelte';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||
import Card from '$ui/components/primitives/Card.svelte';
|
||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||
import Stat from '$ui/components/primitives/Stat.svelte';
|
||||
|
||||
let { data }: { data: { match: MatchDetail } } = $props();
|
||||
const match = $derived(data.match);
|
||||
const gameName = $derived(match.game === 't5' ? 'Black Ops I' : match.game === 't6' ? 'Black Ops II' : match.game.toUpperCase());
|
||||
const number = new Intl.NumberFormat('en-US');
|
||||
const stats = [
|
||||
{ key: 'highestRound', label: 'Highest round' },
|
||||
{ key: 'kills', label: 'Kills' },
|
||||
{ key: 'headshots', label: 'Headshots' },
|
||||
{ key: 'revives', label: 'Revives' },
|
||||
{ key: 'downs', label: 'Downs' },
|
||||
{ key: 'roundsSurvived', label: 'Rounds survived' },
|
||||
{ key: 'bestScore', label: 'Highest reported score' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{match.map} · Match #{match.id} · Electric Cherry</title></svelte:head>
|
||||
|
||||
<div class="match-page">
|
||||
<SectionHeader title={match.map} eyebrow={`Match #${match.id}`} subtitle={`${gameName} · Recorded match results`}>
|
||||
{#snippet crumbs()}<a href="/matches">← Match History</a>{/snippet}
|
||||
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
|
||||
</SectionHeader>
|
||||
|
||||
<section class="summary" aria-label="Match summary">
|
||||
<Card><Stat label="Final round" value={match.finalRound} tone="accent" size="lg" tween={false} /></Card>
|
||||
<Card><Stat label="Game time" value={match.durationMs === null ? null : formatDuration(match.durationMs)} size="lg" tween={false} /></Card>
|
||||
<Card><Stat label="Unique players" value={match.uniquePlayers} size="lg" tween={false} /></Card>
|
||||
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card>
|
||||
</section>
|
||||
|
||||
<Card padding="lg">
|
||||
<h2>Match details</h2>
|
||||
<dl class="details">
|
||||
<div class="server"><dt>Server</dt><dd><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></dd></div>
|
||||
<div><dt>{match.startObserved ? 'Started' : 'First observed'}</dt><dd><time datetime={match.startedAt}>{$timeFormat.dateTime(match.startedAt)}</time></dd></div>
|
||||
<div><dt>Ended</dt><dd>{#if match.endedAt}<time datetime={match.endedAt}>{$timeFormat.dateTime(match.endedAt)}</time>{:else}—{/if}</dd></div>
|
||||
<div><dt>Players at start</dt><dd>{match.startPlayers ?? '—'}</dd></div>
|
||||
</dl>
|
||||
{#if !match.startObserved}<p class="note">The match start was not recorded. The first observation is shown above; the full game time is unknown.</p>{/if}
|
||||
</Card>
|
||||
|
||||
<section class="results" aria-labelledby="players-title">
|
||||
<div class="results-heading">
|
||||
<h2 id="players-title">Player results</h2>
|
||||
<p>Every recorded player, including those absent from the final report. Stats are the highest reported counters for this match.</p>
|
||||
</div>
|
||||
{#if match.players.length === 0}
|
||||
<EmptyState title="No player results recorded" description="This match has no saved player reports." />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (Scrollable table for keyboard users.) -->
|
||||
<div class="table-scroll" role="region" aria-label="Match player results" tabindex="0">
|
||||
<table>
|
||||
<thead><tr><th scope="col">Player</th><th scope="col">Participation</th>{#each stats as stat}<th scope="col">{stat.label}</th>{/each}<th scope="col">Last seen</th></tr></thead>
|
||||
<tbody>
|
||||
{#each match.players as player (player.guid)}
|
||||
<tr>
|
||||
<th scope="row">
|
||||
{#if player.canViewProfile}<a href={playerProfileHref(player.guid)}>{player.name}</a>{:else}{player.name}{/if}
|
||||
<small class="guid">{player.guid}</small>
|
||||
</th>
|
||||
<td class="participation"><Badge variant={player.presentAtEnd ? 'online' : 'neutral'}>{player.presentAtEnd ? 'At match end' : 'Not at match end'}</Badge>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td>
|
||||
{#each stats as stat}<td class:round={stat.key === 'highestRound'}>{player[stat.key] === null ? '—' : number.format(player[stat.key]!)}</td>{/each}
|
||||
<td><time datetime={player.lastSeenAt}>{$timeFormat.dateTime(player.lastSeenAt)}</time></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<details class="note">
|
||||
<summary>About these results</summary>
|
||||
<p>Kills, headshots, revives, downs, and score retain each player's highest reported value. Highest reported score is a points balance, not total points earned or necessarily the final score.</p>
|
||||
<p>Highest round includes late joins. Rounds survived counts only observed round progression while the player was present. “At match end” means the player appeared in the final report; other players keep their earlier stats. A dash means no value was recorded.</p>
|
||||
<p>Player names use the latest recorded identity. Times reflect when reports were received and use your selected timezone. These results remain available after the server is deleted.</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.match-page { display: grid; gap: var(--space-6); min-width: 0; }
|
||||
a { color: var(--accent-primary-hover); }
|
||||
a:focus-visible, .table-scroll:focus-visible, summary:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
|
||||
h2 { margin: 0; font-size: var(--text-md); }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--space-4); }
|
||||
.details { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-5); margin: var(--space-5) 0 0; }
|
||||
.server { grid-column: 1 / -1; }
|
||||
dt { margin-bottom: var(--space-2); color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
dd { margin: 0; font-size: var(--text-sm); overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
||||
.results { min-width: 0; border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||||
.results-heading { padding: var(--space-6); }
|
||||
.results-heading p { margin: var(--space-2) 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
|
||||
.table-scroll { max-width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
|
||||
th, td { padding: var(--space-4); text-align: right; border-bottom: 1px solid var(--border-subtle); white-space: nowrap; }
|
||||
thead th { background: var(--surface-sunken); color: var(--text-secondary); font-size: var(--text-xs); }
|
||||
th:first-child { text-align: left; min-width: 180px; max-width: 280px; white-space: normal; overflow-wrap: anywhere; }
|
||||
th:nth-child(2), .participation { text-align: left; }
|
||||
tbody tr:last-child th, tbody tr:last-child td { border-bottom: none; }
|
||||
td { font-variant-numeric: tabular-nums; }
|
||||
.round { color: var(--accent-primary-hover); }
|
||||
small { display: block; margin-top: var(--space-1); font-size: var(--text-xs); color: var(--text-tertiary); font-weight: normal; }
|
||||
.guid { font-family: var(--font-mono); }
|
||||
.note { color: var(--text-tertiary); font-size: var(--text-xs); line-height: 1.7; }
|
||||
p.note { margin: var(--space-4) 0 0; }
|
||||
summary { cursor: pointer; color: var(--text-secondary); }
|
||||
@media (max-width: 800px) { .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } .details { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
@media (max-width: 480px) { .summary { gap: var(--space-3); } .details { grid-template-columns: minmax(0, 1fr); } .results-heading { padding: var(--space-4); } }
|
||||
</style>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { LeaderboardMetric } from '../../src/lib/leaderboard.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
interface Sample {
|
||||
kills?: number; headshots?: number; downs?: number; rounds?: number; completed?: number;
|
||||
game?: string; map?: string; status?: 'active' | 'completed' | 'interrupted';
|
||||
}
|
||||
function addSamples(handle: Database.Database, guid: string, samples: Sample[]) {
|
||||
for (const sample of samples) {
|
||||
const { kills = 20, headshots = 10, downs = 1, rounds = 2, completed = 1,
|
||||
game = 't5', map = 'theater', status = 'completed' } = sample;
|
||||
const inserted = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status,
|
||||
highest_round, last_event, last_fingerprint)
|
||||
VALUES ('removed-server', ?, ?, ?, ?, '2026-09-01', '2026-09-02', '2026-09-02', ?, 20, 'game_end', 'test')`)
|
||||
.run(game, map, `${game}:${map}`, map, status);
|
||||
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, '2026-09-01', '2026-09-02')`).run(game, guid, guid);
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, headshots,
|
||||
downs, rounds_survived, completed, has_stats, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 20, ?, ?, ?, ?, ?, 1, '2026-09-01', '2026-09-02')`)
|
||||
.run(inserted.lastInsertRowid, guid, kills, headshots, downs, rounds, completed);
|
||||
}
|
||||
}
|
||||
const samples = (count: number, sample: Sample = {}) => Array.from({ length: count }, () => ({ ...sample }));
|
||||
|
||||
it('calculates every efficiency measure from aggregate counts and preserves raw ratios', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addSamples(db(), 'weighted', [
|
||||
{ kills: 1, headshots: 1, downs: 1, rounds: 1, completed: 0 },
|
||||
{ kills: 99, headshots: 9, downs: 9, rounds: 9 },
|
||||
...samples(3, { kills: 0, headshots: 0, downs: 0, rounds: 0, completed: 0 })
|
||||
]);
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const entry = getLeaderboard().entries[0];
|
||||
expect(entry).toMatchObject({ kills: 100, headshots: 10, downs: 10, roundsSurvived: 10,
|
||||
gamesPlayed: 5, gamesCompleted: 1, headshotRate: 0.1, killsPerDown: 10,
|
||||
averageKills: 20, averageRounds: 2, completionRate: 0.2 });
|
||||
const { efficiencyMetrics, efficiencyValue } = await import('../../src/lib/leaderboard-efficiency.js');
|
||||
for (const { key } of efficiencyMetrics) {
|
||||
expect(entry[key]).toBe(efficiencyValue(entry, key));
|
||||
expect(getLeaderboard({ metric: key }).entries).toEqual([entry]);
|
||||
}
|
||||
});
|
||||
|
||||
it('qualifies at five games, one hundred kills and five downs, with each metric enforcing its own sample', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addSamples(db(), 'exact', samples(5));
|
||||
addSamples(db(), 'four-games', samples(4, { kills: 1000, headshots: 1000, downs: 5 }));
|
||||
addSamples(db(), 'ninety-nine-kills', [{ kills: 19 }, ...samples(4)]);
|
||||
addSamples(db(), 'four-downs', [{ downs: 0 }, ...samples(4)]);
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const ids = (metric: LeaderboardMetric) =>
|
||||
getLeaderboard({ metric }).entries.map(entry => entry.guid).sort();
|
||||
expect(ids('headshotRate')).toEqual(['exact', 'four-downs']);
|
||||
expect(ids('killsPerDown')).toEqual(['exact', 'ninety-nine-kills']);
|
||||
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
||||
expect(ids(metric)).toEqual(['exact', 'four-downs', 'ninety-nine-kills']);
|
||||
}
|
||||
expect(getLeaderboard({ metric: 'kills' }).total).toBe(4);
|
||||
expect(getLeaderboard({ metric: 'headshotRate' }).summary).toEqual(getLeaderboard().summary);
|
||||
});
|
||||
|
||||
it('keeps undefined ratios null and unranked while ranking legitimate zero efficiencies', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addSamples(db(), 'zero', samples(5, { kills: 0, headshots: 0, downs: 0, rounds: 0, completed: 0 }));
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard().entries[0]).toMatchObject({ headshotRate: null, killsPerDown: null,
|
||||
averageKills: 0, averageRounds: 0, completionRate: 0 });
|
||||
for (const metric of ['headshotRate', 'killsPerDown'] as const) {
|
||||
expect(getLeaderboard({ metric })).toMatchObject({ entries: [], total: 0,
|
||||
summary: { players: 1, matches: 5, kills: 0 } });
|
||||
}
|
||||
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
||||
expect(getLeaderboard({ metric })).toMatchObject({ total: 1, entries: [{ guid: 'zero', rank: 1 }] });
|
||||
}
|
||||
addSamples(db(), 'no-kills', samples(5, { kills: 0, headshots: 0, downs: 1 }));
|
||||
expect(getLeaderboard({ metric: 'killsPerDown' }).entries).toEqual([
|
||||
expect.objectContaining({ guid: 'no-kills', killsPerDown: 0, rank: 1 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('recomputes qualification within selected games and maps using completed matches only', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addSamples(db(), 'split', [...samples(3), ...samples(2, { game: 't6', map: 'town' })]);
|
||||
addSamples(db(), 'unfinished', [...samples(4), { status: 'active', kills: 10000 },
|
||||
{ status: 'interrupted', kills: 10000 }]);
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard({ metric: 'averageKills' })).toMatchObject({ total: 1,
|
||||
entries: [{ guid: 'split', gamesPlayed: 5, averageKills: 20 }], summary: { players: 2, matches: 9, kills: 180 } });
|
||||
expect(getLeaderboard({ metric: 'averageKills', game: 't5' })).toMatchObject({ total: 0, entries: [],
|
||||
summary: { players: 2, matches: 7, kills: 140 } });
|
||||
expect(getLeaderboard({ metric: 'averageKills', map: 't6:town' })).toMatchObject({ total: 0, entries: [],
|
||||
summary: { players: 1, matches: 2, kills: 40 } });
|
||||
addSamples(db(), 'split', samples(2));
|
||||
expect(getLeaderboard({ metric: 'averageKills', game: 't5', map: 't5:theater' }).entries).toEqual([
|
||||
expect.objectContaining({ guid: 'split', gamesPlayed: 5, rank: 1 })
|
||||
]);
|
||||
});
|
||||
|
||||
it('qualifies before competition ranking, then searches and paginates without changing scope summaries', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
addSamples(handle, 'Ace', samples(5, { kills: 50 }));
|
||||
addSamples(handle, 'Blake', samples(5, { kills: 50 }));
|
||||
addSamples(handle, 'Casey', samples(5, { kills: 10 }));
|
||||
addSamples(handle, 'Small sample', samples(4, { kills: 1000 }));
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t6', 'Casey', 'Current name', '2026-09-01', '2026-09-03')`).run();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const full = getLeaderboard({ metric: 'averageKills', game: 't5' });
|
||||
expect(full.entries.map(entry => [entry.name, entry.rank])).toEqual([['Ace', 1], ['Blake', 1], ['Current name', 3]]);
|
||||
const searched = getLeaderboard({ metric: 'averageKills', game: 't5', q: 'current NAME', limit: 1 });
|
||||
expect(searched).toMatchObject({ total: 1, entries: [{ guid: 'Casey', rank: 3 }], summary: full.summary });
|
||||
expect(getLeaderboard({ metric: 'averageKills', game: 't5', q: 'Small' })).toMatchObject({ total: 0, entries: [], summary: full.summary });
|
||||
const page = getLeaderboard({ metric: 'averageKills', game: 't5', limit: 1, offset: 1 });
|
||||
expect(page).toMatchObject({ total: 3, entries: [{ guid: 'Blake', rank: 1 }], summary: full.summary });
|
||||
expect(getLeaderboard({ metric: 'averageKills', offset: 3 })).toMatchObject({ total: 3, entries: [], summary: full.summary });
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const response = GET({ url: new URL('http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1') });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ metric: 'averageKills', total: 1,
|
||||
entries: [{ guid: 'Casey', rank: 3, averageKills: 10, gamesPlayed: 5 }], summary: full.summary });
|
||||
});
|
||||
|
||||
it('accepts each efficiency metric through the API when the qualified group is empty', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addSamples(db(), 'small', samples(4));
|
||||
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
for (const { key } of efficiencyMetrics) {
|
||||
const response = GET({ url: new URL(`http://localhost/api/leaderboard?metric=${key}`) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ metric: key, entries: [], total: 0,
|
||||
summary: { players: 1, matches: 4, kills: 80 } });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PlayerStats } from '../../src/lib/leaderboard.js';
|
||||
import {
|
||||
EFFICIENCY_MIN_DOWNS, EFFICIENCY_MIN_GAMES, EFFICIENCY_MIN_KILLS,
|
||||
efficiencyEligible, efficiencyMetrics, efficiencyRequirement, efficiencySample, efficiencyValue,
|
||||
isEfficiencyMetric, type EfficiencyMetric
|
||||
} from '../../src/lib/leaderboard-efficiency.js';
|
||||
|
||||
const stats = (overrides: Partial<PlayerStats> = {}): PlayerStats => ({
|
||||
highestRound: 20, kills: 100, roundsSurvived: 47, headshots: 31, revives: 7, downs: 5,
|
||||
gamesPlayed: 5, gamesCompleted: 3, bestKills: 50, bestScore: 12000, ...overrides
|
||||
});
|
||||
|
||||
describe('efficiency ranking qualification', () => {
|
||||
it.each(efficiencyMetrics)('requires five ended games for $key even when other counters are large', ({ key }) => {
|
||||
expect(efficiencyEligible(stats({ gamesPlayed: EFFICIENCY_MIN_GAMES - 1, kills: 10000, downs: 100 }), key)).toBe(false);
|
||||
expect(efficiencyEligible(stats({ gamesPlayed: EFFICIENCY_MIN_GAMES }), key)).toBe(true);
|
||||
});
|
||||
|
||||
it('requires enough kills for headshot rate and enough downs for kills per down', () => {
|
||||
expect(efficiencyEligible(stats({ kills: EFFICIENCY_MIN_KILLS - 1 }), 'headshotRate')).toBe(false);
|
||||
expect(efficiencyEligible(stats({ kills: EFFICIENCY_MIN_KILLS }), 'headshotRate')).toBe(true);
|
||||
expect(efficiencyEligible(stats({ downs: EFFICIENCY_MIN_DOWNS - 1 }), 'killsPerDown')).toBe(false);
|
||||
expect(efficiencyEligible(stats({ downs: EFFICIENCY_MIN_DOWNS }), 'killsPerDown')).toBe(true);
|
||||
expect(efficiencyEligible(stats({ kills: 0 }), 'killsPerDown')).toBe(true);
|
||||
});
|
||||
|
||||
it('counts departed players in ended-game samples and applies no extra counter minimum to game averages', () => {
|
||||
const departed = stats({ kills: 0, headshots: 0, downs: 0, roundsSurvived: 0, gamesCompleted: 0 });
|
||||
for (const metric of ['averageKills', 'averageRounds', 'completionRate'] as const) {
|
||||
expect(efficiencyEligible(departed, metric)).toBe(true);
|
||||
expect(efficiencyValue(departed, metric)).toBe(0);
|
||||
}
|
||||
expect(efficiencyValue(stats({ gamesPlayed: 5, gamesCompleted: 3 }), 'completionRate')).toBe(3 / 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('efficiency values', () => {
|
||||
it.each([
|
||||
['headshotRate', { kills: 0 }], ['killsPerDown', { downs: 0 }],
|
||||
['averageKills', { gamesPlayed: 0 }], ['averageRounds', { gamesPlayed: 0 }],
|
||||
['completionRate', { gamesPlayed: 0 }]
|
||||
] as Array<[EfficiencyMetric, Partial<PlayerStats>]>)('leaves %s undefined with no denominator', (metric, zeroDenominator) => {
|
||||
expect(efficiencyValue(stats(zeroDenominator), metric)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses ratios of totals instead of equally weighting games with different sample sizes', () => {
|
||||
// One game has 1/1 headshots and 1 kill/down; another has 9/99 and 99/9.
|
||||
const aggregate = stats({ gamesPlayed: 2, headshots: 1 + 9, kills: 1 + 99, downs: 1 + 9 });
|
||||
expect(efficiencyValue(aggregate, 'headshotRate')).toBe((1 + 9) / (1 + 99));
|
||||
expect(efficiencyValue(aggregate, 'headshotRate')).not.toBe((1 / 1 + 9 / 99) / 2);
|
||||
expect(efficiencyValue(aggregate, 'killsPerDown')).toBe((1 + 99) / (1 + 9));
|
||||
expect(efficiencyValue(aggregate, 'killsPerDown')).not.toBe((1 / 1 + 99 / 9) / 2);
|
||||
expect(efficiencyEligible(aggregate, 'headshotRate')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves fractional ratios and low samples without rounding, capping or inventing a denominator', () => {
|
||||
const small = stats({ gamesPlayed: 3, gamesCompleted: 1, kills: 2, headshots: 1, downs: 3, roundsSurvived: 7 });
|
||||
expect(efficiencyValue(small, 'headshotRate')).toBe(1 / 2);
|
||||
expect(efficiencyValue(small, 'killsPerDown')).toBe(2 / 3);
|
||||
expect(efficiencyValue(small, 'averageKills')).toBe(2 / 3);
|
||||
expect(efficiencyValue(small, 'averageRounds')).toBe(7 / 3);
|
||||
expect(efficiencyValue(small, 'completionRate')).toBe(1 / 3);
|
||||
expect(efficiencyValue(stats({ kills: 1000000, downs: 1 }), 'killsPerDown')).toBe(1000000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('efficiency explanations', () => {
|
||||
it('shows the actual numerator and denominator with their units', () => {
|
||||
const player = stats({ kills: 1200 });
|
||||
expect(efficiencySample(player, 'headshotRate')).toBe('31 headshots / 1,200 kills');
|
||||
expect(efficiencySample(player, 'killsPerDown')).toBe('1,200 kills / 5 downs');
|
||||
expect(efficiencySample(player, 'averageKills')).toBe('1,200 kills / 5 ended games');
|
||||
expect(efficiencySample(player, 'averageRounds')).toBe('47 observed rounds / 5 ended games');
|
||||
expect(efficiencySample(player, 'completionRate')).toBe('3 finishes / 5 ended games');
|
||||
expect(efficiencySample(stats({ kills: 1, downs: 0 }), 'killsPerDown')).toBe('1 kill / 0 downs');
|
||||
});
|
||||
|
||||
it('identifies ratio metrics and publishes the thresholds used to qualify them', () => {
|
||||
for (const { key, description } of efficiencyMetrics) {
|
||||
expect(isEfficiencyMetric(key)).toBe(true);
|
||||
expect(description).toContain('divided by');
|
||||
expect(efficiencyRequirement(key)).toContain(`${EFFICIENCY_MIN_GAMES} ended games`);
|
||||
}
|
||||
expect(efficiencyRequirement('headshotRate')).toContain(`${EFFICIENCY_MIN_KILLS} kills`);
|
||||
expect(efficiencyRequirement('killsPerDown')).toContain(`${EFFICIENCY_MIN_DOWNS} downs`);
|
||||
expect(efficiencyMetrics.filter(metric => metric.format === 'percent').map(metric => metric.key))
|
||||
.toEqual(['headshotRate', 'completionRate']);
|
||||
expect(isEfficiencyMetric('kills')).toBe(false);
|
||||
expect(isEfficiencyMetric('')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ async function matches() {
|
||||
}>;
|
||||
}
|
||||
|
||||
it('starts counting at round 2, preserves early stats, and excludes players who only stayed for round 1', async () => {
|
||||
it('publishes round 2 or higher games only after game end, preserving early stats and excluding round 1 departures', async () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
@@ -45,14 +45,18 @@ it('starts counting at round 2, preserves early stats, and excludes players who
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
|
||||
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
|
||||
expect(getLeaderboard()).toMatchObject({ total: 1, entries: [expect.objectContaining({
|
||||
highestRound: 2, kills: 6, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 0
|
||||
})], summary: { players: 1, matches: 1, kills: 6 } });
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
expect(serverMatchHistory()).toMatchObject({ total: 1, matches: [{ status: 'active' }] });
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
await report('game_end', 2, [player(undefined, { kills: 10 })]);
|
||||
const board = getLeaderboard();
|
||||
const profile = getPlayerProfile(player().guid);
|
||||
const history = serverMatchHistory();
|
||||
expect(board).toMatchObject({ total: 1, entries: [expect.objectContaining({
|
||||
highestRound: 2, kills: 10, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 1
|
||||
})], summary: { players: 1, matches: 1, kills: 10 } });
|
||||
expect(getPlayerProfile(departed.guid)).toBeNull();
|
||||
expect(history).toMatchObject({ total: 1, matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] });
|
||||
await report('game_start');
|
||||
await report('game_end', 1, [player(undefined, { kills: 5000 })]);
|
||||
@@ -225,12 +229,19 @@ it('credits the highest round without inventing survival before the first midgam
|
||||
});
|
||||
|
||||
it('keeps disconnected player records while completing only the final roster', async () => {
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const other = player('4229696', { kills: 40, name: 'Other player' });
|
||||
await report('game_start', 0, [player(), other]);
|
||||
await report('round_change', 4, [player(undefined, { kills: 80 }), other]);
|
||||
expect(getLeaderboard()).toMatchObject({ entries: [], total: 0 });
|
||||
expect(getPlayerProfile(other.guid)).toBeNull();
|
||||
await report('game_end', 5, [player(undefined, { kills: 100 })]);
|
||||
expect(await records(other.guid)).toEqual([expect.objectContaining({ kills: 40, highest_round: 4, rounds_survived: 3, completed: 0 })]);
|
||||
expect(await records()).toEqual([expect.objectContaining({ kills: 100, highest_round: 5, rounds_survived: 4, completed: 1 })]);
|
||||
expect(getLeaderboard()).toMatchObject({ total: 2, summary: { matches: 1, players: 2, kills: 140 } });
|
||||
expect(getPlayerProfile(other.guid)).toMatchObject({ stats: { kills: 40, highestRound: 4,
|
||||
roundsSurvived: 3, gamesPlayed: 1, gamesCompleted: 0 }, matches: [{ status: 'completed', completed: 0 }] });
|
||||
});
|
||||
|
||||
it('starts separate matches for game starts, map changes, round resets, and new rounds after an end', async () => {
|
||||
@@ -329,10 +340,12 @@ it('keeps deleted match identities and starts a separate match when the server n
|
||||
server, server_deleted_at: expect.any(String), status: 'interrupted'
|
||||
});
|
||||
expect(history.find(match => match.id !== original.id)).toMatchObject({ server, server_deleted_at: null, status: 'active' });
|
||||
expect(getPlayerProfile(player().guid)).toBeNull();
|
||||
await report('game_end', 8, [player(undefined, { kills: 120 })]);
|
||||
const profile = getPlayerProfile(player().guid)!;
|
||||
expect(profile.stats.gamesPlayed).toBe(2);
|
||||
expect(profile.matches.find(match => match.id === original.id)?.server_deleted_at).toEqual(expect.any(String));
|
||||
expect(profile.stats.kills).toBe(240);
|
||||
expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
|
||||
expect(profile.matches).toEqual([expect.objectContaining({ id: history.find(match => match.id !== original.id)!.id,
|
||||
server_deleted_at: null, status: 'completed' })]);
|
||||
});
|
||||
|
||||
it('does not fabricate players for an empty match', async () => {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { leaderboardReturnHref, playerContextHref, profileViewHref } from '../../src/lib/leaderboard-navigation.js';
|
||||
|
||||
describe('leaderboard profile navigation', () => {
|
||||
it('preserves leaderboard scope, search and page while starting profile history on its first page', () => {
|
||||
const source = '/leaderboard?game=t6&map=t6%3Atown&metric=kills&q=Player+One&offset=50';
|
||||
const profile = new URL(playerContextHref('001/2?#', source), 'http://localhost');
|
||||
expect(profile.pathname).toBe('/leaderboard/players/001%2F2%3F%23');
|
||||
expect(profile.searchParams.get('returnTo')).toBe(source);
|
||||
expect(profile.searchParams.get('game')).toBe('t6');
|
||||
expect(profile.searchParams.get('map')).toBe('t6:town');
|
||||
expect(profile.searchParams.get('metric')).toBe('kills');
|
||||
expect(profile.searchParams.has('q')).toBe(false);
|
||||
expect(profile.searchParams.has('offset')).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps profile filters and return context through pagination and resets history pages on filter changes', () => {
|
||||
const current = new URL('http://localhost/leaderboard/players/101?returnTo=%2Fleaderboard%3Fq%3DAce&game=t5&map=t5%3Atheater&metric=highestRound&offset=25');
|
||||
const next = new URL(profileViewHref(current, { offset: '50' }), current);
|
||||
expect(next.searchParams.get('offset')).toBe('50');
|
||||
expect(next.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace');
|
||||
expect(next.searchParams.get('map')).toBe('t5:theater');
|
||||
const filtered = new URL(profileViewHref(current, { game: 't6', map: '' }), current);
|
||||
expect(filtered.searchParams.has('offset')).toBe(false);
|
||||
expect(filtered.searchParams.has('map')).toBe(false);
|
||||
expect(filtered.searchParams.get('game')).toBe('t6');
|
||||
expect(filtered.searchParams.get('returnTo')).toBe('/leaderboard?q=Ace');
|
||||
});
|
||||
|
||||
it('restricts supplied return destinations to the local leaderboard', () => {
|
||||
for (const value of [null, '', 'https://example.com/leaderboard', '//example.com/leaderboard', '/leaderboard/players/101', '/leaderboard/../settings', '/leaderboard\\@example.com']) {
|
||||
expect(leaderboardReturnHref(value)).toBe('/leaderboard');
|
||||
}
|
||||
expect(leaderboardReturnHref('/leaderboard?q=A%26B&unrelated=drop#fragment')).toBe('/leaderboard?q=A%26B');
|
||||
});
|
||||
|
||||
it('uses the displayed fallback scope when a shared leaderboard URL contains unknown filters', () => {
|
||||
const source = '/leaderboard?game=t7&metric=unknown&q=101';
|
||||
const profile = new URL(playerContextHref('101', source), 'http://localhost');
|
||||
expect(profile.searchParams.get('returnTo')).toBe(source);
|
||||
expect(profile.searchParams.has('game')).toBe(false);
|
||||
expect(profile.searchParams.has('metric')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,11 @@ async function seedRecords() {
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`);
|
||||
const iso = (day: number) => `2026-09-0${day}T00:00:00.000Z`;
|
||||
match.run(1, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(1), iso(2), 'completed', 10);
|
||||
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'active', 8);
|
||||
match.run(2, 't5', 'zombie_theater', 't5:theater', 'Kino Der Toten', iso(2), iso(3), 'completed', 8);
|
||||
match.run(3, 't5', 'zombie_coast', 't5:coast', 'Call of the Dead', iso(3), iso(4), 'completed', 4);
|
||||
match.run(4, 't6', 'zm_transit', 't6:town', 'Town Survival', iso(4), iso(5), 'completed', 20);
|
||||
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'interrupted', 15);
|
||||
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'active', 2);
|
||||
match.run(5, 't6', 'zm_transit', 't6:farm', 'Farm Survival', iso(5), iso(6), 'completed', 15);
|
||||
match.run(6, 't5', 'custom_map', 't5:custom_map', 'Custom Map', iso(1), iso(7), 'completed', 2);
|
||||
player.run('t5', '101', 'Ace');
|
||||
player.run('t5', '202', 'Blake');
|
||||
player.run('t5', '303', 'Casey');
|
||||
@@ -33,6 +33,7 @@ async function seedRecords() {
|
||||
player.run('t6', '505', 'Eve');
|
||||
record.run(1, '101', 100, 10, 9, 50, 2, 1, 1000, 1, 1, iso(2));
|
||||
record.run(1, '202', 100, 10, 9, 40, 3, 2, 2000, 1, 1, iso(2));
|
||||
// An observed match ending also publishes saved stats for players who departed before it.
|
||||
record.run(2, '101', 150, 8, 7, 60, 1, 2, 3000, 0, 1, iso(3));
|
||||
record.run(2, '303', 70, 8, 7, 20, 0, 0, 1200, 0, 1, iso(3));
|
||||
record.run(3, '101', 20, 4, 3, 10, 1, 0, 500, 1, 1, iso(4));
|
||||
@@ -44,6 +45,38 @@ async function seedRecords() {
|
||||
}
|
||||
|
||||
describe('persisted leaderboard queries', () => {
|
||||
it('excludes active and interrupted matches from ranks, summaries, map options and every part of player profiles', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const { leaderboardMetrics } = await import('../../src/lib/leaderboard.js');
|
||||
const boards = leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }));
|
||||
const profile = getPlayerProfile('101');
|
||||
for (const [index, status] of ['active', 'interrupted'].entries()) {
|
||||
const id = index + 10;
|
||||
handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(id, server, game, map, map_key, map_name, started_at, last_received_at,
|
||||
status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 'removed-server', 't5', ?, ?, 'Unfinished Map',
|
||||
'2026-09-20', '2026-09-20', ?, 99, 'heartbeat', 'unfinished')`)
|
||||
.run(id, status, `t5:${status}`, status);
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t5', ?, ?, '2026-09-20', '2026-09-20')`).run(status, status);
|
||||
for (const guid of ['101', status]) {
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players
|
||||
(match_id, guid, highest_round, rounds_survived, kills, headshots, revives, downs, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 99, 99, 99999, 99999, 99999, 99999, 99999, 1, 1, '2026-09-20', '2026-09-20')`)
|
||||
.run(id, guid);
|
||||
}
|
||||
expect(getPlayerProfile(status)).toBeNull();
|
||||
expect(getLeaderboard({ map: `t5:${status}` })).toMatchObject({ entries: [], total: 0,
|
||||
summary: { players: 0, matches: 0, kills: 0, highestRound: 0, firstTrackedAt: null, updatedAt: null } });
|
||||
}
|
||||
expect(leaderboardMetrics.map(({ key }) => getLeaderboard({ metric: key, limit: 2, offset: 1 }))).toEqual(boards);
|
||||
expect(getPlayerProfile('101')).toEqual(profile);
|
||||
});
|
||||
|
||||
it('excludes persisted round 0/1 matches from every history, total, ranking and map option', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
@@ -90,7 +123,13 @@ describe('persisted leaderboard queries', () => {
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
|
||||
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
|
||||
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000,
|
||||
lastSeen: '2026-09-05T00:00:00.000Z'
|
||||
headshotRate: 320 / 770, killsPerDown: 770 / 4, averageKills: 770 / 4,
|
||||
averageRounds: 38 / 4, completionRate: 3 / 4,
|
||||
lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||
}
|
||||
}]);
|
||||
expect(board.total).toBe(4);
|
||||
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 4]]);
|
||||
@@ -108,14 +147,26 @@ describe('persisted leaderboard queries', () => {
|
||||
expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
|
||||
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3,
|
||||
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z'
|
||||
headshotRate: 120 / 270, killsPerDown: 270 / 3, averageKills: 270 / 3,
|
||||
averageRounds: 19 / 3, completionRate: 2 / 3,
|
||||
gamesCompleted: 2, bestKills: 150, bestScore: 3000, lastSeen: '2026-09-04T00:00:00.000Z', records: {
|
||||
highestRound: expect.objectContaining({ matchId: 1, value: 10 }),
|
||||
bestKills: expect.objectContaining({ matchId: 2, value: 150 }),
|
||||
bestScore: expect.objectContaining({ matchId: 2, value: 3000 })
|
||||
}
|
||||
});
|
||||
expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
|
||||
const t6 = getLeaderboard({ game: 't6' });
|
||||
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
|
||||
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
|
||||
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1,
|
||||
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z'
|
||||
headshotRate: 200 / 500, killsPerDown: 500, averageKills: 500,
|
||||
averageRounds: 19, completionRate: 1,
|
||||
gamesCompleted: 1, bestKills: 500, bestScore: 5000, lastSeen: '2026-09-05T00:00:00.000Z', records: {
|
||||
highestRound: expect.objectContaining({ matchId: 4, value: 20 }),
|
||||
bestKills: expect.objectContaining({ matchId: 4, value: 500 }),
|
||||
bestScore: expect.objectContaining({ matchId: 4, value: 5000 })
|
||||
}
|
||||
});
|
||||
expect(t6).toMatchObject({ total: 2, summary: { players: 2, matches: 2, kills: 700, roundsSurvived: 33 } });
|
||||
});
|
||||
@@ -142,6 +193,9 @@ describe('persisted leaderboard queries', () => {
|
||||
expect(search.entries.map(entry => [entry.name, entry.rank])).toEqual([['Casey', 3]]);
|
||||
expect(search.total).toBe(1);
|
||||
expect(search.summary).toEqual(board.summary);
|
||||
expect(getLeaderboard({ game: 't5', q: 'no matching player' })).toMatchObject({
|
||||
entries: [], total: 0, summary: board.summary, maps: board.maps
|
||||
});
|
||||
expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
|
||||
.toMatchObject({ name: 'Blake', rank: 2 });
|
||||
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 4 });
|
||||
@@ -175,7 +229,22 @@ describe('persisted leaderboard queries', () => {
|
||||
expect(first.total).toBe(4);
|
||||
expect(second.total).toBe(4);
|
||||
expect(second.summary).toEqual(first.summary);
|
||||
expect(getLeaderboard({ metric: 'kills', limit: 2, offset: 4 }).entries).toEqual([]);
|
||||
expect(getLeaderboard({ metric: 'kills', limit: 2, offset: 4 })).toMatchObject({
|
||||
entries: [], total: first.total, summary: first.summary, maps: first.maps
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps recorded participation in summaries when an identity is unavailable for display', async () => {
|
||||
const handle = await seedRecords();
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const original = getLeaderboard();
|
||||
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
|
||||
const board = getLeaderboard();
|
||||
expect(board.total).toBe(original.total - 1);
|
||||
expect(board.entries.some(entry => entry.guid === '101')).toBe(false);
|
||||
expect(board.summary).toEqual(original.summary);
|
||||
expect(board.maps).toEqual(original.maps);
|
||||
expect(getLeaderboard({ q: '101' })).toMatchObject({ entries: [], total: 0, summary: original.summary });
|
||||
});
|
||||
|
||||
it('ignores unsupported games in identities, totals, rankings and map options', async () => {
|
||||
@@ -232,7 +301,7 @@ describe('player profiles', () => {
|
||||
expect(profile.maps[0]).toMatchObject({ game: 't6', key: 't6:town', highestRound: 20, kills: 500, gamesPlayed: 1, gamesCompleted: 1 });
|
||||
expect(profile.maps[1]).toMatchObject({ game: 't5', key: 't5:theater', highestRound: 10, kills: 250, gamesPlayed: 2, gamesCompleted: 1 });
|
||||
expect(profile.matches.map(match => [match.id, match.game])).toEqual([[4, 't6'], [3, 't5'], [2, 't5'], [1, 't5']]);
|
||||
expect(profile.matches[2]).toMatchObject({ status: 'active', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(profile.matches[2]).toMatchObject({ status: 'completed', completed: 0, server: 'removed-server', kills: 150 });
|
||||
expect(getPlayerProfile('505')).toMatchObject({ name: 'Eve', stats: { kills: 200, gamesPlayed: 1 },
|
||||
maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] });
|
||||
expect(getPlayerProfile('404')).toBeNull();
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import type { GameTelemetry, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => fixture.cleanup());
|
||||
const timestamp = (minute: number) => new Date(Date.UTC(2026, 8, 18, 20, minute)).toISOString();
|
||||
const player = (guid: string, kills = 0): TelemetryPlayerStats => ({
|
||||
guid, name: guid, kills, headshots: 0, downs: 0, revives: 0, score: 0
|
||||
});
|
||||
|
||||
function seed(handle: Database.Database, id: number, options: {
|
||||
game?: string; map?: string; guid?: string; round?: number; kills?: number; score?: number;
|
||||
ended?: string | null; received?: string; status?: string; hasStats?: number; observed?: number | null;
|
||||
} = {}) {
|
||||
const { game = 't5', map = 'theater', guid = '101', round = 10, kills = 100, score = 1000,
|
||||
ended = timestamp(id), received = timestamp(id), status = 'completed', hasStats = 1, observed = null } = options;
|
||||
handle.prepare(`INSERT INTO leaderboard_matches (id, server, game, map, map_key, map_name, started_at,
|
||||
ended_at, last_received_at, status, highest_round, last_event, last_fingerprint)
|
||||
VALUES (?, 't5_zombie_theater_1', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'game_end', ?)`)
|
||||
.run(id, game, map, `${game}:${map}`, `${map} name`, timestamp(0), ended, received, status, round, String(id));
|
||||
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, ?, ?)`).run(game, guid, guid, timestamp(0), received);
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, best_score,
|
||||
has_stats, completed, first_seen_at, last_seen_at, first_observed_round)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`).run(id, guid, round, kills, score, hasStats, timestamp(0), received, observed);
|
||||
}
|
||||
|
||||
it('links each best to its exact player/game/map scope, including profile map records', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
seed(handle, 1, { round: 20, kills: 100, score: 500 });
|
||||
seed(handle, 2, { round: 10, kills: 200, score: 2000 });
|
||||
seed(handle, 3, { map: 'moon', round: 30, kills: 300, score: 1000 });
|
||||
seed(handle, 4, { game: 't6', map: 'town', round: 40, kills: 400, score: 4000 });
|
||||
seed(handle, 5, { guid: '202', round: 50, kills: 500, score: 5000 });
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const overall = getLeaderboard({ q: '101' }).entries[0];
|
||||
expect(overall.rank).toBe(2);
|
||||
expect(overall.records.highestRound).toEqual({ matchId: 4, game: 't6', mapKey: 't6:town', mapName: 'town name',
|
||||
server: 't5_zombie_theater_1', startedAt: timestamp(0), endedAt: timestamp(4), startObserved: false,
|
||||
presentAtStart: null, firstObservedRound: null, value: 40 });
|
||||
for (const metric of ['highestRound', 'bestKills', 'bestScore'] as const) {
|
||||
expect(overall.records[metric]).toMatchObject({ matchId: 4, value: overall[metric] });
|
||||
}
|
||||
const t5 = getLeaderboard({ game: 't5', q: '101' }).entries[0].records;
|
||||
expect(t5.highestRound).toMatchObject({ matchId: 3, value: 30 });
|
||||
expect(t5.bestKills).toMatchObject({ matchId: 3, value: 300 });
|
||||
expect(t5.bestScore).toMatchObject({ matchId: 2, value: 2000 });
|
||||
const theater = getLeaderboard({ map: 't5:theater', q: '101' }).entries[0].records;
|
||||
expect(theater.highestRound).toMatchObject({ matchId: 1, value: 20 });
|
||||
expect(theater.bestKills).toMatchObject({ matchId: 2, value: 200 });
|
||||
expect(theater.bestScore).toMatchObject({ matchId: 2, value: 2000 });
|
||||
expect(getLeaderboard({ offset: 1, limit: 1 }).entries[0].records).toEqual(overall.records);
|
||||
const profile = getPlayerProfile('101')!;
|
||||
expect(profile.records).toEqual(overall.records);
|
||||
expect(profile.maps.find(map => map.key === 't5:theater')!.records).toEqual(theater);
|
||||
expect(profile.maps.find(map => map.key === 't5:moon')!.records.bestScore).toMatchObject({ matchId: 3, value: 1000 });
|
||||
});
|
||||
|
||||
it('resolves tied bests by earliest completion, receipt time for unknown endings, then match ID', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
seed(handle, 1, { ended: timestamp(5) });
|
||||
seed(handle, 2, { ended: timestamp(4), received: timestamp(1) });
|
||||
seed(handle, 3, { ended: null, received: timestamp(2) });
|
||||
seed(handle, 4, { ended: timestamp(2), received: timestamp(0) });
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
for (const records of [getLeaderboard().entries[0].records, getPlayerProfile('101')!.records]) {
|
||||
for (const record of Object.values(records)) expect(record).toMatchObject({ matchId: 3, endedAt: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('never uses unfinished, unsupported, nonqualifying or unrecorded stats as evidence', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
seed(handle, 1);
|
||||
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const board = getLeaderboard();
|
||||
const profile = getPlayerProfile('101');
|
||||
seed(handle, 2, { status: 'active', round: 99, kills: 9999, score: 9999 });
|
||||
seed(handle, 3, { status: 'interrupted', round: 99, kills: 9999, score: 9999 });
|
||||
seed(handle, 4, { game: 'custom', round: 99, kills: 9999, score: 9999 });
|
||||
seed(handle, 5, { round: 1, kills: 9999, score: 9999 });
|
||||
seed(handle, 6, { hasStats: 0, round: 99, kills: 9999, score: 9999 });
|
||||
expect(getLeaderboard()).toEqual(board);
|
||||
expect(getPlayerProfile('101')).toEqual(profile);
|
||||
});
|
||||
|
||||
it('records first observed rounds for start participants and late arrivals without overwriting on return', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
||||
const common = { server: 't5_zombie_theater_1', map: 'zombie_theater' };
|
||||
const report = (event: GameTelemetry['event'], round: number, players: TelemetryPlayerStats[], minute: number) => {
|
||||
recordLeaderboardTelemetry(db(), event === 'game_start'
|
||||
? { ...common, event, players, receivedAt: timestamp(minute) }
|
||||
: { ...common, event, round, players, receivedAt: timestamp(minute) });
|
||||
};
|
||||
report('game_start', 0, [player('101')], 0);
|
||||
report('heartbeat', 0, [player('101'), player('303')], 1);
|
||||
report('heartbeat', 50, [player('101', 100), player('202', 20)], 2);
|
||||
report('heartbeat', 51, [], 3);
|
||||
report('game_end', 52, [player('101', 200), player('202', 40), player('303')], 4);
|
||||
closeDb();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(getPlayerProfile('101')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
|
||||
firstObservedRound: 0, presentAtStart: true });
|
||||
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
|
||||
firstObservedRound: 50, presentAtStart: false });
|
||||
expect(getPlayerProfile('303')!.records.highestRound).toMatchObject({ value: 52, startObserved: true,
|
||||
firstObservedRound: 0, presentAtStart: false });
|
||||
});
|
||||
|
||||
it('migrates legacy participation as unknown before snapshot backfill and preserves it across updates and reopens', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
seed(handle, 1, { status: 'active' });
|
||||
handle.exec('ALTER TABLE leaderboard_match_players DROP COLUMN first_observed_round');
|
||||
handle.exec('ALTER TABLE leaderboard_match_players DROP COLUMN present_at_start');
|
||||
handle.prepare(`INSERT INTO servers (name, komodo_name, game, map)
|
||||
VALUES ('t5_zombie_moon_1', 'moon', 't5', 'zombie_moon')`).run();
|
||||
const saved: GameTelemetry = { event: 'game_end', server: 't5_zombie_moon_1', map: 'zombie_moon', round: 8,
|
||||
players: [player('202', 80)], receivedAt: timestamp(2) };
|
||||
handle.prepare('INSERT INTO server_telemetry (server, round, players, received_at, payload) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(saved.server, 8, 1, saved.receivedAt, JSON.stringify(saved));
|
||||
handle.prepare("DELETE FROM settings WHERE key = 'leaderboard_v1'").run();
|
||||
closeDb();
|
||||
expect(db().prepare('SELECT guid, first_observed_round, present_at_start FROM leaderboard_match_players ORDER BY guid').all()).toEqual([
|
||||
{ guid: '101', first_observed_round: null, present_at_start: null },
|
||||
{ guid: '202', first_observed_round: 8, present_at_start: 0 }
|
||||
]);
|
||||
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
||||
recordLeaderboardTelemetry(db(), { event: 'game_end', server: 't5_zombie_theater_1', map: 'theater', round: 11,
|
||||
players: [player('101', 150)], receivedAt: timestamp(3) });
|
||||
closeDb();
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
expect(getPlayerProfile('101')!.records.bestKills).toMatchObject({ matchId: 1, value: 150,
|
||||
firstObservedRound: null, presentAtStart: null });
|
||||
expect(getPlayerProfile('202')!.records.highestRound).toMatchObject({ firstObservedRound: 8,
|
||||
startObserved: false, presentAtStart: false });
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
let received = 0;
|
||||
const server = 't5_zombie_theater_1';
|
||||
const timestamp = (minute: number) => new Date(Date.UTC(2026, 8, 18, 20, minute)).toISOString();
|
||||
const player = (guid: string, stats: Partial<TelemetryPlayerStats> = {}): TelemetryPlayerStats => ({
|
||||
guid, name: `Player ${guid}`, kills: 0, headshots: 0, downs: 0, revives: 0, score: 0, ...stats
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
received = 0;
|
||||
fixture = await newFixture([]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare("INSERT INTO servers (name, komodo_name, game, map) VALUES (?, ?, 't5', 'zombie_theater')")
|
||||
.run(server, server);
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
async function report(event: GameEvent['event'], round = 0, players = [player('101')]) {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
||||
const common = { server, map: 'zombie_theater', receivedAt: timestamp(received++) };
|
||||
recordLeaderboardTelemetry(db(), event === 'game_start'
|
||||
? { ...common, event, players: players.map(({ guid, name }) => ({ guid, name })) }
|
||||
: { ...common, event, round, players });
|
||||
}
|
||||
|
||||
describe('completed match details', () => {
|
||||
it('returns the history summary and every participant with recorded counters, including departed and late players', async () => {
|
||||
await report('game_start', 0, [player('101'), player('202'), player('303')]);
|
||||
await report('round_change', 2, [player('101', { kills: 30, headshots: 10, downs: 1, revives: 2, score: 2000 }),
|
||||
player('202', { kills: 15, score: 1500 })]);
|
||||
await report('heartbeat', 2, [player('101', { kills: 40, headshots: 12, downs: 1, revives: 2, score: 1000 })]);
|
||||
await report('game_end', 4, [player('101', { kills: 50, headshots: 20, downs: 2, revives: 3, score: 500 }), player('404')]);
|
||||
const { getMatchDetail, serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const match = getMatchDetail(1)!;
|
||||
const { game, startObserved, players, ...summary } = match;
|
||||
expect(summary).toEqual(serverMatchHistory(server).matches[0]);
|
||||
expect({ ...summary, game, startObserved }).toMatchObject({
|
||||
id: 1, game: 't5', startObserved: true, server, status: 'completed',
|
||||
startedAt: timestamp(0), endedAt: timestamp(3), durationMs: 180_000,
|
||||
startPlayers: 3, uniquePlayers: 4, endPlayers: 2, finalRound: 4
|
||||
});
|
||||
expect(players.map(entry => entry.guid)).toEqual(['101', '202', '404', '303']);
|
||||
expect(players[0]).toEqual({ guid: '101', name: 'Player 101', hasStats: true, presentAtEnd: true,
|
||||
canViewProfile: true, firstSeenAt: timestamp(0), lastSeenAt: timestamp(3),
|
||||
highestRound: 4, roundsSurvived: 3, kills: 50, headshots: 20, downs: 2, revives: 3, bestScore: 2000 });
|
||||
expect(players[1]).toEqual({ guid: '202', name: 'Player 202', hasStats: true, presentAtEnd: false,
|
||||
canViewProfile: true, firstSeenAt: timestamp(0), lastSeenAt: timestamp(1),
|
||||
highestRound: 2, roundsSurvived: 1, kills: 15, headshots: 0, downs: 0, revives: 0, bestScore: 1500 });
|
||||
expect(players[2]).toMatchObject({ guid: '404', presentAtEnd: true, highestRound: 4, roundsSurvived: 0,
|
||||
firstSeenAt: timestamp(3), lastSeenAt: timestamp(3), kills: 0, bestScore: 0 });
|
||||
expect(players[3]).toEqual({ guid: '303', name: 'Player 303', hasStats: false, presentAtEnd: false,
|
||||
canViewProfile: false, firstSeenAt: timestamp(0), lastSeenAt: timestamp(0),
|
||||
highestRound: null, roundsSurvived: null, kills: null, headshots: null, downs: null, revives: null, bestScore: null });
|
||||
});
|
||||
|
||||
it('preserves an unknown start and an observed empty ending roster across database reopen', async () => {
|
||||
await report('heartbeat', 5, [player('101', { kills: 40 })]);
|
||||
await report('game_end', 6, []);
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
closeDb();
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
expect(getMatchDetail(1)).toMatchObject({ startObserved: false, startedAt: timestamp(0),
|
||||
startPlayers: null, durationMs: null, endedAt: timestamp(1), endPlayers: 0,
|
||||
uniquePlayers: 1, finalRound: 6, players: [expect.objectContaining({
|
||||
guid: '101', presentAtEnd: false, highestRound: 5, kills: 40, roundsSurvived: 0
|
||||
})] });
|
||||
});
|
||||
|
||||
it('loads an empty completed record without inventing player rows', async () => {
|
||||
await report('game_start', 0, []);
|
||||
await report('game_end', 2, []);
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
expect(getMatchDetail(1)).toMatchObject({ startPlayers: 0, uniquePlayers: 0, endPlayers: 0,
|
||||
startObserved: true, durationMs: 60_000, finalRound: 2, players: [] });
|
||||
});
|
||||
|
||||
it('preserves original match and server identity after deletion and reuse of the server name', async () => {
|
||||
await report('game_start');
|
||||
await report('game_end', 8, [player('101', { kills: 80 })]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const before = getMatchDetail(1)!;
|
||||
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
|
||||
const deleted = getMatchDetail(1)!;
|
||||
expect(deleted).toEqual({ ...before, server_deleted_at: expect.any(String) });
|
||||
db().prepare("INSERT INTO servers (name, komodo_name, game, map) VALUES (?, 'replacement', 't6', 'zm_transit')").run(server);
|
||||
const { recordLeaderboardTelemetry } = await import('../../src/lib/server/fleet/leaderboard-ingest.js');
|
||||
recordLeaderboardTelemetry(db(), { event: 'game_end', server, map: 'zm_transit', round: 20,
|
||||
players: [player('999', { kills: 999 })], receivedAt: timestamp(4) });
|
||||
expect(getMatchDetail(1)).toEqual(deleted);
|
||||
expect(getMatchDetail(2)).toMatchObject({ id: 2, game: 't6', server_deleted_at: null,
|
||||
finalRound: 20, players: [expect.objectContaining({ guid: '999', kills: 999 })] });
|
||||
});
|
||||
|
||||
it('uses the latest shared player name and only offers profiles that actually exist', async () => {
|
||||
await report('game_start', 0, [player('101'), player('202'), player('303')]);
|
||||
await report('round_change', 1, [player('101'), player('202')]);
|
||||
await report('game_end', 2, [player('101')]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
handle.prepare(`INSERT INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES ('t6', '101', 'Latest name', ?, ?), ('t7', '101', 'Unsupported name', ?, ?)`)
|
||||
.run(timestamp(0), timestamp(4), timestamp(0), timestamp(5));
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
let participants = getMatchDetail(1)!.players;
|
||||
expect(participants.find(entry => entry.guid === '101')).toMatchObject({ name: 'Latest name', canViewProfile: true });
|
||||
expect(participants.find(entry => entry.guid === '202')).toMatchObject({ hasStats: true, highestRound: 1, canViewProfile: false });
|
||||
expect(participants.find(entry => entry.guid === '303')).toMatchObject({ hasStats: false, canViewProfile: false });
|
||||
for (const participant of participants) expect(participant.canViewProfile).toBe(getPlayerProfile(participant.guid) !== null);
|
||||
|
||||
// A start-only participant can still have a valid profile from another match.
|
||||
await report('game_start', 0, [player('303')]);
|
||||
await report('heartbeat', 3, [player('303')]);
|
||||
expect(getMatchDetail(1)!.players.find(entry => entry.guid === '303')).toMatchObject({ canViewProfile: false });
|
||||
expect(getPlayerProfile('303')).toBeNull();
|
||||
await report('game_end', 3, [player('303')]);
|
||||
participants = getMatchDetail(1)!.players;
|
||||
expect(participants.find(entry => entry.guid === '303')).toMatchObject({ hasStats: false, canViewProfile: true });
|
||||
|
||||
// Missing identities must not discard a saved roster row or produce a dead link.
|
||||
handle.prepare("DELETE FROM leaderboard_players WHERE guid = '101'").run();
|
||||
expect(getMatchDetail(1)!.players.find(entry => entry.guid === '101')).toMatchObject({ name: '101', canViewProfile: false });
|
||||
handle.prepare("UPDATE leaderboard_matches SET game = 'custom' WHERE id = 2").run();
|
||||
handle.prepare("UPDATE leaderboard_players SET game = 'custom' WHERE guid = '303'").run();
|
||||
expect(getMatchDetail(2)!.players[0]).toMatchObject({ name: 'Player 303', hasStats: true, canViewProfile: false });
|
||||
});
|
||||
|
||||
it('only exposes completed records at round 2 or higher and rejects invalid IDs', async () => {
|
||||
await report('game_start');
|
||||
await report('game_end', 1);
|
||||
await report('game_start');
|
||||
await report('round_change', 2);
|
||||
await report('game_start');
|
||||
await report('round_change', 3);
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
for (const id of [1, 2, 3, 999, 0, -1, 1.5, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
expect(getMatchDetail(id), String(id)).toBeNull();
|
||||
}
|
||||
await report('game_end', 3);
|
||||
expect(getMatchDetail(3)).toMatchObject({ id: 3, status: 'completed', finalRound: 3 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('match detail page load', () => {
|
||||
it('loads a completed match directly by its durable ID', async () => {
|
||||
await report('game_end', 2);
|
||||
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
expect(load({ params: { id: '1' } })).toEqual({ match: getMatchDetail(1) });
|
||||
});
|
||||
|
||||
it('returns 404 for malformed, missing, active, interrupted, and nonqualifying matches', async () => {
|
||||
await report('game_end', 1);
|
||||
await report('game_start');
|
||||
await report('round_change', 2);
|
||||
await report('game_start');
|
||||
await report('round_change', 2);
|
||||
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
|
||||
for (const id of ['1', '2', '3', '999', '', '0', '-1', '1.0', '01', '1e0', '1x', ' 1', 'Infinity',
|
||||
'9007199254740992', "1' OR 1=1 --"]) {
|
||||
expect(() => load({ params: { id } }), id).toThrow(expect.objectContaining({ status: 404 }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import type Database from 'better-sqlite3';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
function addMatches(handle: Database.Database, guid: string, count: number,
|
||||
{ game = 't5', map = 'theater', kills = 10, name = guid } = {}) {
|
||||
handle.prepare(`INSERT OR IGNORE INTO leaderboard_players (game, guid, name, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, ?, '2026-09-01', '2026-09-02')`).run(game, guid, name);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const inserted = handle.prepare(`INSERT INTO leaderboard_matches
|
||||
(server, game, map, map_key, map_name, started_at, ended_at, last_received_at, status,
|
||||
highest_round, last_event, last_fingerprint)
|
||||
VALUES ('removed-server', ?, ?, ?, ?, '2026-09-01', '2026-09-02', '2026-09-02', 'completed', 20, 'game_end', 'test')`)
|
||||
.run(game, map, `${game}:${map}`, map);
|
||||
handle.prepare(`INSERT INTO leaderboard_match_players (match_id, guid, highest_round, kills, headshots,
|
||||
downs, rounds_survived, completed, has_stats, first_seen_at, last_seen_at)
|
||||
VALUES (?, ?, 20, ?, 5, 1, 2, 1, 1, '2026-09-01', '2026-09-02')`).run(inserted.lastInsertRowid, guid, kills);
|
||||
}
|
||||
}
|
||||
|
||||
it('filters and paginates match history independently of lifetime stats, map records and best evidence', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addMatches(db(), '101', 30);
|
||||
addMatches(db(), '101', 3, { game: 't6', map: 'town', kills: 100 });
|
||||
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
|
||||
const lifetime = getPlayerProfile('101')!;
|
||||
expect(lifetime.matchesTotal).toBe(33);
|
||||
const first = getPlayerProfile('101', 0, { game: 't5', map: 't5:theater' })!;
|
||||
const second = getPlayerProfile('101', 25, { game: 't5', map: 't5:theater' })!;
|
||||
expect(first.matchesTotal).toBe(30);
|
||||
expect(first.matches).toHaveLength(25);
|
||||
expect(second.matches).toHaveLength(5);
|
||||
expect(new Set([...first.matches, ...second.matches].map(match => match.id)).size).toBe(30);
|
||||
expect(first.matches.every(match => match.game === 't5' && match.mapName === 'theater')).toBe(true);
|
||||
for (const profile of [first, second, getPlayerProfile('101', 0, { game: 't6' })!]) {
|
||||
expect(profile.stats).toEqual(lifetime.stats);
|
||||
expect(profile.maps).toEqual(lifetime.maps);
|
||||
expect(profile.records).toEqual(lifetime.records);
|
||||
}
|
||||
expect(getPlayerProfile('101', 999, { game: 't5' })!.offset).toBe(25);
|
||||
expect(getPlayerProfile('101', 999, { game: 't6' })).toMatchObject({ offset: 0, matchesTotal: 3 });
|
||||
expect(getPlayerProfile('101', 999, { game: 't6', map: 't5:theater' })).toMatchObject({
|
||||
stats: lifetime.stats, matches: [], matchesTotal: 0, offset: 0
|
||||
});
|
||||
for (const offset of [-1, NaN, 1.5, Infinity]) expect(getPlayerProfile('101', offset, { game: 't5' })!.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('finds the exact GUID after combined or scoped ranking even beyond the first hundred name and GUID collisions', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db();
|
||||
addMatches(handle, '101', 1, { kills: 10 });
|
||||
addMatches(handle, '101', 1, { game: 't6', map: 'town', kills: 20 });
|
||||
for (let index = 0; index < 105; index++) {
|
||||
addMatches(handle, `101-${index}`, 1, { kills: 100, name: '101 namesake' });
|
||||
}
|
||||
const { getPlayerLeaderboardEntry, getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
|
||||
expect(getLeaderboard({ q: '101', metric: 'kills', limit: 100 }).entries.some(entry => entry.guid === '101')).toBe(false);
|
||||
expect(getPlayerLeaderboardEntry('101', { metric: 'kills' })).toMatchObject({ guid: '101', rank: 106,
|
||||
gamesPlayed: 2, kills: 30, records: { bestKills: { game: 't6', value: 20 } } });
|
||||
expect(getPlayerLeaderboardEntry('101', { metric: 'kills', game: 't5' })).toMatchObject({ guid: '101', rank: 106,
|
||||
gamesPlayed: 1, kills: 10, records: { bestKills: { game: 't5', value: 10 } } });
|
||||
expect(getPlayerLeaderboardEntry('101', { metric: 'kills', game: 't6', map: 't6:town' })).toMatchObject({
|
||||
guid: '101', rank: 1, gamesPlayed: 1, kills: 20
|
||||
});
|
||||
expect(getPlayerLeaderboardEntry('10', { metric: 'kills' })).toBeNull();
|
||||
expect(getPlayerLeaderboardEntry("' OR 1=1 --", { metric: 'kills' })).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps lifetime profiles available when selected history is empty or an efficiency rank is unqualified', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addMatches(db(), '101', 4);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const filtered = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?game=t6&map=t6%3Atown&offset=25') });
|
||||
expect(filtered).toMatchObject({ returnTo: '/leaderboard',
|
||||
profile: { stats: { gamesPlayed: 4, kills: 40 }, matches: [], matchesTotal: 0, offset: 0 },
|
||||
scope: { game: 't6', map: 't6:town', metric: 'highestRound', entry: null } });
|
||||
const unqualified = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') });
|
||||
expect(unqualified).toMatchObject({ profile: { matchesTotal: 4, stats: { gamesPlayed: 4 } },
|
||||
scope: { game: '', map: '', metric: 'averageKills', entry: null } });
|
||||
addMatches(db(), '101', 1);
|
||||
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') }))
|
||||
.toMatchObject({ scope: { entry: { guid: '101', rank: 1, averageKills: 10, gamesPlayed: 5 } } });
|
||||
});
|
||||
|
||||
it('loads canonical navigation context while keeping source search and pagination out of profile rank', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addMatches(db(), '101', 1, { kills: 100 });
|
||||
addMatches(db(), '202', 1, { kills: 200 });
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const url = new URL('http://localhost/leaderboard/players/101?game=t5&map=t5%3Atheater&metric=kills');
|
||||
url.searchParams.set('returnTo', '/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50&unknown=drop');
|
||||
const loaded = load({ params: { guid: '101' }, url });
|
||||
expect(loaded.returnTo).toBe('/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50');
|
||||
expect(loaded.scope).toMatchObject({ game: 't5', map: 't5:theater', metric: 'kills', entry: { guid: '101', rank: 2 } });
|
||||
expect(loaded.profile.offset).toBe(0);
|
||||
url.searchParams.set('returnTo', 'https://external.invalid/leaderboard');
|
||||
expect(load({ params: { guid: '101' }, url }).returnTo).toBe('/leaderboard');
|
||||
const defaults = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101') });
|
||||
expect(defaults).toMatchObject({ returnTo: '/leaderboard', scope: { game: '', map: '', metric: 'highestRound' } });
|
||||
});
|
||||
|
||||
it('rejects invalid filter parameters and retains existing offset normalization and missing-player behavior', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addMatches(db(), '101', 30);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
for (const query of ['game=t7', 'game=', 'metric=', 'metric=unknown', `map=${'x'.repeat(1025)}`]) {
|
||||
expect(() => load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }))
|
||||
.toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
for (const query of ['offset=garbage', 'offset=-1', 'offset=1.5', 'offset=Infinity']) {
|
||||
expect(load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }).profile.offset).toBe(0);
|
||||
}
|
||||
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=999') }).profile.offset).toBe(25);
|
||||
expect(() => load({ params: { guid: 'missing' }, url: new URL('http://localhost/leaderboard/players/missing?game=t5') }))
|
||||
.toThrow(expect.objectContaining({ status: 404 }));
|
||||
});
|
||||
@@ -31,6 +31,78 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAl
|
||||
const api = () => import('../../src/lib/server/fleet/plutonium.js');
|
||||
|
||||
describe('Plutonium versions', () => {
|
||||
it.each([
|
||||
['create', 'queued'], ['rebuild', 'waiting'], ['start', 'running'],
|
||||
['restart', 'awaiting-health'], ['stop', 'waiting'], ['destroy', 'running']
|
||||
])('suppresses fleet probes during %s (%s), preserves evidence, and resumes afterwards', async (action, state) => {
|
||||
const m = await api();
|
||||
await m.checkPlutonium();
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { rconPlutoniumVersion } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const saved = db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0]);
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('busy', ?, ?, ?, 'Provisioning', ?, 'test')`).run(servers[0], action, state, new Date().toISOString());
|
||||
closeDb();
|
||||
vi.mocked(komodoInspectHealthy).mockClear();
|
||||
vi.mocked(rconPlutoniumVersion).mockClear();
|
||||
vi.setSystemTime(Date.now() + 1_000);
|
||||
await m.checkPlutonium();
|
||||
expect(komodoInspectHealthy).toHaveBeenCalledTimes(1);
|
||||
expect(komodoInspectHealthy).toHaveBeenCalledWith(servers[1]);
|
||||
expect(rconPlutoniumVersion).toHaveBeenCalledTimes(1);
|
||||
expect(rconPlutoniumVersion).toHaveBeenCalledWith(servers[1]);
|
||||
expect(db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0])).toEqual(saved);
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'busy'").run();
|
||||
await m.checkPlutonium();
|
||||
expect(rconPlutoniumVersion).toHaveBeenCalledWith(servers[0]);
|
||||
expect(db().prepare('SELECT checked_at FROM plutonium_versions WHERE server = ?').get(servers[0]))
|
||||
.toEqual({ checked_at: new Date().toISOString() });
|
||||
});
|
||||
it('suppresses fleet probes while a failed restart retains recovery capacity', async () => {
|
||||
const m = await api();
|
||||
await m.checkPlutonium();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { rconPlutoniumVersion } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const saved = db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0]);
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('failed-restart', ?, 'restart', 'failed', 'Timed out', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
db().prepare('INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)')
|
||||
.run(servers[0], new Date().toISOString(), 'failed-restart');
|
||||
vi.mocked(komodoInspectHealthy).mockClear();
|
||||
vi.mocked(rconPlutoniumVersion).mockClear();
|
||||
await m.checkPlutonium();
|
||||
expect(komodoInspectHealthy).not.toHaveBeenCalledWith(servers[0]);
|
||||
expect(rconPlutoniumVersion).not.toHaveBeenCalledWith(servers[0]);
|
||||
expect(db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0])).toEqual(saved);
|
||||
db().prepare('UPDATE restart_rollout SET healthy_at = ? WHERE server = ?').run(new Date().toISOString(), servers[0]);
|
||||
vi.setSystemTime(Date.now() + 1_000);
|
||||
await m.checkPlutonium();
|
||||
expect(rconPlutoniumVersion).toHaveBeenCalledWith(servers[0]);
|
||||
});
|
||||
it.each(['health', 'version'] as const)('discards maintenance observations when provisioning is queued during the %s probe', async phase => {
|
||||
const m = await api();
|
||||
await m.checkPlutonium();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { probeVersion } = await import('../../src/lib/server/integrations/plutonium.js');
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { rconPlutoniumVersion } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
const saved = db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0]);
|
||||
const queue = () => db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('new-rebuild', ?, 'rebuild', 'waiting', 'Waiting', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
vi.mocked(rconPlutoniumVersion).mockClear();
|
||||
if (phase === 'health') vi.mocked(komodoInspectHealthy).mockImplementationOnce(async () => {
|
||||
queue(); return { ok: true, state: 'running' };
|
||||
});
|
||||
else vi.mocked(rconPlutoniumVersion).mockImplementationOnce(async () => {
|
||||
queue(); return { revision: null, error: 'Restarting' };
|
||||
});
|
||||
vi.setSystemTime(Date.now() + 1_000);
|
||||
expect(await probeVersion(servers[0], { maintenance: true })).toBeNull();
|
||||
if (phase === 'health') expect(rconPlutoniumVersion).not.toHaveBeenCalled();
|
||||
expect(db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0])).toEqual(saved);
|
||||
});
|
||||
it('coalesces published-release checks without probing the fleet', async () => {
|
||||
const { checkRelease, release } = await import('../../src/lib/server/integrations/plutonium.js');
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
@@ -292,6 +364,88 @@ describe('persistent maintenance', () => {
|
||||
expect(komodoRestart).not.toHaveBeenCalled();
|
||||
expect(m.plutoniumStatus().jobs[0].detail).toContain('another provisioning operation');
|
||||
});
|
||||
it('waits to attach legacy maintenance while another operation owns the server', async () => {
|
||||
const m = await api(); await m.checkPlutonium();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO maintenance_jobs (server, requested_at) VALUES (?, ?)`).run(servers[0], new Date().toISOString());
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('existing-start', ?, 'start', 'waiting', 'Waiting to start', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ state: 'queued', provisioning_id: null, detail: expect.stringContaining('another provisioning operation') });
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM server_deployments').get()).toEqual({ count: 1 });
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'existing-start'").run();
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0].provisioning_id).toBeTruthy();
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM server_deployments').get()).toEqual({ count: 2 });
|
||||
});
|
||||
it.each([false, true])('adopts a submitted legacy restart with retained recovery capacity (linked operation: %s)', async linked => {
|
||||
const m = await api(); await m.checkPlutonium();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const submittedAt = new Date().toISOString();
|
||||
db().prepare(`INSERT INTO maintenance_jobs (server, requested_at, restarted_at, state)
|
||||
VALUES (?, ?, ?, 'restarting')`).run(servers[0], submittedAt, submittedAt);
|
||||
if (linked) {
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, submitted_at, triggered_by)
|
||||
VALUES ('legacy-restart', ?, 'restart', 'awaiting-health', 'Waiting for health', ?, ?, 'test')`).run(servers[0], submittedAt, submittedAt);
|
||||
}
|
||||
db().prepare('INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, ?)')
|
||||
.run(servers[0], submittedAt, linked ? 'legacy-restart' : null);
|
||||
await m.maintenanceTick();
|
||||
const job = m.plutoniumStatus().jobs[0];
|
||||
expect(job).toMatchObject({ state: 'restarting', operation_state: 'awaiting-health' });
|
||||
expect(job.provisioning_id).toBeTruthy();
|
||||
if (linked) expect(job.provisioning_id).toBe('legacy-restart');
|
||||
expect(db().prepare('SELECT operation_id FROM restart_rollout WHERE server = ?').get(servers[0])).toEqual({ operation_id: job.provisioning_id });
|
||||
vi.setSystemTime(Date.now() + 15_000); await maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0].state).toBe('done');
|
||||
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
expect(komodoRestart).not.toHaveBeenCalled();
|
||||
});
|
||||
it('defers completed maintenance verification while a later operation owns the server, then resumes', async () => {
|
||||
const m = await api(); await m.checkPlutonium(); m.queueMaintenance([servers[0]]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const job = m.plutoniumStatus().jobs[0];
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = ?").run(job.provisioning_id);
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('later-restart', ?, 'restart', 'waiting', 'Waiting to restart', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||
const { rconPlutoniumVersion } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
vi.mocked(komodoInspectHealthy).mockClear(); vi.mocked(rconPlutoniumVersion).mockClear();
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ state: 'queued', detail: expect.stringContaining('before verifying') });
|
||||
expect(komodoInspectHealthy).not.toHaveBeenCalled(); expect(rconPlutoniumVersion).not.toHaveBeenCalled();
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'later-restart'").run();
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0].state).toBe('done');
|
||||
expect(rconPlutoniumVersion).toHaveBeenCalledWith(servers[0]);
|
||||
});
|
||||
it('does not fail maintenance verification when another operation starts during its version probe', async () => {
|
||||
const m = await api(); await m.checkPlutonium(); m.queueMaintenance([servers[0]]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = ?").run(m.plutoniumStatus().jobs[0].provisioning_id);
|
||||
const { rconPlutoniumVersion } = await import('../../src/lib/server/integrations/rcon.js');
|
||||
vi.mocked(rconPlutoniumVersion).mockImplementationOnce(async () => {
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('later-rebuild', ?, 'rebuild', 'running', 'Rebuilding', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
return { revision: null, error: 'Restarting' };
|
||||
});
|
||||
const before = db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0]);
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ state: 'queued', detail: expect.stringContaining('before verifying') });
|
||||
expect(db().prepare('SELECT * FROM plutonium_versions WHERE server = ?').get(servers[0])).toEqual(before);
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'later-rebuild'").run();
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0].state).toBe('done');
|
||||
});
|
||||
it('mirrors its own failed operation even while another operation owns the server', async () => {
|
||||
const m = await api(); await m.checkPlutonium(); m.queueMaintenance([servers[0]]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare("UPDATE server_deployments SET state = 'failed', error = 'Restart failed' WHERE id = ?").run(m.plutoniumStatus().jobs[0].provisioning_id);
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('later-start', ?, 'start', 'waiting', 'Waiting to start', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ state: 'failed', detail: 'Restart failed' });
|
||||
});
|
||||
it('does not repeat an interrupted restart intent that has no matching rollout slot', async () => {
|
||||
const m = await api(); await m.checkPlutonium(); m.queueMaintenance([servers[0]]);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
@@ -361,6 +515,58 @@ describe('Maintenance policies and runtime updates', () => {
|
||||
expect(m.plutoniumStatus().jobs).toEqual([expect.objectContaining({ server: servers[0], kind: 'update', origin: 'automatic', target_revision: 5354 })]);
|
||||
expect(m.plutoniumStatus().servers.find(s => s.server === servers[1])?.update_status).toBe('unknown');
|
||||
});
|
||||
it.each(['queued', 'waiting', 'running', 'awaiting-health'])('skips automatic updates during %s provisioning and resumes after completion', async state => {
|
||||
const m = await outdated();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('existing-restart', ?, 'restart', ?, 'Restarting', ?, 'test')`).run(servers[0], state, new Date().toISOString());
|
||||
const p = await import('../../src/lib/server/maintenance/policies.js'); p.setMaintenancePolicy('updates', true);
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs).toEqual([expect.objectContaining({ server: servers[1], kind: 'update', origin: 'automatic' })]);
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM server_deployments WHERE server = ?').get(servers[0])).toEqual({ count: 1 });
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'existing-restart'").run();
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs).toHaveLength(2);
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ server: servers[0], kind: 'update', origin: 'automatic' });
|
||||
});
|
||||
it('skips a busy server for the scheduled occurrence while other servers proceed', async () => {
|
||||
const m = await outdated(); m.saveMaintenanceSchedule(true, '06:00', 'UTC');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('existing-start', ?, 'start', 'waiting', 'Waiting to start', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
vi.setSystemTime(new Date('2026-09-21T06:00:00Z'));
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs).toEqual([expect.objectContaining({ server: servers[1], kind: 'schedule' })]);
|
||||
expect(m.plutoniumStatus().schedule.nextAt).toBe('2026-09-22T06:00:00.000Z');
|
||||
db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'existing-start'").run();
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs).toHaveLength(1);
|
||||
vi.setSystemTime(new Date('2026-09-22T06:00:00Z'));
|
||||
await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs).toHaveLength(2);
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ server: servers[0], kind: 'schedule' });
|
||||
});
|
||||
it('skips automatic and scheduled requests while a failed operation retains recovery capacity', async () => {
|
||||
const m = await outdated(); m.saveMaintenanceSchedule(true, '06:00', 'UTC');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('failed-restart', ?, 'restart', 'failed', 'Restart failed', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
db().prepare(`INSERT INTO restart_rollout (server, requested_at, operation_id) VALUES (?, ?, 'failed-restart')`)
|
||||
.run(servers[0], new Date().toISOString());
|
||||
expect(m.queueMaintenance([servers[0]], 'update', 'automatic')).toBe(0);
|
||||
vi.setSystemTime(new Date('2026-09-21T06:00:00Z')); await m.maintenanceTick();
|
||||
expect(m.plutoniumStatus().jobs).toEqual([expect.objectContaining({ server: servers[1], kind: 'schedule' })]);
|
||||
db().prepare('UPDATE restart_rollout SET healthy_at = ? WHERE server = ?').run(new Date().toISOString(), servers[0]);
|
||||
expect(m.queueMaintenance([servers[0]], 'update', 'automatic')).toBe(1);
|
||||
});
|
||||
it('preserves explicit update requests while another operation owns the server', async () => {
|
||||
const m = await outdated();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('existing-start', ?, 'start', 'waiting', 'Waiting to start', ?, 'test')`).run(servers[0], new Date().toISOString());
|
||||
expect(m.queueUpdates([servers[0]])).toBe(1);
|
||||
expect(m.plutoniumStatus().jobs[0]).toMatchObject({ server: servers[0], kind: 'update', origin: 'manual' });
|
||||
});
|
||||
it('holds queued automatic updates when their policy is disabled, but finishes submitted recovery while paused', async () => {
|
||||
const m = await outdated();
|
||||
const p = await import('../../src/lib/server/maintenance/policies.js'); p.setMaintenancePolicy('updates', true);
|
||||
|
||||
@@ -194,6 +194,50 @@ describe('Watchdog detection and ownership', () => {
|
||||
expect(incidents()).toEqual([]);
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'stop' })]);
|
||||
});
|
||||
|
||||
it.each(['queued', 'waiting', 'running', 'awaiting-health', 'retained recovery'])(
|
||||
'suppresses health and drift throughout %s provisioning, then resumes', async state => {
|
||||
const retained = state === 'retained recovery';
|
||||
const now = new Date().toISOString();
|
||||
database.db().prepare(`INSERT INTO server_deployments (id, server, action, state, stage, started_at, triggered_by)
|
||||
VALUES ('manual-operation', ?, ?, ?, 'Operator work', ?, 'manual')`)
|
||||
.run(server, retained ? 'restart' : 'rebuild', retained ? 'failed' : state, now);
|
||||
if (retained) database.db().prepare(`INSERT INTO restart_rollout (server, operation_id, requested_at)
|
||||
VALUES (?, 'manual-operation', ?)`).run(server, now);
|
||||
await writeFile(join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp', 'stray.gsc'), 'unexpected');
|
||||
down();
|
||||
for (let n = 0; n < 4; n++) {
|
||||
await watchdog.watchdogTick(true);
|
||||
advance(watchdog.WATCHDOG_SCAN_MS);
|
||||
}
|
||||
expect(incidents()).toEqual([]);
|
||||
expect(komodo.komodoInspectHealthy).not.toHaveBeenCalled();
|
||||
expect(rcon.rconStatusResult).not.toHaveBeenCalled();
|
||||
expect(operations()).toHaveLength(1);
|
||||
|
||||
database.db().prepare("UPDATE server_deployments SET state = 'done' WHERE id = 'manual-operation'").run();
|
||||
if (retained) database.db().prepare('UPDATE restart_rollout SET healthy_at = ? WHERE server = ?').run(new Date().toISOString(), server);
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(incidents()).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'health', state: 'open', failures: 1 }),
|
||||
expect.objectContaining({ kind: 'drift', state: 'recovering' })
|
||||
]));
|
||||
expect(operations()).toHaveLength(2);
|
||||
expect(operations()[1]).toMatchObject({ action: 'rebuild', state: 'waiting' });
|
||||
}
|
||||
);
|
||||
|
||||
it('stops probing when provisioning takes ownership during the container inspection', async () => {
|
||||
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockImplementationOnce(async () => {
|
||||
expect(requestLifecycle(server, 'restart').ok).toBe(true);
|
||||
return { ok: true, state: 'running' };
|
||||
});
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(incidents()).toEqual([]);
|
||||
expect(rcon.rconStatusResult).not.toHaveBeenCalled();
|
||||
expect(operations()).toEqual([expect.objectContaining({ action: 'restart', state: 'waiting' })]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Watchdog recovery persistence', () => {
|
||||
@@ -254,6 +298,44 @@ describe('Watchdog recovery persistence', () => {
|
||||
expect(operations().map(op => op.action)).toEqual(['restart', 'rebuild']);
|
||||
});
|
||||
|
||||
it('reclaims a failed attempt recovery slot only after an explicit incident retry', async () => {
|
||||
const row = await sustainedFailure();
|
||||
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
|
||||
await processProvisioning();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
await watchdog.watchdogTick(true);
|
||||
vi.mocked(komodo.komodoDestroyDeployment).mockResolvedValue({ ok: false, kind: 'unreachable', message: 'Host unavailable' });
|
||||
await processProvisioning();
|
||||
await watchdog.watchdogTick(true);
|
||||
const failed = operations()[1];
|
||||
expect(failed).toMatchObject({ action: 'rebuild', state: 'failed' });
|
||||
expect(onlyIncident()).toMatchObject({ id: row.id, state: 'attention', cycle: 1 });
|
||||
const slot = () => database.db().prepare('SELECT operation_id, healthy_at FROM restart_rollout WHERE server = ?').get(server);
|
||||
expect(slot()).toEqual({ operation_id: failed.id, healthy_at: null });
|
||||
|
||||
await writeFile(join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp', 'stray.gsc'), 'unexpected');
|
||||
vi.mocked(komodo.komodoInspectHealthy).mockClear();
|
||||
advance(watchdog.WATCHDOG_RECOVERY_MS);
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(komodo.komodoInspectHealthy).not.toHaveBeenCalled();
|
||||
expect(operations()).toHaveLength(2);
|
||||
|
||||
expect(watchdog.updateWatchdogIncident(row.id, 'retry')).toEqual({ ok: true });
|
||||
await watchdog.watchdogTick(true);
|
||||
expect(operations()).toHaveLength(2);
|
||||
advance(watchdog.WATCHDOG_SCAN_MS); await watchdog.watchdogTick(true);
|
||||
advance(watchdog.WATCHDOG_SCAN_MS); await watchdog.watchdogTick(true);
|
||||
const retry = operations()[2];
|
||||
expect(retry).toMatchObject({ action: 'restart', state: 'waiting' });
|
||||
expect(onlyIncident()).toMatchObject({ id: row.id, state: 'recovering', cycle: 2 });
|
||||
expect(slot()).toEqual({ operation_id: retry.id, healthy_at: null });
|
||||
expect(watchdog.watchdogDetail(row.id)?.actions).toHaveLength(3);
|
||||
|
||||
await processProvisioning();
|
||||
expect(komodo.komodoRestart).toHaveBeenCalledTimes(2);
|
||||
expect(operations()[2].state).toBe('awaiting-health');
|
||||
});
|
||||
|
||||
it('resumes a persisted detection window and does not duplicate queued work after reopening', async () => {
|
||||
down();
|
||||
await watchdog.watchdogTick(true);
|
||||
|
||||
Reference in New Issue
Block a user