diff --git a/docs/guides/leaderboard.md b/docs/guides/leaderboard.md
index f11c7d0..b1048e3 100644
--- a/docs/guides/leaderboard.md
+++ b/docs/guides/leaderboard.md
@@ -75,9 +75,20 @@ 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, interrupted, and **End report missing** matches remain listed
+retain their earlier counters; players with no stats display dashes. Counters are
+the highest reported values within the match, not necessarily its final scoreboard.
+
+Each match shows one score column. If any player has a recorded total (including
+zero), **Total score** shows each player's highest recorded cumulative points
+earned in that match. Players absent from the final report retain their latest
+recorded total. A dash in this column means no total was recorded for that player;
+their points balance is never substituted. Historical matches where all totals
+are missing retain **Highest reported score**, the highest recorded points
+balance, not total points earned or necessarily the final score. This choice is
+based on recorded data, not the match date. It does not change leaderboard or
+profile score rankings, which still use balances.
+
+Active, interrupted, and **End report missing** matches remain listed
without results links. Matches can filter each status and show known gaps in the
reporter's sequence; a delayed report can reduce that missing-report count.
Match pages remain available after the original server is removed.
diff --git a/package-lock.json b/package-lock.json
index 402ec73..c13c2eb 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1",
"globals": "^17.13.0",
+ "happy-dom": "^21.0.1",
"prettier": "^3.9.9",
"prettier-plugin-svelte": "^3.5.2",
"svelte": "^5.1.0",
@@ -1531,6 +1532,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/ws": {
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.71.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz",
@@ -2079,6 +2097,19 @@
"ieee754": "^1.1.13"
}
},
+ "node_modules/buffer-image-size": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz",
+ "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/cacheable": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
@@ -2271,6 +2302,19 @@
"once": "^1.4.0"
}
},
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
@@ -2807,6 +2851,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/happy-dom": {
+ "version": "21.0.1",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-21.0.1.tgz",
+ "integrity": "sha512-pRBs/u9fhrncyByOl58DMex0R2uAj6ZPKWIjV5j5yNGZBqLw3P6BTRhlc0DD5b/Ht4/6JYTMuE2LJhUWRC6SGw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": ">=20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
+ "@types/ws": "^8.18.1",
+ "buffer-image-size": "^0.6.4",
+ "entities": "^7.0.1",
+ "whatwg-mimetype": "^3.0.0",
+ "ws": "^8.21.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/hashery": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
@@ -4328,6 +4391,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/whatwg-mimetype": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
+ "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4377,6 +4450,28 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
+ "node_modules/ws": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
+ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 3728d8d..73568d2 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.23.1",
"globals": "^17.13.0",
+ "happy-dom": "^21.0.1",
"prettier": "^3.9.9",
"prettier-plugin-svelte": "^3.5.2",
"svelte": "^5.1.0",
diff --git a/src/routes/matches/[id]/+page.svelte b/src/routes/matches/[id]/+page.svelte
index d65820b..a242453 100644
--- a/src/routes/matches/[id]/+page.svelte
+++ b/src/routes/matches/[id]/+page.svelte
@@ -24,15 +24,19 @@
: match.game.toUpperCase()
);
const number = new Intl.NumberFormat('en-US');
- const stats = [
+ const hasScoreTotals = $derived(match.players.some((player) => player.scoreTotal != null));
+ const stats = $derived([
{ 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;
+ {
+ key: hasScoreTotals ? 'scoreTotal' : 'bestScore',
+ label: hasScoreTotals ? 'Total score' : 'Highest reported score'
+ }
+ ] as const);
Every recorded player, including those absent from the final report. Stats are the highest reported counters for this match. + {#if hasScoreTotals} + Total score is the highest recorded cumulative points earned in this match. + {:else} + Highest reported score is the highest recorded points balance. + {/if}
{#if match.players.length === 0} @@ -161,7 +170,7 @@ >{#if !player.hasStats}No stats recorded{/if} {#each stats as stat}- 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. -
+Kills, headshots, revives, and downs retain each player's highest reported value.
+ {#if hasScoreTotals} ++ Total score is the highest recorded cumulative points earned in this match. Players + absent from the final report retain their latest recorded total. A dash in Total + score means no total was recorded for that player. +
+ {:else} ++ Highest reported score is a points balance, not total points earned or necessarily + the final score. This match has no recorded totals, so each player's highest + reported balance is shown. +
+ {/if}
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
diff --git a/tests/client/ui-match-results.test.ts b/tests/client/ui-match-results.test.ts
new file mode 100644
index 0000000..34b300a
--- /dev/null
+++ b/tests/client/ui-match-results.test.ts
@@ -0,0 +1,219 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { flushSync, mount, unmount } from 'svelte';
+import { fromStore, writable } from 'svelte/store';
+import MatchPage from '../../src/routes/matches/[id]/+page.svelte';
+import type { MatchDetail, MatchPlayer } from '../../src/lib/match-history.js';
+
+vi.mock('$app/stores', () => ({
+ page: writable({ url: new URL('http://localhost/matches/1'), data: { user: null } })
+}));
+
+function player(overrides: Partial