Compare commits

..
8 Commits
Author SHA1 Message Date
josh 221c4c10cf Reuse leaderboard aggregates and add repeatable benchmarks 2026-10-01 12:33:11 -04:00
josh f10345ae79 Use compact player record cards on mobile 2026-10-01 12:26:35 -04:00
josh eac9ad4fbc Preserve leaderboard context in player profiles 2026-10-01 12:23:30 -04:00
josh 47ebe83106 Add qualified efficiency leaderboard rankings 2026-10-01 12:16:27 -04:00
josh c7d71c63ba Link personal bests to their recorded matches 2026-10-01 12:12:21 -04:00
josh 13963cba1c Count only completed games in player leaderboards 2026-10-01 12:05:19 -04:00
josh 0cb48c435e Suppress maintenance watchers during provisioning
Keep maintenance observations and automatic requests clear of active lifecycle operations and retained recovery capacity while preserving owned recovery and explicit retries.

Fixes #55
2026-10-01 11:54:22 -04:00
josh a3cd3ee585 Add completed match detail pages 2026-10-01 11:45:23 -04:00
39 changed files with 2400 additions and 143 deletions
+13 -1
View File
@@ -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. executes every lifecycle operation using the shared safety checks and queues.
Health recovery uses one restart followed by one rebuild if needed; drift repair 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.** 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 Plutonium updates are applied by Plutainer's startup updater
(`PLUTAINER_AUTO_UPDATE` must be enabled). Check versions and choose **Update on (`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. timezone, follow daylight saving changes, and coalesce missed days into one run.
Intentionally stopped servers are excluded. Existing schedules, pending jobs, Intentionally stopped servers are excluded. Existing schedules, pending jobs,
operation IDs, and history survive the move from Settings and Watchdog. 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 **Pause all maintenance** holds new and queued maintenance while checks and
submitted recovery continue. Each capability retains its own policy. Automatic 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 The **Leaderboard** records persistent player totals and map records from these
same reports, including highest round, cumulative kills, rounds survived, same reports, including highest round, cumulative kills, rounds survived,
headshots, revives, downs, and games played. See [Leaderboard](docs/leaderboard.md) 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 ## Running tests
+122 -13
View File
@@ -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 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 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. filters intact. Results refresh while the page is visible.
Matches only appear in server/global history and leaderboard map options once Matches only appear in server/global history once they reach round 2.
they reach round 2. Player records only contribute to leaderboards, summaries, Leaderboards, summaries, map options, profiles and player history include only
profiles and player history once that player is observed at round 2 or later. 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 This also excludes existing round 1 records. Early telemetry is retained so
qualifying games keep their start time, roster and cumulative counters. qualifying games keep their start time, roster and cumulative counters.
@@ -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 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. 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 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 required. The leaderboard and profiles identify a player by **GUID**, displaying
the latest reported name across both games, regardless of game or map filters. 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 Different names for the same GUID do not split their leaderboard entry or
profile; identical names with different GUIDs remain separate. Stored player profile; identical names with different GUIDs remain separate. Stored player
records remain keyed by **game + GUID** for telemetry ingestion; the combined records remain keyed by **game + GUID** for telemetry ingestion; combining their
leaderboard and profiles require no storage migration. GUIDs stay strings. 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 ## 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. | | 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 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. | | 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. | | 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. | | 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. | | 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, regardless of the search text. Player counts include each qualifying GUID once,
including when they have records in both games. Summary rounds are including when they have records in both games. Summary rounds are
player-rounds: four players progressing from game start to round 8 contribute 28. 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 Highest-round records always use the round reached in a completed match,
and players who joined mid-game. 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 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 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`. available `maps`, the matching player `total`, pagination values, and `summary`.
Each entry represents one GUID and combines its qualifying matches within the 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 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 identity. Both `total` and `summary.players` count unique GUIDs; `total` applies
limited by the player search. 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`, 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
View File
@@ -41,6 +41,13 @@ future runs but does not cancel requests it already created.
All capabilities respect intentionally stopped servers. Start them explicitly All capabilities respect intentionally stopped servers. Start them explicitly
through Provisioning before expecting maintenance to act on them. 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 ## Plutonium updates
The published revision comes from the official production updater manifest. 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, Daily restarts are independent of update availability. At the saved queue time,
Maintenance requests a restart for each server whose desired state is running. 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. 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 Skipped times run at the first valid minute after the gap; repeated times run once
+1
View File
@@ -11,6 +11,7 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
"seed:fixture": "tsx scripts/seed-fixture.ts" "seed:fixture": "tsx scripts/seed-fixture.ts"
}, },
"dependencies": { "dependencies": {
+241
View File
@@ -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 }); }
+76
View File
@@ -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 ? '%' : ''}`;
}
+44
View File
@@ -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
View File
@@ -1,4 +1,6 @@
export const leaderboardMetrics = [ import { efficiencyMetrics, type EfficiencyMetric } from './leaderboard-efficiency.js';
export const playerStatMetrics = [
{ key: 'highestRound', label: 'Highest round' }, { key: 'highestRound', label: 'Highest round' },
{ key: 'kills', label: 'Total kills' }, { key: 'kills', label: 'Total kills' },
{ key: 'roundsSurvived', label: 'Total rounds' }, { key: 'roundsSurvived', label: 'Total rounds' },
@@ -11,14 +13,32 @@ export const leaderboardMetrics = [
{ key: 'bestScore', label: 'Highest reported score' } { key: 'bestScore', label: 'Highest reported score' }
] as const; ] as const;
export const leaderboardMetrics = [...playerStatMetrics, ...efficiencyMetrics] as const;
export type LeaderboardMetric = typeof leaderboardMetrics[number]['key']; 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 { export interface PlayerMapRecord extends PlayerStats {
game: string; game: string;
key: string; key: string;
name: string; name: string;
lastSeen: string; lastSeen: string;
records: PersonalBestRecords;
} }
export interface PlayerMatch { export interface PlayerMatch {
id: number; id: number;
@@ -44,8 +64,10 @@ export interface PlayerProfile {
firstSeen: string; firstSeen: string;
lastSeen: string; lastSeen: string;
stats: PlayerStats; stats: PlayerStats;
records: PersonalBestRecords;
maps: PlayerMapRecord[]; maps: PlayerMapRecord[];
matches: PlayerMatch[]; matches: PlayerMatch[];
matchesTotal: number;
offset: number; offset: number;
limit: number; limit: number;
} }
@@ -54,7 +76,7 @@ export function playerProfileHref(guid: string): string {
return `/leaderboard/players/${encodeURIComponent(guid)}`; return `/leaderboard/players/${encodeURIComponent(guid)}`;
} }
export interface LeaderboardEntry { export interface LeaderboardEntry extends Record<EfficiencyMetric, number | null> {
rank: number; rank: number;
guid: string; guid: string;
name: string; name: string;
@@ -69,6 +91,7 @@ export interface LeaderboardEntry {
bestKills: number; bestKills: number;
bestScore: number; bestScore: number;
lastSeen: string; lastSeen: string;
records: PersonalBestRecords;
} }
export interface LeaderboardMap { export interface LeaderboardMap {
+27
View File
@@ -15,3 +15,30 @@ export interface ServerMatch {
durationMs: number | null; durationMs: number | null;
finalRound: 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[];
}
+4
View File
@@ -360,6 +360,10 @@ export function db(): Database.Database {
assertSnapshotSchemeSafe(handle); assertSnapshotSchemeSafe(handle);
migrateGameTelemetry(handle); migrateGameTelemetry(handle);
migrateTable(handle, 'leaderboard_matches', [{ name: 'start_players', ddl: 'ALTER TABLE leaderboard_matches ADD COLUMN start_players INTEGER' }]); 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); backfillLeaderboard(handle);
initializeFleetSettings(handle); initializeFleetSettings(handle);
} catch (err) { } catch (err) {
+2
View File
@@ -55,6 +55,8 @@ CREATE TABLE IF NOT EXISTS leaderboard_match_players (
completed INTEGER NOT NULL DEFAULT 0, completed INTEGER NOT NULL DEFAULT 0,
first_seen_at TEXT NOT NULL, first_seen_at TEXT NOT NULL,
last_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) PRIMARY KEY (match_id, guid)
); );
CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id); CREATE INDEX IF NOT EXISTS idx_leaderboard_player_matches ON leaderboard_match_players(guid, match_id);
+3 -3
View File
@@ -91,8 +91,8 @@ export function recordLeaderboardTelemetry(handle: Database.Database, report: Ga
last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`); last_seen_at = MAX(last_seen_at, excluded.last_seen_at)`);
const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players const savePlayer = handle.prepare(`INSERT INTO leaderboard_match_players
(match_id, guid, highest_round, rounds_survived, kills, headshots, downs, revives, best_score, (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) last_round, present, has_stats, completed, first_seen_at, last_seen_at, first_observed_round, present_at_start)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?)
ON CONFLICT(match_id, guid) DO UPDATE SET ON CONFLICT(match_id, guid) DO UPDATE SET
highest_round = MAX(highest_round, excluded.highest_round), highest_round = MAX(highest_round, excluded.highest_round),
rounds_survived = rounds_survived + excluded.rounds_survived, 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; const stats = 'kills' in player ? player : null;
savePlayer.run(match.id, player.guid, round, survived, savePlayer.run(match.id, player.guid, round, survived,
stats?.kills ?? 0, stats?.headshots ?? 0, stats?.downs ?? 0, stats?.revives ?? 0, stats?.score ?? 0, 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, ?), handle.prepare(`UPDATE leaderboard_matches SET highest_round = MAX(highest_round, ?),
last_received_at = ?, last_event = ?, last_fingerprint = ?, 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;
}
+107 -27
View File
@@ -1,10 +1,27 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.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, import { leaderboardMetrics, type LeaderboardEntry, type LeaderboardMap, type LeaderboardMetric,
type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js'; type LeaderboardOptions, type LeaderboardResponse } from '../../leaderboard.js';
const metrics = new Set<string>(leaderboardMetrics.map(metric => metric.key)); const metrics = new Set<string>(leaderboardMetrics.map(metric => metric.key));
export const MAX_LEADERBOARD_OFFSET = 1_000_000; 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 { export function isLeaderboardMetric(value: string): value is LeaderboardMetric {
return metrics.has(value); 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. */ /** Rank each GUID once within the selected matches; player search keeps their actual rank. */
export function getLeaderboard(options: LeaderboardOptions = {}): LeaderboardResponse { 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 handle = db();
const metric = options.metric && isLeaderboardMetric(options.metric) ? options.metric : 'highestRound'; 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 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 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}`]; `mp.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: string[] = []; const parameters: string[] = [];
if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); } if (options.game) { conditions.push('m.game = ?'); parameters.push(options.game); }
if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); } if (options.map) { conditions.push('m.map_key = ?'); parameters.push(options.map); }
const scoped = `FROM leaderboard_match_players mp const qualification = isEfficiencyMetric(metric)
JOIN leaderboard_matches m ON m.id = mp.match_id ? `WHERE measures.gamesPlayed >= ${EFFICIENCY_MIN_GAMES} AND measures.${metric} IS NOT NULL`
WHERE ${conditions.join(' AND ')}`; + (metric === 'headshotRate' ? ` AND measures.kills >= ${EFFICIENCY_MIN_KILLS}` : '')
const aggregated = `WITH totals AS ( + (metric === 'killsPerDown' ? ` AND measures.downs >= ${EFFICIENCY_MIN_DOWNS}` : '')
SELECT mp.guid, MAX(mp.highest_round) AS highestRound, : '';
SUM(mp.kills) AS kills, SUM(mp.rounds_survived) AS roundsSurvived, // A board reuses observations for player totals and distinct match metadata.
SUM(mp.headshots) AS headshots, SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, // Exact-player requests only consume the totals, so they can stream the join once.
COUNT(*) AS gamesPlayed, SUM(mp.completed) AS gamesCompleted, const observationStorage = exactGuid === undefined ? 'MATERIALIZED' : 'NOT MATERIALIZED';
MAX(mp.kills) AS bestKills, MAX(mp.best_score) AS bestScore, MAX(mp.last_seen_at) AS lastSeen const aggregated = `WITH observations AS ${observationStorage} (
${scoped} GROUP BY mp.guid 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 ( ), identities AS (
SELECT guid, name, ROW_NUMBER() OVER (PARTITION BY guid ORDER BY last_seen_at DESC, game) AS recency 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') FROM leaderboard_players WHERE game IN ('t5', 't6')
), ranked AS ( ), ranked AS (
SELECT totals.*, p.name, RANK() OVER (ORDER BY totals.${metric} DESC) AS rank SELECT measures.*, p.name, RANK() OVER (ORDER BY measures.${metric} DESC) AS rank
FROM totals JOIN identities p ON p.guid = totals.guid AND p.recency = 1 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 query = options.q?.trim() ?? '';
const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : ''; const search = query ? 'WHERE instr(lower(name), lower(?)) > 0 OR instr(lower(guid), lower(?)) > 0' : '';
const searchedParameters = query ? [...parameters, query, query] : parameters; // Reuse one ranked set for the page and matching count. Summaries deliberately
const entries = handle.prepare(`${aggregated} SELECT * FROM ranked ${search} // cover the full game/map scope, including players below an efficiency threshold.
ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?`) const rows = handle.prepare(`${aggregated}, searched AS MATERIALIZED (
.all(...searchedParameters, limit, offset) as LeaderboardEntry[]; SELECT * FROM ranked ${search}
const { total } = handle.prepare(`${aggregated} SELECT COUNT(*) AS total FROM ranked ${search}`) ), page AS (
.get(...searchedParameters) as { total: number }; SELECT * FROM searched ORDER BY ${metric} DESC, name COLLATE NOCASE, name, guid LIMIT ? OFFSET ?
const summary = handle.prepare(`SELECT ), playerSummary AS (
COUNT(DISTINCT mp.guid) AS players, SELECT COUNT(*) AS players, COALESCE(SUM(kills), 0) AS kills,
COUNT(DISTINCT m.id) AS matches, COALESCE(SUM(mp.kills), 0) AS kills, COALESCE(SUM(roundsSurvived), 0) AS roundsSurvived,
COALESCE(SUM(mp.rounds_survived), 0) AS roundsSurvived, COALESCE(MAX(highestRound), 0) AS highestRound FROM totals
COALESCE(MAX(mp.highest_round), 0) AS highestRound, ), matchSummary AS (
MIN(m.started_at) AS firstTrackedAt, MAX(m.last_received_at) AS updatedAt SELECT COUNT(DISTINCT match_id) AS matches, MIN(matchStartedAt) AS firstTrackedAt,
${scoped}`).get(...parameters) as LeaderboardResponse['summary']; 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. // 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 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 = ?' : ''} AND highest_round >= ${MIN_RECORDED_ROUND}${options.game ? ' AND game = ?' : ''}
GROUP BY game, map_key HAVING id = MAX(id) GROUP BY game, map_key HAVING id = MAX(id)
ORDER BY game, name COLLATE NOCASE, map_key`) ORDER BY game, name COLLATE NOCASE, map_key`)
+55 -9
View File
@@ -1,12 +1,7 @@
import { db } from '../db/index.js'; 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 matchSummary = `m.id, m.server, m.server_deleted_at, m.map_name AS map, m.started_at AS startedAt, m.status,
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,
CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt, CASE WHEN m.status = 'completed' THEN m.ended_at END AS endedAt,
m.start_players AS startPlayers, m.start_players AS startPlayers,
(SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers, (SELECT COUNT(*) FROM leaderboard_match_players p WHERE p.match_id = m.id) AS uniquePlayers,
@@ -15,8 +10,59 @@ export function serverMatchHistory(server?: string, limit = 25): { matches: Serv
END AS endPlayers, END AS endPlayers,
CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN CASE WHEN m.status = 'completed' AND m.start_observed = 1 THEN
MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs, MAX(0, ROUND((julianday(m.ended_at) - julianday(m.started_at)) * 86400000)) END AS durationMs,
CASE WHEN m.status = 'completed' THEN m.highest_round END AS finalRound 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 ?`)
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[]; .all(...parameters, limit) as ServerMatch[];
return { matches, total }; 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)
})) };
}
+21 -9
View File
@@ -1,16 +1,18 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { MIN_RECORDED_ROUND } from '../../match-history.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, 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.rounds_survived) AS roundsSurvived, SUM(mp.headshots) AS headshots,
SUM(mp.revives) AS revives, SUM(mp.downs) AS downs, COUNT(*) AS gamesPlayed, 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`; 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 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}`; 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(); const handle = db();
// Keep game-specific ingestion identities, but show one identity with the latest reported name. // 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, 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; if (!player) return null;
const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats; const stats = handle.prepare(`SELECT ${totals} ${scope}`).get(guid) as PlayerStats;
if (!stats.gamesPlayed) return null; 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 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 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} 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`) 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, 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, 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.highest_round AS highestRound, mp.rounds_survived AS roundsSurvived,
mp.kills, mp.headshots, mp.revives, mp.downs, mp.best_score AS bestScore 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 ?`) ${historyScope} ORDER BY mp.last_seen_at DESC, m.id DESC LIMIT ? OFFSET ?`)
.all(guid, limit, offset) as PlayerMatch[]; .all(...historyParameters, limit, offset) as PlayerMatch[];
return { ...player, stats, maps, matches, offset, limit }; return { ...player, stats, records, maps, matches, matchesTotal, offset, limit };
} }
+9
View File
@@ -1,5 +1,14 @@
import { db } from '../db/index.js'; 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. */ /** Only the latest lifecycle operation can describe the current server. */
export function isProvisioning(server: string): boolean { export function isProvisioning(server: string): boolean {
const operation = db().prepare(`SELECT action, state FROM server_deployments const operation = db().prepare(`SELECT action, state FROM server_deployments
+9 -2
View File
@@ -1,6 +1,7 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { config } from '../config.js'; import { config } from '../config.js';
import { getServer, listServers } from '../fleet/servers.js'; import { getServer, listServers } from '../fleet/servers.js';
import { hasActiveProvisioning } from '../fleet/provisioning.js';
import { latestGameSubmission } from '../fleet/status-evidence.js'; import { latestGameSubmission } from '../fleet/status-evidence.js';
import { rconPlutoniumVersion } from './rcon.js'; import { rconPlutoniumVersion } from './rcon.js';
import { komodoInspectHealthy } from './komodo.js'; import { komodoInspectHealthy } from './komodo.js';
@@ -34,15 +35,21 @@ export function runningVersions(): PlutoniumVersion[] {
: revision < target ? 'outdated' : revision > target ? 'ahead' : 'current' }; : 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); const original = getServer(server);
if (!original) return null; if (!original) return null;
const epoch = latestGameSubmission(server); const epoch = latestGameSubmission(server);
const recovering = restartRecoveryPending(server); const recovering = restartRecoveryPending(server);
const health = !recovering && original.desired_state !== 'stopped' const health = !recovering && original.desired_state !== 'stopped'
? await komodoInspectHealthy(server).catch(() => ({ ok: false })) : { ok: false }; ? 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.' } let result = !health.ok ? { revision: null, error: 'Waiting for a healthy running server.' }
: await rconPlutoniumVersion(server).catch(() => ({ revision: null, error: 'Version check failed.' })); : await rconPlutoniumVersion(server).catch(() => ({ revision: null, error: 'Version check failed.' }));
if (suppressed()) return null;
const current = getServer(server); const current = getServer(server);
// An older probe must not clear evidence already recorded for a new process // An older probe must not clear evidence already recorded for a new process
// or replacement registration, including replacements with no submission yet. // or replacement registration, including replacements with no submission yet.
@@ -72,7 +79,7 @@ async function check(): Promise<void> {
await checkRelease(); await checkRelease();
const servers = listServers(); const servers = listServers();
for (let i = 0; i < servers.length; i += 4) { 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()); writeSetting('plutonium_versions_checked_at', nowIso());
} }
+34 -14
View File
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { komodoConfigured } from '../config.js'; import { komodoConfigured } from '../config.js';
import { getServer, listServers } from '../fleet/servers.js'; import { getServer, listServers } from '../fleet/servers.js';
import { hasActiveProvisioning } from '../fleet/provisioning.js';
import { komodoInspectHealthy } from '../integrations/komodo.js'; import { komodoInspectHealthy } from '../integrations/komodo.js';
import { rconStatusResult } from '../integrations/rcon.js'; import { rconStatusResult } from '../integrations/rcon.js';
import { telemetryEvidence } from '../fleet/status-evidence.js'; import { telemetryEvidence } from '../fleet/status-evidence.js';
@@ -73,8 +74,17 @@ export function watchdogDetail(id: string): WatchdogDetail | null {
const row = incident(id); 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; 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) { function scanSuppressed(server: string, kind?: 'health' | 'drift'): boolean {
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); // 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 { 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) 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) { for (const row of rows) {
const action = actions(row.id).filter(a => a.cycle === row.cycle).at(-1); const action = actions(row.id).filter(a => a.cycle === row.cycle).at(-1);
if (!action) continue; 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 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; { 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; } 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') { if (row.kind === 'drift') {
try { try {
const plan = await previewDrift([row.server]); 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; } 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'); transition(row.id, 'resolved', action.action === 'restart' ? 'Server recovered after restart' : 'Server recovered after rebuild');
continue; continue;
@@ -150,8 +167,9 @@ async function reconcileActions(): Promise<void> {
} }
interface Observation { healthy: boolean; actionable: boolean; reason: string; containerState: string | null } 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); const health = await komodoInspectHealthy(server);
if (scanSuppressed(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return null;
const state = health.state?.toLowerCase() ?? 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 (!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 }; 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> { async function scanServer(server: string): Promise<void> {
const row = getServer(server); const row = getServer(server);
if (!row || row.desired_state === 'stopped') return; if (!row || row.desired_state === 'stopped') return;
if (hasPending(server) || protectedWork(server)) return; if (scanSuppressed(server) || protectedWork(server)) return;
const observation = await observe(server); const observation = await observe(server);
// Probes can overlap an API request; recheck ownership before queuing anything. // Probes can overlap an API request; recheck ownership before queuing anything.
if (hasPending(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return; if (!observation || scanSuppressed(server) || protectedWork(server) || getServer(server)?.desired_state === 'stopped') return;
if (observation.healthy) { if (!scanSuppressed(server, 'health') && observation.healthy) {
const current = active(server, 'health'); const current = active(server, 'health');
if (current) transition(current.id, 'resolved', 'Fresh game status and Komodo health verified'); 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 problem = detect(server, 'health', observation.reason, observation);
const startup = ['starting','restarting'].includes(observation.containerState ?? ''); const startup = ['starting','restarting'].includes(observation.containerState ?? '');
const oldEnough = Date.now() - Date.parse(problem.first_seen_at) >= (startup ? WATCHDOG_RECOVERY_MS : WATCHDOG_FAILURE_MS); 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); 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); 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) { if (!prepared.plan.files.length) {
const current = active(server, 'drift'); const current = active(server, 'drift');
if (current) transition(current.id, 'resolved', 'Server files match the promoted setup'); 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) { while (cursor < servers.length) {
const server = servers[cursor++]; const server = servers[cursor++];
try { await scanServer(server.name); } 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)); await Promise.all(Array.from({ length: Math.min(4, servers.length) }, pump));
// Preserve incidents for temporarily absent rebuild instances, but close retired registrations. // 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[]; 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) { 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; 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 // An absent registry can also mean a failed rebuild. Only a completed
// destruction retires that failure without requiring inspection. // destruction retires that failure without requiring inspection.
+12 -1
View File
@@ -1,5 +1,6 @@
import { db } from '../db/index.js'; import { db } from '../db/index.js';
import { getServer, listServers } from '../fleet/servers.js'; import { getServer, listServers } from '../fleet/servers.js';
import { hasActiveProvisioning } from '../fleet/provisioning.js';
import { requestRestart, cancelProvisioning } from '../provisioning/index.js'; import { requestRestart, cancelProvisioning } from '../provisioning/index.js';
import { nextMaintenanceAt } from '../../plutonium.js'; import { nextMaintenanceAt } from '../../plutonium.js';
import type { MaintenanceJob, MaintenanceSchedule, PlutoniumStatus } 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; let added = 0;
for (const server of new Set(servers)) { for (const server of new Set(servers)) {
if (getServer(server)?.desired_state === 'stopped') continue; 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) added += db().prepare(`INSERT OR IGNORE INTO maintenance_jobs (server, target_revision, requested_at, kind, origin)
VALUES (?, ?, ?, ?, ?)`).run(server, target, nowIso(), kind, origin).changes; 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 }; 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> { async function processJob(job: MaintenanceJob): Promise<void> {
const server = getServer(job.server); const server = getServer(job.server);
if (job.server_deleted_at || !server || server.destroyed_at) return finish(job, 'cancelled', 'Server removed.'); 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) }); const operation = requestRestart(job.server, { kind: 'maintenance', id: String(job.id) });
if (['failed','interrupted','cancelled'].includes(operation.state)) { if (['failed','interrupted','cancelled'].includes(operation.state)) {
return finish(job, operation.state === 'cancelled' ? 'cancelled' : 'failed', operation.error ?? operation.stage); 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); 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)) { 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.`); 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.`);
} }
+14 -3
View File
@@ -41,7 +41,7 @@
<Card padding="lg"> <Card padding="lg">
<h2>Match history</h2> <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} {#if error}
<div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div> <div role="alert">{error} <Button onclick={() => refresh(server, limit)}>Retry</Button></div>
{/if} {/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> <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> <tbody>
{#each matches as match (match.id)} {#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><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> <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} {#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.status === 'active' ? 'In progress' : match.status === 'completed' ? 'Completed' : 'Interrupted'}</td>
<td>{match.startPlayers ?? '—'}</td><td>{match.uniquePlayers}</td><td>{match.endPlayers ?? '—'}</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> <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, 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); } th { color: var(--text-secondary); font-size: var(--text-xs); }
td { font-variant-numeric: tabular-nums; } 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> </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>
+35
View File
@@ -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>
+24 -12
View File
@@ -3,28 +3,35 @@
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; 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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import EmptyState from '$ui/components/primitives/EmptyState.svelte'; import EmptyState from '$ui/components/primitives/EmptyState.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import RecordSource from '$ui/components/RecordSource.svelte';
const number = new Intl.NumberFormat('en-US'); const number = new Intl.NumberFormat('en-US');
const pageSize = 50; const pageSize = 50;
const games = [{ key: 't5', label: 'Black Ops I' }, { key: 't6', label: 'Black Ops II' }]; 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 defaultColumns: PlayerStatMetric[] = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'gamesPlayed'];
const descriptions: Record<LeaderboardMetric, string> = { const descriptions: Record<PlayerStatMetric, string> = {
highestRound: 'The highest round reached in a recorded game.', highestRound: 'The highest round reached in a recorded game.',
kills: 'Cumulative kills across recorded games.', kills: 'Cumulative kills across recorded games.',
roundsSurvived: 'Round progression during observed participation, added across games.', roundsSurvived: 'Round progression during observed participation, added across games.',
headshots: 'Cumulative headshots across recorded games.', headshots: 'Cumulative headshots across recorded games.',
revives: 'Cumulative teammates revived across recorded games.', revives: 'Cumulative teammates revived across recorded games.',
downs: 'Cumulative downs across recorded games, ranked from most to fewest.', 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.', gamesCompleted: 'Games where the player appears in the final game-end report.',
bestKills: 'The most kills reported for a player in a single game.', 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.' 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 data = $state<LeaderboardResponse | null>(null);
let maps = $state<LeaderboardMap[]>([]); 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 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 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>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><span>Players</span><strong>{number.format(data.summary.players)}</strong><small>Each player counted once</small></div>
</div> </div>
</details> </details>
<section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}> <section class="rankings" class:compact={!fullTable} aria-labelledby="rankings-title" aria-busy={loading}>
<div class="rankings-header"> <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 class="refresh-status" role="status">{loading ? 'Refreshing…' : checkedAt ? `Checked ${$timeFormat.time(checkedAt)}` : ''}<span>Refreshes every 15 seconds</span></div>
</div> </div>
{#if data.entries.length} {#if data.entries.length}
@@ -200,7 +207,7 @@
<th scope="col" class="player">Player</th> <th scope="col" class="player">Player</th>
{#each columns as key (key)} {#each columns as key (key)}
<th scope="col" class="stat" class:selected={key === metric} aria-sort={key === metric ? 'descending' : 'none'}> <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> </th>
{/each} {/each}
</tr></thead> </tr></thead>
@@ -209,25 +216,29 @@
<tr> <tr>
<td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td> <td class="rank"><span class:podium={entry.rank <= 3}>{number.format(entry.rank)}</span></td>
<th scope="row" class="player"> <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> <div class="identity"><CopyValue value={entry.guid} label={`GUID for ${entry.name}`} /></div>
</th> </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> </tr>
{/each} {/each}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="pagination"> <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><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> </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} {:else if query || game || map || offset}
<EmptyState spacious title="No players found" description="Try a different game, map, or player name."> <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} {#snippet actions()}<Button size="sm" onclick={clearFilters}>Show all players</Button>{/snippet}
</EmptyState> </EmptyState>
{:else} {: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} {/if}
</section> </section>
{:else if loading} {:else if loading}
@@ -238,7 +249,7 @@
<summary>How these records work</summary> <summary>How these records work</summary>
<div> <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>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>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>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> <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; } .stat { text-align: right; white-space: nowrap; }
td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; } td.stat { font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
.stat.selected { color: var(--accent-primary-hover); } .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); } 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 { 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); } th button:hover { color: var(--accent-primary-hover); }
@@ -1,8 +1,20 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getPlayerProfile } from '$lib/server/fleet/player-profile.js'; 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 }) { 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');
if (!profile) error(404, 'No recorded stats found for this player'); const map = url.searchParams.get('map');
return { profile }; 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, returnTo: leaderboardReturnHref(url.searchParams.get('returnTo')),
scope: { game: game ?? '', map: map ?? '', metric, entry: getPlayerLeaderboardEntry(params.guid, options) } };
} }
@@ -1,22 +1,53 @@
<script lang="ts"> <script lang="ts">
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import { invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import { leaderboardMetrics, type PlayerProfile, type LeaderboardMetric } from '$lib/leaderboard.js'; 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 SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import Button from '$ui/components/primitives/Button.svelte'; import Button from '$ui/components/primitives/Button.svelte';
import CopyValue from '$ui/components/CopyValue.svelte'; import CopyValue from '$ui/components/CopyValue.svelte';
import Badge from '$ui/components/primitives/Badge.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 profile = $derived(data.profile);
const stats = $derived(profile.stats); 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 date = (value: string) => $timeFormat.dateTime(value);
const gameName = (game: string) => game === 't5' ? 'Black Ops I' : game === 't6' ? 'Black Ops II' : game.toUpperCase(); 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 PlayerStatMetric[];
const historyColumns = ['highestRound', 'kills', 'roundsSurvived', 'headshots', 'revives', 'downs', 'bestScore'] as const satisfies readonly LeaderboardMetric[];
let refreshing = $state(false); let refreshing = $state(false);
let refreshError = $state(''); 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() { async function refresh() {
refreshing = true; refreshing = true;
refreshError = ''; refreshError = '';
@@ -30,17 +61,32 @@
<section class="profile"> <section class="profile">
<SectionHeader title={profile.name} eyebrow="Player profile" subtitle="Black Ops I & Black Ops II · Lifetime Zombies stats"> <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} {#snippet actions()}<Button size="sm" loading={refreshing} onclick={refresh}>Refresh stats</Button>{/snippet}
</SectionHeader> </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> <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} {#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"> <section aria-labelledby="totals-title">
<h2 id="totals-title">Lifetime stats</h2> <h2 id="totals-title">Lifetime stats</h2>
<div class="stats"> <div class="stats">
{#each leaderboardMetrics as metric (metric.key)} {#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></div> <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} {/each}
</div> </div>
</section> </section>
@@ -48,49 +94,51 @@
<section aria-labelledby="metrics-title"> <section aria-labelledby="metrics-title">
<h2 id="metrics-title">Player metrics</h2> <h2 id="metrics-title">Player metrics</h2>
<div class="stats metrics"> <div class="stats metrics">
<div class="stat"><span>Headshots / kills</span><strong>{ratio(stats.headshots, stats.kills, true)}</strong></div> {#each efficiencyMetrics as metric (metric.key)}
<div class="stat"><span>Kills per down</span><strong>{ratio(stats.kills, stats.downs)}</strong></div> <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>
<div class="stat"><span>Average kills per game</span><strong>{ratio(stats.kills, stats.gamesPlayed)}</strong></div> {/each}
<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>
</div> </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>
<section class="panel" aria-labelledby="maps-title"> <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="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.) --> <!-- 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> <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}`)} <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> <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 leaderboardMetrics as metric}<td class:best={metric.key === 'highestRound'}>{number.format(map[metric.key])}</td>{/each}</tr> {#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> {/each}</tbody>
</table> </table>
</div> </div>
</section> </section>
<section class="panel" aria-labelledby="history-title"> <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.) --> <!-- 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> <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)} <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> <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} {#each historyColumns as key}<td class:best={key === 'highestRound'}>{number.format(match[key])}</td>{/each}
</tr> </tr>
{/each}</tbody> {/each}</tbody>
</table> </table>
</div> </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 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={`?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><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> </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> </section>
<details class="note"><summary>How these stats work</summary> <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> <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> </details>
</section> </section>
@@ -98,7 +146,7 @@
<style> <style>
.profile { display: flex; flex-direction: column; gap: var(--space-6); min-width: 0; } .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); } .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); } a { color: var(--accent-primary-hover); }
.stats { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--space-3); } .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); } .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 { border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
.panel-heading { padding: var(--space-5); } .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; } .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); } section > .note { margin-top: var(--space-3); }
.mobile-records { display: none; }
.table-scroll { max-width: 100%; overflow-x: auto; } .table-scroll { max-width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); } 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; } 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); } .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); } summary { cursor: pointer; color: var(--text-secondary); }
@media (max-width: 1000px) { .stats { grid-template-columns: repeat(3, minmax(0, 1fr)); } } @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> </style>
+9
View File
@@ -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 };
}
+121
View File
@@ -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 } });
}
});
+92
View File
@@ -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);
});
});
+20 -7
View File
@@ -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 { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js'); const { getPlayerProfile } = await import('../../src/lib/server/fleet/player-profile.js');
const { serverMatchHistory } = await import('../../src/lib/server/fleet/match-history.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(getPlayerProfile(player().guid)).toBeNull();
expect(serverMatchHistory()).toEqual({ total: 0, matches: [] }); expect(serverMatchHistory()).toEqual({ total: 0, matches: [] });
await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]); await report('round_change', 2, [player(undefined, { kills: 6, score: 500 })]);
expect(getLeaderboard()).toMatchObject({ total: 1, entries: [expect.objectContaining({ expect(getLeaderboard()).toMatchObject({ entries: [], maps: [], total: 0, summary: { players: 0, matches: 0, kills: 0 } });
highestRound: 2, kills: 6, bestScore: 1000, roundsSurvived: 1, gamesPlayed: 1, gamesCompleted: 0 expect(getPlayerProfile(player().guid)).toBeNull();
})], summary: { players: 1, matches: 1, kills: 6 } }); expect(serverMatchHistory()).toMatchObject({ total: 1, matches: [{ status: 'active' }] });
expect(getPlayerProfile(departed.guid)).toBeNull(); expect(getPlayerProfile(departed.guid)).toBeNull();
await report('game_end', 2, [player(undefined, { kills: 10 })]); await report('game_end', 2, [player(undefined, { kills: 10 })]);
const board = getLeaderboard(); const board = getLeaderboard();
const profile = getPlayerProfile(player().guid); const profile = getPlayerProfile(player().guid);
const history = serverMatchHistory(); 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 }] }); expect(history).toMatchObject({ total: 1, matches: [{ startPlayers: 2, uniquePlayers: 2, durationMs: 3000, finalRound: 2 }] });
await report('game_start'); await report('game_start');
await report('game_end', 1, [player(undefined, { kills: 5000 })]); 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 () => { 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' }); const other = player('4229696', { kills: 40, name: 'Other player' });
await report('game_start', 0, [player(), other]); await report('game_start', 0, [player(), other]);
await report('round_change', 4, [player(undefined, { kills: 80 }), 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 })]); 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(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(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 () => { 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' 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(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)!; const profile = getPlayerProfile(player().guid)!;
expect(profile.stats.gamesPlayed).toBe(2); expect(profile.stats).toMatchObject({ gamesPlayed: 1, kills: 120 });
expect(profile.matches.find(match => match.id === original.id)?.server_deleted_at).toEqual(expect.any(String)); expect(profile.matches).toEqual([expect.objectContaining({ id: history.find(match => match.id !== original.id)!.id,
expect(profile.stats.kills).toBe(240); server_deleted_at: null, status: 'completed' })]);
}); });
it('does not fabricate players for an empty match', async () => { it('does not fabricate players for an empty match', async () => {
+44
View File
@@ -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);
});
});
+77 -8
View File
@@ -20,11 +20,11 @@ async function seedRecords() {
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`); VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '2026-09-01T00:00:00.000Z', ?)`);
const iso = (day: number) => `2026-09-0${day}T00: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(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(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(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(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), 'active', 2); 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', '101', 'Ace');
player.run('t5', '202', 'Blake'); player.run('t5', '202', 'Blake');
player.run('t5', '303', 'Casey'); player.run('t5', '303', 'Casey');
@@ -33,6 +33,7 @@ async function seedRecords() {
player.run('t6', '505', 'Eve'); player.run('t6', '505', 'Eve');
record.run(1, '101', 100, 10, 9, 50, 2, 1, 1000, 1, 1, iso(2)); 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)); 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, '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(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)); 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', () => { 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 () => { it('excludes persisted round 0/1 matches from every history, total, ranking and map option', async () => {
const handle = await seedRecords(); const handle = await seedRecords();
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js'); 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, rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 770,
roundsSurvived: 38, headshots: 320, revives: 8, downs: 4, roundsSurvived: 38, headshots: 320, revives: 8, downs: 4,
gamesPlayed: 4, gamesCompleted: 3, bestKills: 500, bestScore: 5000, 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.total).toBe(4);
expect(board.entries.map(entry => [entry.guid, entry.rank])).toEqual([['101', 1], ['505', 2], ['202', 3], ['303', 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({ expect(t5.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270, rank: 1, guid: '101', name: 'Ace', highestRound: 10, kills: 270,
roundsSurvived: 19, headshots: 120, revives: 4, downs: 3, gamesPlayed: 3, 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 } }); expect(t5).toMatchObject({ total: 3, summary: { players: 3, matches: 3, kills: 440, roundsSurvived: 35 } });
const t6 = getLeaderboard({ game: 't6' }); const t6 = getLeaderboard({ game: 't6' });
expect(t6.entries.find(entry => entry.guid === '101')).toEqual({ expect(t6.entries.find(entry => entry.guid === '101')).toEqual({
rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500, rank: 1, guid: '101', name: 'Ace', highestRound: 20, kills: 500,
roundsSurvived: 19, headshots: 200, revives: 4, downs: 1, gamesPlayed: 1, 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 } }); 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.entries.map(entry => [entry.name, entry.rank])).toEqual([['Casey', 3]]);
expect(search.total).toBe(1); expect(search.total).toBe(1);
expect(search.summary).toEqual(board.summary); 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]) expect(getLeaderboard({ game: 't5', metric: 'kills', limit: 1, offset: 1 }).entries[0])
.toMatchObject({ name: 'Blake', rank: 2 }); .toMatchObject({ name: 'Blake', rank: 2 });
expect(getLeaderboard({ q: '303' }).entries[0]).toMatchObject({ name: 'Casey', rank: 4 }); 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(first.total).toBe(4);
expect(second.total).toBe(4); expect(second.total).toBe(4);
expect(second.summary).toEqual(first.summary); 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 () => { 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[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.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.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 }, expect(getPlayerProfile('505')).toMatchObject({ name: 'Eve', stats: { kills: 200, gamesPlayed: 1 },
maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] }); maps: [expect.objectContaining({ game: 't6' })], matches: [expect.objectContaining({ game: 't6' })] });
expect(getPlayerProfile('404')).toBeNull(); expect(getPlayerProfile('404')).toBeNull();
+146
View File
@@ -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 });
});
+171
View File
@@ -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 }));
}
});
});
+120
View File
@@ -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 }));
});
+206
View File
@@ -31,6 +31,78 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.unstubAl
const api = () => import('../../src/lib/server/fleet/plutonium.js'); const api = () => import('../../src/lib/server/fleet/plutonium.js');
describe('Plutonium versions', () => { 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 () => { it('coalesces published-release checks without probing the fleet', async () => {
const { checkRelease, release } = await import('../../src/lib/server/integrations/plutonium.js'); const { checkRelease, release } = await import('../../src/lib/server/integrations/plutonium.js');
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js'); const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
@@ -292,6 +364,88 @@ describe('persistent maintenance', () => {
expect(komodoRestart).not.toHaveBeenCalled(); expect(komodoRestart).not.toHaveBeenCalled();
expect(m.plutoniumStatus().jobs[0].detail).toContain('another provisioning operation'); 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 () => { 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 m = await api(); await m.checkPlutonium(); m.queueMaintenance([servers[0]]);
const { db } = await import('../../src/lib/server/db/index.js'); 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().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'); 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 () => { it('holds queued automatic updates when their policy is disabled, but finishes submitted recovery while paused', async () => {
const m = await outdated(); const m = await outdated();
const p = await import('../../src/lib/server/maintenance/policies.js'); p.setMaintenancePolicy('updates', true); const p = await import('../../src/lib/server/maintenance/policies.js'); p.setMaintenancePolicy('updates', true);
+82
View File
@@ -194,6 +194,50 @@ describe('Watchdog detection and ownership', () => {
expect(incidents()).toEqual([]); expect(incidents()).toEqual([]);
expect(operations()).toEqual([expect.objectContaining({ action: 'stop' })]); 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', () => { describe('Watchdog recovery persistence', () => {
@@ -254,6 +298,44 @@ describe('Watchdog recovery persistence', () => {
expect(operations().map(op => op.action)).toEqual(['restart', 'rebuild']); 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 () => { it('resumes a persisted detection window and does not duplicate queued work after reopening', async () => {
down(); down();
await watchdog.watchdogTick(true); await watchdog.watchdogTick(true);