Show recorded match score totals with legacy fallback (Refs #147)
CI / checks (push) Successful in 5m22s

This commit is contained in:
2026-10-12 16:13:48 -04:00
parent 81fa35b792
commit ffda04f276
6 changed files with 367 additions and 13 deletions
+14 -3
View File
@@ -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.
+95
View File
@@ -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",
+1
View File
@@ -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",
+27 -9
View File
@@ -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);
</script>
<svelte:head><title>{match.map} · Match #{match.id} · Electric Cherry</title></svelte:head>
@@ -122,6 +126,11 @@
<p>
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}
</p>
</div>
{#if match.players.length === 0}
@@ -161,7 +170,7 @@
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
>
{#each stats as stat}<td class:round={stat.key === 'highestRound'}
>{player[stat.key] === null
>{player[stat.key] == null
? '—'
: number.format(player[stat.key]!)}</td
>{/each}
@@ -178,11 +187,20 @@
<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>Kills, headshots, revives, and downs retain each player's highest reported value.</p>
{#if hasScoreTotals}
<p>
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.
</p>
{:else}
<p>
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.
</p>
{/if}
<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
+219
View File
@@ -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<MatchPlayer> = {}): MatchPlayer {
return {
guid: '101',
name: 'Cherry',
hasStats: true,
presentAtEnd: true,
canViewProfile: true,
firstSeenAt: '2026-10-07T00:00:00Z',
lastSeenAt: '2026-10-07T00:01:00Z',
highestRound: 4,
roundsSurvived: 3,
kills: 20,
headshots: 5,
revives: 1,
downs: 2,
bestScore: 2760,
scoreTotal: 8470,
...overrides
};
}
function match(players: MatchPlayer[], id = 1): MatchDetail {
return {
id,
server: 't5_zombie_theater_1',
game: 't5',
map: 'Kino Der Toten',
observedRound: 4,
finalRound: 4,
status: 'completed',
reporterMatchKey: null,
missingReports: null,
startObserved: true,
startedAt: '2026-10-07T00:00:00Z',
lastReportedAt: '2026-10-07T00:01:00Z',
endedAt: '2026-10-07T00:01:00Z',
durationMs: 60_000,
startPlayers: players.length,
uniquePlayers: players.length,
endPlayers: players.filter((entry) => entry.presentAtEnd).length,
players,
chat: []
};
}
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
});
function show(initial: MatchDetail) {
const data = writable({ match: initial });
const state = fromStore(data);
component = mount(MatchPage, {
target: document.body,
props: {
get data() {
return state.current;
}
}
});
flushSync();
return (next: MatchDetail) => {
data.set({ match: next });
flushSync();
};
}
function expectScores(label: string, values: string[]) {
const headers = [...document.querySelectorAll('thead th')].map((cell) => cell.textContent);
expect(headers).toEqual([
'Player',
'Participation',
'Highest round',
'Kills',
'Headshots',
'Revives',
'Downs',
'Rounds survived',
label,
'Last seen'
]);
const scores = [...document.querySelectorAll('tbody tr')].map(
(row) => row.children[8].textContent
);
expect(scores).toEqual(values);
expect(document.body.textContent).not.toContain('NaN');
}
function expectExplanation(totals: boolean) {
const intro = document.querySelector('.results-heading')!.textContent!.replace(/\s+/g, ' ');
const about = document.querySelector('details.note')!.textContent!.replace(/\s+/g, ' ');
if (totals) {
expect(intro).toContain('Total score is the highest recorded cumulative points earned');
expect(about).toContain('Total score is the highest recorded cumulative points earned');
expect(about).toContain(
'Players absent from the final report retain their latest recorded total.'
);
expect(about).toContain(
'A dash in Total score means no total was recorded for that player.'
);
expect(document.body.textContent).not.toContain('Highest reported score');
expect(about).not.toContain('points balance');
} else {
expect(intro).toContain('Highest reported score is the highest recorded points balance.');
expect(about).toContain(
'Highest reported score is a points balance, not total points earned'
);
expect(document.body.textContent).not.toContain('Total score');
}
}
describe('match result score presentation', () => {
it('shows recorded totals instead of balances, including totals for departed players', () => {
show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })]));
expectScores('Total score', ['8,470', '6,500']);
expectExplanation(true);
expect(document.querySelector('tbody')!.textContent).not.toContain('2,760');
expect(document.querySelector('tbody')!.textContent).toContain('Not at match end');
});
it('preserves balances when every total is null or missing', () => {
show(
match([
player({ scoreTotal: null }),
player({ guid: '202', scoreTotal: undefined, bestScore: 500 })
])
);
expectScores('Highest reported score', ['2,760', '500']);
expectExplanation(false);
});
it('treats zero as a recorded total', () => {
show(match([player({ scoreTotal: 0 })]));
expectScores('Total score', ['0']);
expectExplanation(true);
});
it('uses dashes for null and missing totals even when balances are known', () => {
show(
match([
player({ scoreTotal: null }),
player({ guid: '202', scoreTotal: undefined }),
player({ guid: '303' })
])
);
expectScores('Total score', ['—', '—', '8,470']);
expectExplanation(true);
expect(document.querySelector('tbody')!.textContent).not.toContain('2,760');
});
it.each([null, 8470])(
'keeps participants without stats when the other total is %s',
(scoreTotal) => {
show(
match([
player({ scoreTotal }),
player({
guid: '202',
hasStats: false,
presentAtEnd: false,
highestRound: null,
roundsSurvived: null,
kills: null,
headshots: null,
revives: null,
downs: null,
bestScore: null,
scoreTotal: null
})
])
);
expectScores(scoreTotal === null ? 'Highest reported score' : 'Total score', [
scoreTotal === null ? '2,760' : '8,470',
'—'
]);
const row = document.querySelectorAll('tbody tr')[1];
expect(row.textContent).toContain('No stats recorded');
expect([...row.children].slice(2, 9).map((cell) => cell.textContent)).toEqual(
Array(7).fill('—')
);
}
);
it('updates the same mounted page when navigating in both directions and refreshing data', () => {
const legacy = match([player({ scoreTotal: null })]);
const current = match([player()], 2);
const update = show(legacy);
expectScores('Highest reported score', ['2,760']);
expectExplanation(false);
update(current);
expectScores('Total score', ['8,470']);
expectExplanation(true);
update(legacy);
expectScores('Highest reported score', ['2,760']);
expectExplanation(false);
update({ ...legacy, players: [player({ scoreTotal: 0 })] });
expectScores('Total score', ['0']);
expectExplanation(true);
});
it('renders an empty match without a score table', () => {
show(match([]));
expect(document.body.textContent).toContain('No player results recorded');
expect(document.querySelector('table')).toBeNull();
});
});
+11 -1
View File
@@ -12,7 +12,17 @@ export default defineConfig({
test: {
name: 'application',
include: ['tests/**/*.{test,spec}.{js,ts}'],
exclude: [...configDefaults.exclude, migrationMemoryTest],
exclude: [...configDefaults.exclude, migrationMemoryTest, 'tests/client/**'],
sequence: { groupOrder: 0 }
}
},
{
extends: true,
resolve: { conditions: ['browser'] },
test: {
name: 'client',
environment: 'happy-dom',
include: ['tests/client/**/*.{test,spec}.{js,ts}'],
sequence: { groupOrder: 0 }
}
},