Capture team chat and expose moderation match archives

This commit is contained in:
2026-10-17 20:11:49 -04:00
parent b678de2f6f
commit 870f13b150
28 changed files with 943 additions and 81 deletions
+9 -6
View File
@@ -4,6 +4,8 @@ export interface ChatMessage {
slot: number;
player: string;
message: string;
/** Omitted for public chat to preserve existing archived message identities. */
channel?: 'team';
}
export type ChatEntry =
@@ -98,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
previousSeconds = seconds;
}
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
const match = /^\s*(\d+:[0-5]\d)\s+(say|sayteam);([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
if (!match) continue;
const message = plainText(match[5]);
const message = plainText(match[6]);
if (!message) continue;
push({
type: 'message',
message: {
time: match[1],
guid: match[2],
slot: Number(match[3]),
player: plainText(match[4]) || 'Unknown player',
message
guid: match[3],
slot: Number(match[4]),
player: plainText(match[5]) || 'Unknown player',
message,
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
}
});
}
+8 -3
View File
@@ -10,6 +10,7 @@ export interface MatchHistoryQuery {
sort: MatchSort;
direction: 'asc' | 'desc';
page: number;
archive: boolean;
}
export const matchQueryKeys = [
'game',
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
'status',
'sort',
'direction',
'page'
'page',
'archive'
] as const;
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const game = params.get('game') ?? '';
@@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const sort = params.get('sort') || 'started';
const direction = params.get('direction') || 'desc';
const page = Number(params.get('page') || 1);
const archive = params.get('archive') ?? '';
if (
!['', 't5', 't6'].includes(game) ||
map.length > 1024 ||
@@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
!['asc', 'desc'].includes(direction) ||
!Number.isSafeInteger(page) ||
page < 1 ||
page > 1000000
page > 1000000 ||
!['', '0', '1'].includes(archive)
)
throw new Error('Invalid match history filters.');
return {
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
status: status as MatchHistoryQuery['status'],
sort: sort as MatchSort,
direction: direction as 'asc' | 'desc',
page
page,
archive: archive === '1'
};
}
/** Namespaced filters also work inside an instance's Matches tab. */
-1
View File
@@ -47,7 +47,6 @@ export interface MatchPlayer {
}
export interface MatchDetail extends ServerMatch {
status: 'completed';
game: string;
startObserved: boolean;
players: MatchPlayer[];
+50 -6
View File
@@ -212,10 +212,14 @@ interface ChatReadState {
entries: ContextualChatEntry[];
contextVersion?: string;
wasOccupied: boolean;
needsDrain: boolean;
bytesRead: number;
}
const readers = new Map<string, ChatReadState>();
const failures = new Map<string, number>();
const CHAT_READ_MIN_AGE_MS = 15_000;
const MAX_IDLE_STALLED_READS = 3;
const idleStalls = new Map<string, number>();
type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
const lastReads = new Map<string, LastChatRead>();
@@ -249,6 +253,7 @@ export function collectServerChat(
const key = chatReadKey(server);
const existing = pending.get(key);
if (existing) return existing;
let progressed = false;
const work = (async () => {
const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) throw new Error('Invalid server log path');
@@ -263,7 +268,9 @@ export function collectServerChat(
partial: Buffer.alloc(0),
skipLine: false,
entries: [],
wasOccupied: false
wasOccupied: false,
needsDrain: false,
bytesRead: 0
};
const tail = await readLogChunk(path, state.cursor);
const current = getServer(server.name);
@@ -290,7 +297,10 @@ export function collectServerChat(
state.entries = [];
}
state.cursor = tail.cursor;
const bytes = Buffer.concat([state.partial, Buffer.from(tail.contentBase64, 'base64')]);
const content = Buffer.from(tail.contentBase64, 'base64');
progressed = content.length > 0;
state.bytesRead += content.length;
const bytes = Buffer.concat([state.partial, content]);
const end = bytes.lastIndexOf(10) + 1;
if (end) {
const lines = bytes
@@ -318,14 +328,17 @@ export function collectServerChat(
state.skipLine = true;
}
// File mtime anchors the final line only after the backlog has been read.
if (tail.cursor.offset < tail.size || state.partial.length) {
state.needsDrain = tail.cursor.offset < tail.size || state.partial.length > 0;
if (state.needsDrain) {
state.signature = undefined;
readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false };
}
} else {
// Older agents remain readable during a rolling update.
state.needsDrain = false;
changed = state.signature !== `${tail.size}:${tail.mtime}`;
progressed = changed;
if (changed) state.lines = tail.lines;
}
const signature = `${tail.size}:${tail.mtime}`;
@@ -354,6 +367,9 @@ export function collectServerChat(
})()
.then(
(result) => {
const state = readers.get(server.name);
if (progressed || result.missing || (state?.key === key && !state.needsDrain))
idleStalls.delete(key);
lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
return result;
},
@@ -377,25 +393,53 @@ export async function collectFleetChat(): Promise<void> {
for (const name of readers.keys()) if (!names.has(name)) readers.delete(name);
for (const name of failures.keys()) if (!names.has(name)) failures.delete(name);
for (const key of lastReads.keys()) if (!keys.has(key)) lastReads.delete(key);
for (const key of idleStalls.keys()) if (!keys.has(key)) idleStalls.delete(key);
const servers = live.filter((server) => {
if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
if (server.container_state && !['running', 'unknown'].includes(server.container_state))
return false;
const players = observe(server).last_players ?? server.last_players;
// One final read after players leave retains messages written at match end.
return players !== 0 || readers.get(server.name)?.wasOccupied;
const key = chatReadKey(server);
if (players !== 0) {
idleStalls.delete(key);
return true;
}
const stalls = idleStalls.get(key) ?? 0;
if (stalls >= MAX_IDLE_STALLED_READS) return false;
const last = lastReads.get(key);
if (stalls > 0 && last && Date.now() - last.completedAt < CHAT_READ_MIN_AGE_MS)
return false;
const state = readers.get(server.name);
// Pick up surviving final logs after a collector restart, then finish any
// unread chunks even after players leave. Errors and incomplete final lines
// have bounded idle retries rather than polling an empty server forever.
return (
!last ||
'error' in last ||
(state?.key === key && (state.wasOccupied || state.needsDrain))
);
});
// Bound agent traffic and isolate failures to their own server.
async function collect() {
for (let server = servers.shift(); server; server = servers.shift()) {
const key = chatReadKey(server);
const before = readers.get(server.name);
try {
await collectServerChat(server);
const state = readers.get(server.name);
if (state)
if (state?.key === key) {
state.wasOccupied =
(observe(server).last_players ?? server.last_players ?? 0) > 0;
if (!state.wasOccupied && state.needsDrain) {
const progressed =
before?.key !== key || state.bytesRead > before.bytesRead;
idleStalls.set(key, progressed ? 0 : (idleStalls.get(key) ?? 0) + 1);
} else idleStalls.delete(key);
}
failures.delete(server.name);
} catch (error) {
if ((observe(server).last_players ?? server.last_players) === 0)
idleStalls.set(key, (idleStalls.get(key) ?? 0) + 1);
if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
console.error(`[chat] Could not collect chat for ${server.name}`, error);
failures.set(server.name, Date.now());
@@ -0,0 +1,16 @@
import { error } from '@sveltejs/kit';
import type { AuthUser } from '../../auth.js';
import { AuthError } from '../auth/index.js';
import { moderationActor } from '../moderation/core.js';
export type MatchArchiveLocals = { user: AuthUser | null };
/** Recheck current account privileges before returning the expanded moderation archive. */
export function requireMatchArchiveAccess(user: Pick<AuthUser, 'id'> | null | undefined): void {
try {
moderationActor(user?.id ?? '');
} catch (cause) {
if (cause instanceof AuthError) error(cause.status, cause.message);
throw cause;
}
}
+9 -5
View File
@@ -32,7 +32,7 @@ export function serverMatchHistory(
now = Date.now()
): MatchHistoryResponse {
const handle = db();
const clauses = [`m.highest_round >= ${MIN_RECORDED_ROUND}`];
const clauses = [query.archive ? '1=1' : `m.highest_round >= ${MIN_RECORDED_ROUND}`];
const parameters: Array<string | number> = [];
if (server !== undefined) {
clauses.push('m.server = ?');
@@ -93,14 +93,18 @@ export function serverMatchHistory(
}
/** Read durable match records without depending on a current server or scoreboard. */
export function getMatchDetail(id: number): MatchDetail | null {
export function getMatchDetail(id: number, archive = false): MatchDetail | null {
if (!Number.isSafeInteger(id) || id < 1) return null;
const handle = db();
const match = handle
.prepare(
`SELECT ${matchSummary}, m.start_observed AS startObserved
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
FROM leaderboard_matches m WHERE m.id = ?${
archive
? ''
: ` AND m.status = 'completed'
AND m.highest_round >= ${MIN_RECORDED_ROUND}`
}`
)
.get(id) as
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number })
@@ -116,7 +120,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
(SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats,
CASE WHEN ? IS NULL THEN p.present ELSE p.completed END AS presentAtEnd,
CASE WHEN ? != 'completed' THEN 0 WHEN ? IS NULL THEN p.present ELSE p.completed END 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,
@@ -131,7 +135,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
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, match.reporterMatchKey, id) as Array<
.all(match.game, match.status, match.reporterMatchKey, id) as Array<
Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number;
presentAtEnd: number;
+1
View File
@@ -81,6 +81,7 @@ export function archivedEvidenceSnapshots(
entry.message.guid === old.guid &&
entry.message.time === old.time &&
entry.message.player === old.player &&
entry.message.channel === old.channel &&
entry.message.message === old.message
);
if (original) attached.add(original.messageId!);
+5 -1
View File
@@ -9,6 +9,7 @@
import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
@@ -337,7 +338,10 @@
<span class="sr-only">Select evidence</span>
</label>
{/if}
<strong>{item.entry.message.player}</strong
<strong
>{item.entry.message
.player}{#if item.entry.message.channel === 'team'}
<Badge>Team chat</Badge>{/if}</strong
>{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
class="moderation-link"
+53 -12
View File
@@ -5,6 +5,7 @@
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte';
import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js';
@@ -42,6 +43,7 @@
let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now());
const filters = $derived(readMatchQuery(matchApiParams($page.url)));
const canViewArchive = $derived(canReadModeration($page.data?.user?.role));
const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url);
params.set('limit', String(matchPageSize));
@@ -131,6 +133,19 @@
</script>
<section class="history" aria-label="Match history">
{#if canViewArchive}<div class="archive-control">
<label
><input
type="checkbox"
checked={filters.archive}
onchange={(event) =>
update({ archive: event.currentTarget.checked ? '1' : '' })}
/> Moderation archive</label
>
{#if filters.archive}<p>
Includes round-one, interrupted, and unfinished matches for evidence review.
</p>{/if}
</div>{/if}
{#snippet filterFields(suffix: string)}
<div class="filters">
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
@@ -357,9 +372,12 @@
<tr>
<td><RelativeTime value={match.startedAt} /></td>
<td class="match">
{#if match.status === 'completed'}<a
{#if match.status === 'completed' || (canViewArchive && filters.archive)}<a
class="match-link"
href={contextHref(`/matches/${match.id}`, $page.url)}
href={contextHref(
`/matches/${match.id}${canViewArchive && filters.archive ? '?matchArchive=1' : ''}`,
$page.url
)}
aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a
>
@@ -379,7 +397,8 @@
? ` · ${duration}`
: ''}</small
>
{#if notReporting}<small class="mobile-match-context"
{#if notReporting || (filters.archive && match.status !== 'completed')}<small
class="mobile-match-context"
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
@@ -403,7 +422,7 @@
class:interrupted={match.status === 'interrupted' ||
match.status === 'end-report-missing' ||
notReporting}>{status}</span
>{#if notReporting}<small
>{#if notReporting || (filters.archive && match.status !== 'completed')}<small
>Last report <RelativeTime
value={match.lastReportedAt}
missing="Not recorded"
@@ -469,14 +488,16 @@
<summary>About match history</summary>
<p>
Players counts unique players across all reports. A dash means the value is unavailable,
not zero. In-progress matches show elapsed time since their recorded start and link to
the live server while reports are current. Otherwise, they show Not reporting with the
last report time. Completed durations require a recorded start and completed ending. End
report missing means a newer game was reported before this game's end report arrived.
Missing report counts show gaps in the reporter's sequence and can decrease as delayed
reports arrive. Only completed games contribute to the leaderboard. Round shows the
final round for completed matches and the highest observed round for unfinished matches.
Open a completed match for exact timestamps and starting and ending player counts.
not zero. In-progress matches show elapsed time since their recorded start. {#if filters.archive}Archive
links open retained match reports and chat.{:else}They link to the live server while
reports are current.{/if} Otherwise, they show Not reporting with the last report time.
Completed durations require a recorded start and completed ending. End report missing means
a newer game was reported before this game's end report arrived. Missing report counts show
gaps in the reporter's sequence and can decrease as delayed reports arrive. Only completed
games contribute to the leaderboard. Round shows the final round for completed matches and
the highest observed round for unfinished matches. Open a {filters.archive
? 'recorded'
: 'completed'} match for timestamps and participant details.
</p>
</details>
</section>
@@ -487,6 +508,26 @@
gap: var(--space-4);
min-width: 0;
}
.archive-control {
display: grid;
gap: var(--space-2);
font-size: var(--text-sm);
}
.archive-control label {
display: flex;
align-items: center;
gap: var(--space-2);
width: fit-content;
cursor: pointer;
}
.archive-control input {
accent-color: var(--accent-primary);
}
.archive-control p {
margin: 0;
color: var(--text-secondary);
line-height: var(--leading-body);
}
.filters {
display: flex;
align-items: flex-start;
@@ -27,6 +27,7 @@
<li class:highlighted>
<div class="author">
<strong>{entry.message.player}</strong>
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if}
+15 -2
View File
@@ -1,8 +1,13 @@
import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
import { authError } from '$lib/server/auth/http.js';
export function GET({ url }: { url: URL }) {
export function GET({ url, locals }: { url: URL; locals?: MatchArchiveLocals }) {
const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +18,13 @@ export function GET({ url }: { url: URL }) {
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(undefined, limit, query));
try {
if (query.archive) requireMatchArchiveAccess(locals?.user);
return json(
serverMatchHistory(undefined, limit, query),
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
);
} catch (cause) {
return authError(cause);
}
}
@@ -1,8 +1,21 @@
import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
import { authError } from '$lib/server/auth/http.js';
export function GET({ params, url }: { params: { name: string }; url: URL }) {
export function GET({
params,
url,
locals
}: {
params: { name: string };
url: URL;
locals?: MatchArchiveLocals;
}) {
const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +26,13 @@ export function GET({ params, url }: { params: { name: string }; url: URL }) {
} catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 });
}
return json(serverMatchHistory(params.name, limit, query));
try {
if (query.archive) requireMatchArchiveAccess(locals?.user);
return json(
serverMatchHistory(params.name, limit, query),
query.archive ? { headers: { 'Cache-Control': 'no-store' } } : undefined
);
} catch (cause) {
return authError(cause);
}
}
+17 -1
View File
@@ -1,12 +1,28 @@
import { error } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.js';
export function load({ url }: { url: URL }) {
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({
url,
locals,
setHeaders
}: {
url: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(matchApiParams(url));
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { history: serverMatchHistory(undefined, matchPageSize, query) };
}
+12 -2
View File
@@ -2,12 +2,22 @@
let { data }: { data: import('./$types').PageData } = $props();
import MatchHistory from '$ui/components/MatchHistory.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import { page } from '$app/stores';
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
</script>
<svelte:head><title>Matches · Electric Cherry</title></svelte:head>
<svelte:head
><title>{archive ? 'Moderation match archive' : 'Matches'} · Electric Cherry</title
></svelte:head
>
<div class="page">
<SectionHeader title="Matches" subtitle="Recorded matches across your servers." />
<SectionHeader
title={archive ? 'Moderation match archive' : 'Matches'}
subtitle={archive
? 'Retained match reports and chat for evidence review.'
: 'Recorded matches across your servers.'}
/>
<MatchHistory initial={data.history} />
</div>
+27 -2
View File
@@ -1,9 +1,34 @@
import { error } from '@sveltejs/kit';
import { getMatchDetail } from '$lib/server/fleet/match-history.js';
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({ params }: { params: { id: string } }) {
export function load({
params,
url,
locals,
setHeaders
}: {
params: { id: string };
url?: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
if (!/^[1-9]\d*$/.test(params.id)) error(404, 'Match not found');
const match = getMatchDetail(Number(params.id));
const match = getMatchDetail(Number(params.id), query.archive);
if (!match) error(404, 'Match not found');
return { match };
}
+79 -24
View File
@@ -1,6 +1,10 @@
<script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
import { browser } from '$app/environment';
import { timestampISO } from '$lib/time-zone.js';
import { freshStatusTimestamp } from '$lib/server-freshness.js';
import { uiNow } from '$ui/stores/servers.js';
import { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import type { MatchDetail } from '$lib/match-history.js';
@@ -16,6 +20,27 @@
let { data }: { data: { match: MatchDetail } } = $props();
const match = $derived(data.match);
const completed = $derived(match.status === 'completed');
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
const reporting = $derived(
match.status === 'active' &&
!match.server_deleted_at &&
freshStatusTimestamp(
timestampISO(match.lastReportedAt) ?? null,
browser ? $uiNow : Date.now()
)
);
const status = $derived(
completed
? 'Completed'
: match.status === 'active'
? reporting
? 'In progress'
: 'Not reporting'
: match.status === 'end-report-missing'
? 'End report missing'
: 'Interrupted'
);
const gameName = $derived(
match.game === 't5'
? 'Black Ops I'
@@ -45,22 +70,27 @@
<SectionHeader
title={match.map}
eyebrow={`Match #${match.id}`}
subtitle={`${gameName} · Recorded match results`}
subtitle={`${gameName} · ${completed ? 'Recorded match results' : 'Recorded match archive'}`}
>
{#snippet crumbs()}<Breadcrumbs
items={[
{ label: 'Match History', href: '/matches' },
{
label: archive ? 'Moderation archive' : 'Match History',
href: archive ? '/matches?matchArchive=1' : '/matches'
},
{ label: `Match #${match.id}` }
]}
/>{/snippet}
{#snippet actions()}<Badge variant="online" size="md">Completed</Badge>{/snippet}
{#snippet actions()}<Badge variant={completed || reporting ? 'online' : 'warning'} size="md"
>{status}</Badge
>{/snippet}
</SectionHeader>
<section class="summary" aria-label="Match summary">
<section class="summary" class:unfinished={!completed} aria-label="Match summary">
<Card
><Stat
label="Final round"
value={match.finalRound}
label={completed ? 'Final round' : 'Highest observed round'}
value={completed ? match.finalRound : match.observedRound}
tone="accent"
size="lg"
tween={false}
@@ -68,7 +98,7 @@
>
<Card
><Stat
label="Game time"
label={completed ? 'Game time' : 'Recorded duration'}
value={match.durationMs === null ? null : formatDuration(match.durationMs)}
size="lg"
tween={false}
@@ -82,8 +112,14 @@
tween={false}
/></Card
>
<Card><Stat label="Players at end" value={match.endPlayers} size="lg" tween={false} /></Card
>
{#if completed}<Card
><Stat
label="Players at end"
value={match.endPlayers}
size="lg"
tween={false}
/></Card
>{/if}
</section>
<Card padding="lg">
@@ -104,9 +140,12 @@
<dd><RelativeTime value={match.startedAt} /></dd>
</div>
<div>
<dt>Ended</dt>
<dt>{completed ? 'Ended' : 'Last reported'}</dt>
<dd>
{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if}
<RelativeTime
value={completed ? match.endedAt : match.lastReportedAt}
missing="Not recorded"
/>
</dd>
</div>
<div>
@@ -119,13 +158,19 @@
game time is unknown.
</p>{/if}
</Card>
{#if !completed}<p class="note">
This match has no completed results. The archive preserves observed participants,
reported stats, and retained chat for review.
</p>{/if}
<section class="results" aria-labelledby="players-title">
<div class="results-heading">
<h2 id="players-title">Player results</h2>
<h2 id="players-title">{completed ? 'Player results' : 'Recorded participants'}</h2>
<p>
Every recorded player, including those absent from the final report. Stats are the
highest reported counters for this match.
{completed
? 'Every recorded player, including those absent from the final report.'
: 'Every participant recorded before reporting stopped or in the latest available reports.'}
Stats are the highest reported counters for this match.
{#if hasScoreTotals}
Total score is the highest recorded cumulative points earned in this match.
{:else}
@@ -163,10 +208,15 @@
<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
><Badge
variant={completed && player.presentAtEnd
? 'online'
: 'neutral'}
>{!completed
? 'Recorded participant'
: 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'}
@@ -191,8 +241,8 @@
{#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.
{completed ? 'absent from the final report' : 'absent from later reports'} retain their
latest recorded total. A dash in Total score means no total was recorded for that player.
</p>
{:else}
<p>
@@ -203,9 +253,10 @@
{/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
the final report; other players keep their earlier stats. A dash means no value was
recorded.
progression while the player was present. {#if completed}“At match end” means the player
appeared in the final report; other players keep their earlier stats.{:else}Participation
confirms a recorded report and does not establish who was present at the end.{/if} A dash
means no value was recorded.
</p>
<p>
Player names use the latest recorded identity. Times reflect when reports were received
@@ -240,6 +291,9 @@
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4);
}
.summary.unfinished {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.details {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -341,7 +395,8 @@
color: var(--text-secondary);
}
@media (max-width: 800px) {
.summary {
.summary,
.summary.unfinished {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.details {
+1
View File
@@ -249,6 +249,7 @@
error={refreshError ? 'Updates paused' : ''}
/>
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
<Button href="/matches?matchArchive=1">Chat archive</Button>
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
>Your case updates ({report.unreadCount})</Button
>{/if}
@@ -317,7 +317,7 @@
{#if archive}<div class="source-context">
{#if archive.matchId}<a
href={contextHref(
`/matches/${archive.matchId}`,
`/matches/${archive.matchId}?matchArchive=1`,
$page.url
)}>View match #{archive.matchId}</a
>{/if}
+27 -1
View File
@@ -1,5 +1,31 @@
import { serverSnapshot } from '$lib/server/fleet/snapshot.js';
import { error } from '@sveltejs/kit';
import { matchApiParams, readMatchQuery } from '$lib/match-history-query.js';
import {
requireMatchArchiveAccess,
type MatchArchiveLocals
} from '$lib/server/fleet/match-history-access.js';
export function load({ params }: { params: { name: string } }) {
export function load({
params,
url,
locals,
setHeaders
}: {
params: { name: string };
url?: URL;
locals?: MatchArchiveLocals;
setHeaders?: (headers: Record<string, string>) => void;
}) {
let query;
try {
query = readMatchQuery(url ? matchApiParams(url) : new URLSearchParams());
} catch {
error(400, 'Invalid match history filters.');
}
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { serverSnapshot: serverSnapshot(params.name) };
}