Capture team chat and expose moderation match archives
This commit is contained in:
@@ -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 } : {})
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -47,7 +47,6 @@ export interface MatchPlayer {
|
||||
}
|
||||
|
||||
export interface MatchDetail extends ServerMatch {
|
||||
status: 'completed';
|
||||
game: string;
|
||||
startObserved: boolean;
|
||||
players: MatchPlayer[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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!);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user