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
+16
View File
@@ -206,6 +206,22 @@ Snapshots remain readable after source-match cleanup. Unknown or estimated
timestamps remain such; the snapshot does not turn them into exact observations. timestamps remain such; the snapshot does not turn them into exact observations.
Unarchived or already deleted chat cannot be attached as verified evidence. Unarchived or already deleted chat cannot be attached as verified evidence.
Open **Chat archive** from Moderation to include all recorded matches, including
round-one games, interrupted games, and games missing an end report. Archive
access requires moderation read permission. Filter by server, game, map, status,
or date and open a match to select its preserved messages. Incomplete matches
show their observed state rather than final results. Ordinary match history and
leaderboard qualification are unchanged.
Both public (`say`) and team (`sayteam`) log messages are captured. Team messages
carry a **Team chat** label in chat and saved excerpts. Collection runs without a live
viewer and checks surviving logs once after startup, including idle servers.
Archive coverage still depends on the source log and accepted match telemetry:
logs that were deleted or rotated before collection, or old clock segments that
cannot safely be associated with a match, cannot be reconstructed. The collector
retains a bounded log tail (up to 2,000 lines / 256 KiB); it is not a full historical
log import. Already saved case excerpts survive later source cleanup.
Case decisions, edits, and restrictions check the current revision; refresh before Case decisions, edits, and restrictions check the current revision; refresh before
retrying a decision if another staff member has changed the case. Evidence, retrying a decision if another staff member has changed the case. Evidence,
restrictions and append-only moderation events survive source-match cleanup and restrictions and append-only moderation events survive source-match cleanup and
+9 -6
View File
@@ -4,6 +4,8 @@ export interface ChatMessage {
slot: number; slot: number;
player: string; player: string;
message: string; message: string;
/** Omitted for public chat to preserve existing archived message identities. */
channel?: 'team';
} }
export type ChatEntry = export type ChatEntry =
@@ -98,18 +100,19 @@ function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineInde
previousSeconds = seconds; 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; if (!match) continue;
const message = plainText(match[5]); const message = plainText(match[6]);
if (!message) continue; if (!message) continue;
push({ push({
type: 'message', type: 'message',
message: { message: {
time: match[1], time: match[1],
guid: match[2], guid: match[3],
slot: Number(match[3]), slot: Number(match[4]),
player: plainText(match[4]) || 'Unknown player', player: plainText(match[5]) || 'Unknown player',
message message,
...(match[2] === 'sayteam' ? { channel: 'team' as const } : {})
} }
}); });
} }
+8 -3
View File
@@ -10,6 +10,7 @@ export interface MatchHistoryQuery {
sort: MatchSort; sort: MatchSort;
direction: 'asc' | 'desc'; direction: 'asc' | 'desc';
page: number; page: number;
archive: boolean;
} }
export const matchQueryKeys = [ export const matchQueryKeys = [
'game', 'game',
@@ -19,7 +20,8 @@ export const matchQueryKeys = [
'status', 'status',
'sort', 'sort',
'direction', 'direction',
'page' 'page',
'archive'
] as const; ] as const;
export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery { export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const game = params.get('game') ?? ''; const game = params.get('game') ?? '';
@@ -30,6 +32,7 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
const sort = params.get('sort') || 'started'; const sort = params.get('sort') || 'started';
const direction = params.get('direction') || 'desc'; const direction = params.get('direction') || 'desc';
const page = Number(params.get('page') || 1); const page = Number(params.get('page') || 1);
const archive = params.get('archive') ?? '';
if ( if (
!['', 't5', 't6'].includes(game) || !['', 't5', 't6'].includes(game) ||
map.length > 1024 || map.length > 1024 ||
@@ -40,7 +43,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
!['asc', 'desc'].includes(direction) || !['asc', 'desc'].includes(direction) ||
!Number.isSafeInteger(page) || !Number.isSafeInteger(page) ||
page < 1 || page < 1 ||
page > 1000000 page > 1000000 ||
!['', '0', '1'].includes(archive)
) )
throw new Error('Invalid match history filters.'); throw new Error('Invalid match history filters.');
return { return {
@@ -51,7 +55,8 @@ export function readMatchQuery(params: URLSearchParams): MatchHistoryQuery {
status: status as MatchHistoryQuery['status'], status: status as MatchHistoryQuery['status'],
sort: sort as MatchSort, sort: sort as MatchSort,
direction: direction as 'asc' | 'desc', direction: direction as 'asc' | 'desc',
page page,
archive: archive === '1'
}; };
} }
/** Namespaced filters also work inside an instance's Matches tab. */ /** 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 { export interface MatchDetail extends ServerMatch {
status: 'completed';
game: string; game: string;
startObserved: boolean; startObserved: boolean;
players: MatchPlayer[]; players: MatchPlayer[];
+50 -6
View File
@@ -212,10 +212,14 @@ interface ChatReadState {
entries: ContextualChatEntry[]; entries: ContextualChatEntry[];
contextVersion?: string; contextVersion?: string;
wasOccupied: boolean; wasOccupied: boolean;
needsDrain: boolean;
bytesRead: number;
} }
const readers = new Map<string, ChatReadState>(); const readers = new Map<string, ChatReadState>();
const failures = new Map<string, number>(); const failures = new Map<string, number>();
const CHAT_READ_MIN_AGE_MS = 15_000; 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 }); type LastChatRead = { completedAt: number } & ({ missing: boolean } | { error: unknown });
const lastReads = new Map<string, LastChatRead>(); const lastReads = new Map<string, LastChatRead>();
@@ -249,6 +253,7 @@ export function collectServerChat(
const key = chatReadKey(server); const key = chatReadKey(server);
const existing = pending.get(key); const existing = pending.get(key);
if (existing) return existing; if (existing) return existing;
let progressed = false;
const work = (async () => { const work = (async () => {
const path = resolveLogPath(server.name, gameLogKey(server.name)); const path = resolveLogPath(server.name, gameLogKey(server.name));
if (!path) throw new Error('Invalid server log path'); if (!path) throw new Error('Invalid server log path');
@@ -263,7 +268,9 @@ export function collectServerChat(
partial: Buffer.alloc(0), partial: Buffer.alloc(0),
skipLine: false, skipLine: false,
entries: [], entries: [],
wasOccupied: false wasOccupied: false,
needsDrain: false,
bytesRead: 0
}; };
const tail = await readLogChunk(path, state.cursor); const tail = await readLogChunk(path, state.cursor);
const current = getServer(server.name); const current = getServer(server.name);
@@ -290,7 +297,10 @@ export function collectServerChat(
state.entries = []; state.entries = [];
} }
state.cursor = tail.cursor; 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; const end = bytes.lastIndexOf(10) + 1;
if (end) { if (end) {
const lines = bytes const lines = bytes
@@ -318,14 +328,17 @@ export function collectServerChat(
state.skipLine = true; state.skipLine = true;
} }
// File mtime anchors the final line only after the backlog has been read. // 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; state.signature = undefined;
readers.set(server.name, state); readers.set(server.name, state);
return { entries: currentChatEntries(current, state.entries), missing: false }; return { entries: currentChatEntries(current, state.entries), missing: false };
} }
} else { } else {
// Older agents remain readable during a rolling update. // Older agents remain readable during a rolling update.
state.needsDrain = false;
changed = state.signature !== `${tail.size}:${tail.mtime}`; changed = state.signature !== `${tail.size}:${tail.mtime}`;
progressed = changed;
if (changed) state.lines = tail.lines; if (changed) state.lines = tail.lines;
} }
const signature = `${tail.size}:${tail.mtime}`; const signature = `${tail.size}:${tail.mtime}`;
@@ -354,6 +367,9 @@ export function collectServerChat(
})() })()
.then( .then(
(result) => { (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 }); lastReads.set(key, { completedAt: Date.now(), missing: result.missing });
return result; 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 readers.keys()) if (!names.has(name)) readers.delete(name);
for (const name of failures.keys()) if (!names.has(name)) failures.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 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) => { const servers = live.filter((server) => {
if (server.host_id && !hosts.get(server.host_id)?.connected) return false; if (server.host_id && !hosts.get(server.host_id)?.connected) return false;
if (server.container_state && !['running', 'unknown'].includes(server.container_state)) if (server.container_state && !['running', 'unknown'].includes(server.container_state))
return false; return false;
const players = observe(server).last_players ?? server.last_players; const players = observe(server).last_players ?? server.last_players;
// One final read after players leave retains messages written at match end. const key = chatReadKey(server);
return players !== 0 || readers.get(server.name)?.wasOccupied; 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. // Bound agent traffic and isolate failures to their own server.
async function collect() { async function collect() {
for (let server = servers.shift(); server; server = servers.shift()) { for (let server = servers.shift(); server; server = servers.shift()) {
const key = chatReadKey(server);
const before = readers.get(server.name);
try { try {
await collectServerChat(server); await collectServerChat(server);
const state = readers.get(server.name); const state = readers.get(server.name);
if (state) if (state?.key === key) {
state.wasOccupied = state.wasOccupied =
(observe(server).last_players ?? server.last_players ?? 0) > 0; (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); failures.delete(server.name);
} catch (error) { } 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) { if (Date.now() - (failures.get(server.name) ?? -Infinity) >= 5 * 60_000) {
console.error(`[chat] Could not collect chat for ${server.name}`, error); console.error(`[chat] Could not collect chat for ${server.name}`, error);
failures.set(server.name, Date.now()); 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() now = Date.now()
): MatchHistoryResponse { ): MatchHistoryResponse {
const handle = db(); 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> = []; const parameters: Array<string | number> = [];
if (server !== undefined) { if (server !== undefined) {
clauses.push('m.server = ?'); clauses.push('m.server = ?');
@@ -93,14 +93,18 @@ export function serverMatchHistory(
} }
/** Read durable match records without depending on a current server or scoreboard. */ /** 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; if (!Number.isSafeInteger(id) || id < 1) return null;
const handle = db(); const handle = db();
const match = handle const match = handle
.prepare( .prepare(
`SELECT ${matchSummary}, m.start_observed AS startObserved `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}` AND m.highest_round >= ${MIN_RECORDED_ROUND}`
}`
) )
.get(id) as .get(id) as
| (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number }) | (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 (SELECT identity.name FROM leaderboard_players identity
WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name, WHERE identity.guid = p.guid AND identity.game = ?), p.guid) AS name,
p.has_stats AS hasStats, 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, 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.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.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 = ? FROM leaderboard_match_players p WHERE p.match_id = ?
ORDER BY hasStats DESC, kills DESC, highestRound DESC, name COLLATE NOCASE, p.guid` 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'> & { Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
hasStats: number; hasStats: number;
presentAtEnd: number; presentAtEnd: number;
+1
View File
@@ -81,6 +81,7 @@ export function archivedEvidenceSnapshots(
entry.message.guid === old.guid && entry.message.guid === old.guid &&
entry.message.time === old.time && entry.message.time === old.time &&
entry.message.player === old.player && entry.message.player === old.player &&
entry.message.channel === old.channel &&
entry.message.message === old.message entry.message.message === old.message
); );
if (original) attached.add(original.messageId!); if (original) attached.add(original.messageId!);
+5 -1
View File
@@ -9,6 +9,7 @@
import { pollVisible } from '$ui/poll.js'; import { pollVisible } from '$ui/poll.js';
import { timeFormat } from '$ui/stores/time-zone.js'; import { timeFormat } from '$ui/stores/time-zone.js';
import Button from './primitives/Button.svelte'; import Button from './primitives/Button.svelte';
import Badge from './primitives/Badge.svelte';
import Freshness from './Freshness.svelte'; import Freshness from './Freshness.svelte';
import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte'; import MatchEvidenceDialog from './moderation/MatchEvidenceDialog.svelte';
@@ -337,7 +338,10 @@
<span class="sr-only">Select evidence</span> <span class="sr-only">Select evidence</span>
</label> </label>
{/if} {/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)} >{@render chatContext(item.entry.context)}
{#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a {#if canReadModeration($page.data?.user?.role) && (moderationGame === 't5' || moderationGame === 't6')}<a
class="moderation-link" class="moderation-link"
+53 -12
View File
@@ -5,6 +5,7 @@
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { canReadModeration } from '$lib/auth.js';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { contextHref } from '$lib/navigation-context.js'; import { contextHref } from '$lib/navigation-context.js';
import { timestampISO } from '$lib/time-zone.js'; import { timestampISO } from '$lib/time-zone.js';
@@ -42,6 +43,7 @@
let navigationId = 0; let navigationId = 0;
const now = $derived(browser ? $uiNow : Date.now()); const now = $derived(browser ? $uiNow : Date.now());
const filters = $derived(readMatchQuery(matchApiParams($page.url))); const filters = $derived(readMatchQuery(matchApiParams($page.url)));
const canViewArchive = $derived(canReadModeration($page.data?.user?.role));
const apiUrl = $derived.by(() => { const apiUrl = $derived.by(() => {
const params = matchApiParams($page.url); const params = matchApiParams($page.url);
params.set('limit', String(matchPageSize)); params.set('limit', String(matchPageSize));
@@ -131,6 +133,19 @@
</script> </script>
<section class="history" aria-label="Match history"> <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)} {#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
{#if server === undefined}<Field forId={`match-game${suffix}`} label="Game"> {#if server === undefined}<Field forId={`match-game${suffix}`} label="Game">
@@ -357,9 +372,12 @@
<tr> <tr>
<td><RelativeTime value={match.startedAt} /></td> <td><RelativeTime value={match.startedAt} /></td>
<td class="match"> <td class="match">
{#if match.status === 'completed'}<a {#if match.status === 'completed' || (canViewArchive && filters.archive)}<a
class="match-link" 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)}`} aria-label={`View ${match.map} match ${match.id} from ${$timeFormat.dateTime(match.startedAt)}`}
>{match.map} · Match #{match.id}</a >{match.map} · Match #{match.id}</a
> >
@@ -379,7 +397,8 @@
? ` · ${duration}` ? ` · ${duration}`
: ''}</small : ''}</small
> >
{#if notReporting}<small class="mobile-match-context" {#if notReporting || (filters.archive && match.status !== 'completed')}<small
class="mobile-match-context"
>Last report <RelativeTime >Last report <RelativeTime
value={match.lastReportedAt} value={match.lastReportedAt}
missing="Not recorded" missing="Not recorded"
@@ -403,7 +422,7 @@
class:interrupted={match.status === 'interrupted' || class:interrupted={match.status === 'interrupted' ||
match.status === 'end-report-missing' || match.status === 'end-report-missing' ||
notReporting}>{status}</span notReporting}>{status}</span
>{#if notReporting}<small >{#if notReporting || (filters.archive && match.status !== 'completed')}<small
>Last report <RelativeTime >Last report <RelativeTime
value={match.lastReportedAt} value={match.lastReportedAt}
missing="Not recorded" missing="Not recorded"
@@ -469,14 +488,16 @@
<summary>About match history</summary> <summary>About match history</summary>
<p> <p>
Players counts unique players across all reports. A dash means the value is unavailable, 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 not zero. In-progress matches show elapsed time since their recorded start. {#if filters.archive}Archive
the live server while reports are current. Otherwise, they show Not reporting with the links open retained match reports and chat.{:else}They link to the live server while
last report time. Completed durations require a recorded start and completed ending. End reports are current.{/if} Otherwise, they show Not reporting with the last report time.
report missing means a newer game was reported before this game's end report arrived. Completed durations require a recorded start and completed ending. End report missing means
Missing report counts show gaps in the reporter's sequence and can decrease as delayed a newer game was reported before this game's end report arrived. Missing report counts show
reports arrive. Only completed games contribute to the leaderboard. Round shows the gaps in the reporter's sequence and can decrease as delayed reports arrive. Only completed
final round for completed matches and the highest observed round for unfinished matches. games contribute to the leaderboard. Round shows the final round for completed matches and
Open a completed match for exact timestamps and starting and ending player counts. the highest observed round for unfinished matches. Open a {filters.archive
? 'recorded'
: 'completed'} match for timestamps and participant details.
</p> </p>
</details> </details>
</section> </section>
@@ -487,6 +508,26 @@
gap: var(--space-4); gap: var(--space-4);
min-width: 0; 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 { .filters {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -27,6 +27,7 @@
<li class:highlighted> <li class:highlighted>
<div class="author"> <div class="author">
<strong>{entry.message.player}</strong> <strong>{entry.message.player}</strong>
{#if entry.message.channel === 'team'}<Badge>Team chat</Badge>{/if}
{#if highlighted}<Badge variant="warning">Subject message</Badge> {#if highlighted}<Badge variant="warning">Subject message</Badge>
{:else if selected}<Badge>Selected context</Badge> {:else if selected}<Badge>Selected context</Badge>
{:else}<span>Surrounding context</span>{/if} {:else}<span>Surrounding context</span>{/if}
+15 -2
View File
@@ -1,8 +1,13 @@
import { readMatchQuery } from '$lib/match-history-query.js'; import { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; 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); const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 }); return json({ error: 'limit must be between 1 and 10000' }, { status: 400 });
@@ -13,5 +18,13 @@ export function GET({ url }: { url: URL }) {
} catch { } catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 }); 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 { readMatchQuery } from '$lib/match-history-query.js';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; 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); const limit = Number(url.searchParams.get('limit') ?? 25);
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) { if (!Number.isSafeInteger(limit) || limit < 1 || limit > 10000) {
return json({ error: 'limit must be between 1 and 10000' }, { status: 400 }); 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 { } catch {
return json({ error: 'Invalid match history filters.' }, { status: 400 }); 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 { error } from '@sveltejs/kit';
import { serverMatchHistory } from '$lib/server/fleet/match-history.js'; import { serverMatchHistory } from '$lib/server/fleet/match-history.js';
import { matchApiParams, matchPageSize, readMatchQuery } from '$lib/match-history-query.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; let query;
try { try {
query = readMatchQuery(matchApiParams(url)); query = readMatchQuery(matchApiParams(url));
} catch { } catch {
error(400, 'Invalid match history filters.'); error(400, 'Invalid match history filters.');
} }
if (query.archive) {
setHeaders?.({ 'Cache-Control': 'no-store' });
requireMatchArchiveAccess(locals?.user);
}
return { history: serverMatchHistory(undefined, matchPageSize, query) }; return { history: serverMatchHistory(undefined, matchPageSize, query) };
} }
+12 -2
View File
@@ -2,12 +2,22 @@
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
import MatchHistory from '$ui/components/MatchHistory.svelte'; import MatchHistory from '$ui/components/MatchHistory.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
import { page } from '$app/stores';
const archive = $derived($page.url.searchParams.get('matchArchive') === '1');
</script> </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"> <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} /> <MatchHistory initial={data.history} />
</div> </div>
+27 -2
View File
@@ -1,9 +1,34 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { getMatchDetail } from '$lib/server/fleet/match-history.js'; 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'); 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'); if (!match) error(404, 'Match not found');
return { match }; return { match };
} }
+77 -22
View File
@@ -1,6 +1,10 @@
<script lang="ts"> <script lang="ts">
import RelativeTime from '$ui/components/RelativeTime.svelte'; import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores'; 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 { contextHref } from '$lib/navigation-context.js';
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte'; import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
import type { MatchDetail } from '$lib/match-history.js'; import type { MatchDetail } from '$lib/match-history.js';
@@ -16,6 +20,27 @@
let { data }: { data: { match: MatchDetail } } = $props(); let { data }: { data: { match: MatchDetail } } = $props();
const match = $derived(data.match); 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( const gameName = $derived(
match.game === 't5' match.game === 't5'
? 'Black Ops I' ? 'Black Ops I'
@@ -45,22 +70,27 @@
<SectionHeader <SectionHeader
title={match.map} title={match.map}
eyebrow={`Match #${match.id}`} eyebrow={`Match #${match.id}`}
subtitle={`${gameName} · Recorded match results`} subtitle={`${gameName} · ${completed ? 'Recorded match results' : 'Recorded match archive'}`}
> >
{#snippet crumbs()}<Breadcrumbs {#snippet crumbs()}<Breadcrumbs
items={[ items={[
{ label: 'Match History', href: '/matches' }, {
label: archive ? 'Moderation archive' : 'Match History',
href: archive ? '/matches?matchArchive=1' : '/matches'
},
{ label: `Match #${match.id}` } { label: `Match #${match.id}` }
]} ]}
/>{/snippet} />{/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> </SectionHeader>
<section class="summary" aria-label="Match summary"> <section class="summary" class:unfinished={!completed} aria-label="Match summary">
<Card <Card
><Stat ><Stat
label="Final round" label={completed ? 'Final round' : 'Highest observed round'}
value={match.finalRound} value={completed ? match.finalRound : match.observedRound}
tone="accent" tone="accent"
size="lg" size="lg"
tween={false} tween={false}
@@ -68,7 +98,7 @@
> >
<Card <Card
><Stat ><Stat
label="Game time" label={completed ? 'Game time' : 'Recorded duration'}
value={match.durationMs === null ? null : formatDuration(match.durationMs)} value={match.durationMs === null ? null : formatDuration(match.durationMs)}
size="lg" size="lg"
tween={false} tween={false}
@@ -82,8 +112,14 @@
tween={false} tween={false}
/></Card /></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> </section>
<Card padding="lg"> <Card padding="lg">
@@ -104,9 +140,12 @@
<dd><RelativeTime value={match.startedAt} /></dd> <dd><RelativeTime value={match.startedAt} /></dd>
</div> </div>
<div> <div>
<dt>Ended</dt> <dt>{completed ? 'Ended' : 'Last reported'}</dt>
<dd> <dd>
{#if match.endedAt}<RelativeTime value={match.endedAt} />{:else}—{/if} <RelativeTime
value={completed ? match.endedAt : match.lastReportedAt}
missing="Not recorded"
/>
</dd> </dd>
</div> </div>
<div> <div>
@@ -119,13 +158,19 @@
game time is unknown. game time is unknown.
</p>{/if} </p>{/if}
</Card> </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"> <section class="results" aria-labelledby="players-title">
<div class="results-heading"> <div class="results-heading">
<h2 id="players-title">Player results</h2> <h2 id="players-title">{completed ? 'Player results' : 'Recorded participants'}</h2>
<p> <p>
Every recorded player, including those absent from the final report. Stats are the {completed
highest reported counters for this match. ? '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} {#if hasScoreTotals}
Total score is the highest recorded cumulative points earned in this match. Total score is the highest recorded cumulative points earned in this match.
{:else} {:else}
@@ -163,8 +208,13 @@
<small class="guid">{player.guid}</small> <small class="guid">{player.guid}</small>
</th> </th>
<td class="participation" <td class="participation"
><Badge variant={player.presentAtEnd ? 'online' : 'neutral'} ><Badge
>{player.presentAtEnd variant={completed && player.presentAtEnd
? 'online'
: 'neutral'}
>{!completed
? 'Recorded participant'
: player.presentAtEnd
? 'At match end' ? 'At match end'
: 'Not at match end'}</Badge : 'Not at match end'}</Badge
>{#if !player.hasStats}<small>No stats recorded</small>{/if}</td >{#if !player.hasStats}<small>No stats recorded</small>{/if}</td
@@ -191,8 +241,8 @@
{#if hasScoreTotals} {#if hasScoreTotals}
<p> <p>
Total score is the highest recorded cumulative points earned in this match. Players 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 {completed ? 'absent from the final report' : 'absent from later reports'} retain their
score means no total was recorded for that player. latest recorded total. A dash in Total score means no total was recorded for that player.
</p> </p>
{:else} {:else}
<p> <p>
@@ -203,9 +253,10 @@
{/if} {/if}
<p> <p>
Highest round includes late joins. Rounds survived counts only observed round 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 progression while the player was present. {#if completed}“At match end” means the player
the final report; other players keep their earlier stats. A dash means no value was appeared in the final report; other players keep their earlier stats.{:else}Participation
recorded. confirms a recorded report and does not establish who was present at the end.{/if} A dash
means no value was recorded.
</p> </p>
<p> <p>
Player names use the latest recorded identity. Times reflect when reports were received 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)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: var(--space-4); gap: var(--space-4);
} }
.summary.unfinished {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.details { .details {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -341,7 +395,8 @@
color: var(--text-secondary); color: var(--text-secondary);
} }
@media (max-width: 800px) { @media (max-width: 800px) {
.summary { .summary,
.summary.unfinished {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.details { .details {
+1
View File
@@ -249,6 +249,7 @@
error={refreshError ? 'Updates paused' : ''} error={refreshError ? 'Updates paused' : ''}
/> />
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button> <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" {#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
>Your case updates ({report.unreadCount})</Button >Your case updates ({report.unreadCount})</Button
>{/if} >{/if}
@@ -317,7 +317,7 @@
{#if archive}<div class="source-context"> {#if archive}<div class="source-context">
{#if archive.matchId}<a {#if archive.matchId}<a
href={contextHref( href={contextHref(
`/matches/${archive.matchId}`, `/matches/${archive.matchId}?matchArchive=1`,
$page.url $page.url
)}>View match #{archive.matchId}</a )}>View match #{archive.matchId}</a
>{/if} >{/if}
+27 -1
View File
@@ -1,5 +1,31 @@
import { serverSnapshot } from '$lib/server/fleet/snapshot.js'; 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) }; return { serverSnapshot: serverSnapshot(params.name) };
} }
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import { writable } from 'svelte/store';
import MatchHistory from '../../src/lib/ui/components/MatchHistory.svelte';
import type { MatchHistoryResponse, ServerMatch } from '../../src/lib/match-history.js';
import type { UserRole } from '../../src/lib/auth.js';
const currentPage = writable({
url: new URL('http://localhost/matches'),
data: { user: { role: 'moderator' as UserRole } }
});
const navigation = vi.hoisted(() => ({ goto: vi.fn().mockResolvedValue(undefined) }));
vi.mock('$app/stores', () => ({
page: {
subscribe: (run: Parameters<typeof currentPage.subscribe>[0]) => currentPage.subscribe(run)
}
}));
vi.mock('$app/navigation', () => ({ goto: navigation.goto }));
vi.mock('../../src/lib/ui/poll.js', () => ({ pollVisible: () => () => {} }));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
navigation.goto.mockClear();
});
const statuses: ServerMatch['status'][] = [
'completed',
'interrupted',
'end-report-missing',
'active'
];
const report: MatchHistoryResponse = {
total: 4,
page: 1,
limit: 25,
maps: [],
matches: statuses.map((status, index) => ({
id: index + 1,
game: 't5',
server: 't5_zombie_theater_1',
map: 'Kino Der Toten',
observedRound: 1,
finalRound: status === 'completed' ? 1 : null,
status,
reporterMatchKey: null,
missingReports: null,
startedAt: '2026-10-17T10:00:00Z',
lastReportedAt: '2026-10-17T10:01:00Z',
endedAt: status === 'completed' ? '2026-10-17T10:01:00Z' : null,
durationMs: null,
startPlayers: 1,
uniquePlayers: 1,
endPlayers: status === 'completed' ? 1 : null
}))
};
function show(search = '', role: UserRole = 'moderator') {
currentPage.set({
url: new URL(`http://localhost/matches${search}`),
data: { user: { role } }
});
component = mount(MatchHistory, { target: document.body, props: { initial: report } });
flushSync();
}
it('enables the moderation archive through a namespaced URL while preserving filters', async () => {
show('?matchGame=t5&matchPage=2');
document.querySelector<HTMLInputElement>('input[type="checkbox"]')!.click();
await vi.waitFor(() => expect(navigation.goto).toHaveBeenCalledOnce());
expect(navigation.goto.mock.calls[0][0]).toBe('/matches?matchGame=t5&matchArchive=1');
});
it('links round-one and unfinished archive rows to retained match detail with return context', () => {
show('?matchArchive=1&matchGame=t5');
expect(document.querySelector<HTMLInputElement>('input[type="checkbox"]')?.checked).toBe(true);
const links = [...document.querySelectorAll<HTMLAnchorElement>('tbody a.match-link')];
expect(links).toHaveLength(4);
for (const [index, link] of links.entries()) {
const url = new URL(link.href);
expect(url.pathname).toBe(`/matches/${index + 1}`);
expect(url.searchParams.get('matchArchive')).toBe('1');
expect(url.searchParams.get('returnTo')).toBe('/matches?matchArchive=1&matchGame=t5');
}
expect(document.body.textContent).toContain('Highest observed');
expect(document.body.textContent).toContain('Last report');
});
it('keeps ordinary history navigation and hides archive controls from viewers', () => {
show('', 'viewer');
expect(document.querySelector('input[type="checkbox"]')).toBeNull();
const links = [...document.querySelectorAll<HTMLAnchorElement>('tbody a.match-link')];
expect(links).toHaveLength(1);
expect(new URL(links[0].href).searchParams.has('matchArchive')).toBe(false);
});
+26
View File
@@ -123,6 +123,32 @@ function expectExplanation(totals: boolean) {
} }
describe('match result score presentation', () => { describe('match result score presentation', () => {
it.each(['interrupted', 'end-report-missing', 'active'] as const)(
'describes %s archives as observed reports without final result claims',
(status) => {
const archived = match([player({ highestRound: 1, presentAtEnd: false })]);
archived.status = status;
archived.observedRound = 1;
archived.finalRound = null;
archived.endedAt = null;
archived.endPlayers = null;
archived.durationMs = null;
show(archived);
expect(document.body.textContent).toContain(
status === 'interrupted'
? 'Interrupted'
: status === 'end-report-missing'
? 'End report missing'
: 'Not reporting'
);
expect(document.body.textContent).toContain('Highest observed round');
expect(document.body.textContent).toContain('Last reported');
expect(document.querySelector('tbody')?.textContent).toContain('Recorded participant');
expect(document.body.textContent).not.toContain('At match end');
expect(document.body.textContent).not.toContain('Players at end');
expect(document.body.textContent).not.toContain('Final round');
}
);
it('shows recorded totals instead of balances, including totals for departed players', () => { it('shows recorded totals instead of balances, including totals for departed players', () => {
show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })])); show(match([player(), player({ guid: '202', presentAtEnd: false, scoreTotal: 6500 })]));
expectScores('Total score', ['8,470', '6,500']); expectScores('Total score', ['8,470', '6,500']);
+32 -1
View File
@@ -51,11 +51,42 @@ describe('game chat', () => {
'1:00 say;123;bad;Player;hello', '1:00 say;123;bad;Player;hello',
'1:00 say;123;0;Player;\x15^7', '1:00 say;123;0;Player;\x15^7',
'1:00 InitGame: say;123;0;Player;hello', '1:00 InitGame: say;123;0;Player;hello',
'1:00 sayteam;123;0;Player;hello' '1:00 sayteam;123;bad;Player;hello',
'1:00 sayteam;123;0;Player;\x15^7'
]) ])
).toEqual([]); ).toEqual([]);
}); });
it('keeps team and public chat in order with distinct channels and stable public identities', () => {
const messages = parseGameChat([
'1:00 say;123;0;Player;hello; café',
'1:00 sayteam;123;0;^2Player;\x15hello; café',
'1:01 sayteam;456;1;Teammate;reply'
]);
expect(messages).toEqual([
{ time: '1:00', guid: '123', slot: 0, player: 'Player', message: 'hello; café' },
{
time: '1:00',
guid: '123',
slot: 0,
player: 'Player',
message: 'hello; café',
channel: 'team'
},
{
time: '1:01',
guid: '456',
slot: 1,
player: 'Teammate',
message: 'reply',
channel: 'team'
}
]);
expect(JSON.stringify(messages[0])).toBe(
'{"time":"1:00","guid":"123","slot":0,"player":"Player","message":"hello; café"}'
);
});
it('separates matches even when elapsed time keeps increasing', () => { it('separates matches even when elapsed time keeps increasing', () => {
const entries = parseGameChatTimeline([ const entries = parseGameChatTimeline([
'1200:00 InitGame: \\mapname\\zombie_theater', '1200:00 InitGame: \\mapname\\zombie_theater',
+239
View File
@@ -0,0 +1,239 @@
import { randomUUID } from 'node:crypto';
import type Database from 'better-sqlite3';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../src/lib/auth.js';
import {
matchApiParams,
matchHistoryHref,
readMatchQuery
} from '../../src/lib/match-history-query.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
let handle: Database.Database;
const server = 't5_zombie_theater_1';
const clock = '2026-10-17T12:00:00.000Z';
const end = '2026-10-17T12:01:00.000Z';
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
handle = db();
handle
.prepare("INSERT INTO servers(name,game,map) VALUES(?,'t5','zombie_theater')")
.run(server);
for (const role of ['admin', 'moderator', 'super_viewer', 'operator', 'viewer'])
handle
.prepare(
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES(?,?,?,'unused',?,0,0)`
)
.run(role, role, role, role);
handle
.prepare(
"INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES('t5','123','Player',?,?)"
)
.run(clock, end);
const insert =
handle.prepare(`INSERT INTO leaderboard_matches(id,server,game,map,map_key,map_name,started_at,ended_at,last_received_at,status,start_observed,highest_round,last_event,last_fingerprint)
VALUES(?,?,'t5','zombie_theater','t5:zombie_theater','Kino Der Toten',?,?,?,?,1,?,'game_end','test')`);
for (const [id, status, round] of [
[1, 'completed', 1],
[2, 'interrupted', 1],
[3, 'active', 0],
[4, 'end-report-missing', 2],
[5, 'completed', 8],
[6, 'completed', 1]
] as const) {
insert.run(id, id === 6 ? 'other-server' : server, clock, end, end, status, round);
handle
.prepare(
`INSERT INTO leaderboard_match_players(match_id,guid,highest_round,has_stats,completed,first_seen_at,last_seen_at)
VALUES(?,'123',?,1,?,?,?)`
)
.run(id, round, status === 'completed' ? 1 : 0, clock, end);
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
id,
`message-${id}`,
1,
JSON.stringify({
type: 'message',
message: {
time: '1:00',
guid: '123',
slot: 1,
player: 'Player',
message: `Message from match ${id}`
},
context: { realTime: null, matchElapsedMs: null, round: null }
})
);
}
});
afterEach(async () => fixture.cleanup());
async function user(role: string): Promise<AuthUser> {
const { getUser } = await import('../../src/lib/server/auth/index.js');
return getUser(role)!;
}
async function api(actor: AuthUser | null, scoped = false, archive = '1') {
const url = new URL(`http://localhost/api/matches?archive=${archive}`);
if (scoped) {
const { GET } = await import('../../src/routes/api/servers/[name]/matches/+server.js');
return GET({ url, params: { name: server }, locals: { user: actor } });
}
const { GET } = await import('../../src/routes/api/matches/+server.js');
return GET({ url, locals: { user: actor } });
}
async function page(
actor: AuthUser | null,
surface: 'list' | 'detail' | 'instance',
id = '1',
archive = '1'
) {
const url = new URL(`http://localhost/matches/${id}?matchArchive=${archive}`);
const setHeaders = vi.fn();
let result;
if (surface === 'list') {
const { load } = await import('../../src/routes/matches/+page.server.js');
result = load({ url, locals: { user: actor }, setHeaders });
} else if (surface === 'detail') {
const { load } = await import('../../src/routes/matches/[id]/+page.server.js');
result = load({ url, params: { id }, locals: { user: actor }, setHeaders });
} else {
const { load } = await import('../../src/routes/servers/[name]/+page.server.js');
result = load({ url, params: { name: server }, locals: { user: actor }, setHeaders });
}
return { result, setHeaders };
}
it('makes archive selection explicit, strictly parses it and preserves it through namespaced navigation', () => {
expect(readMatchQuery(new URLSearchParams()).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=0')).archive).toBe(false);
expect(readMatchQuery(new URLSearchParams('archive=1')).archive).toBe(true);
for (const value of ['true', 'yes', '2'])
expect(() => readMatchQuery(new URLSearchParams(`archive=${value}`))).toThrow(
'Invalid match history filters'
);
const url = new URL('http://localhost/matches?matchPage=3&matchStatus=interrupted');
const href = matchHistoryHref(url, { archive: '1' });
expect(href).toContain('matchArchive=1');
expect(href).not.toContain('matchPage');
expect(matchApiParams(new URL(href, url)).get('archive')).toBe('1');
});
it('exposes saved round-one, interrupted, active and missing-end chat only in expanded queries without changing gameplay qualification', async () => {
const { getMatchDetail, serverMatchHistory } =
await import('../../src/lib/server/fleet/match-history.js');
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const publicBefore = getLeaderboard();
expect(publicBefore.entries[0].gamesPlayed).toBe(1);
expect(serverMatchHistory().total).toBe(2);
const archive = readMatchQuery(new URLSearchParams('archive=1'));
expect(serverMatchHistory(undefined, 25, archive).total).toBe(6);
expect(serverMatchHistory(server, 25, archive).total).toBe(5);
expect(
serverMatchHistory(
server,
25,
readMatchQuery(new URLSearchParams('archive=1&status=interrupted'))
).matches.map((item) => item.id)
).toEqual([2]);
for (const id of [1, 2, 3, 4, 6]) {
expect(getMatchDetail(id)).toBeNull();
expect(getMatchDetail(id, true)?.chat).toMatchObject([
{ message: { message: `Message from match ${id}` }, messageId: `${id}:message-${id}:1` }
]);
}
for (const id of [2, 3, 4])
expect(getMatchDetail(id, true)).toMatchObject({
finalRound: null,
durationMs: null,
endedAt: null,
endPlayers: null,
players: [{ presentAtEnd: false }]
});
expect(getMatchDetail(5)).toMatchObject({ status: 'completed', finalRound: 8 });
expect(getLeaderboard()).toEqual(publicBefore);
handle.prepare('DELETE FROM servers WHERE name=?').run(server);
expect(getMatchDetail(2, true)?.chat).toHaveLength(1);
});
it.each(['admin', 'moderator', 'super_viewer'])(
'allows %s archive access with private responses on every API and page surface',
async (role) => {
const actor = await user(role);
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(200);
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect((await response.json()).total).toBe(scoped ? 5 : 6);
}
for (const surface of ['list', 'detail', 'instance'] as const) {
const loaded = await page(actor, surface);
expect(loaded.setHeaders).toHaveBeenCalledWith({ 'Cache-Control': 'no-store' });
if (surface === 'detail')
expect(loaded.result).toMatchObject({
match: { id: 1, status: 'completed', finalRound: 1 }
});
}
}
);
it.each(['operator', 'viewer', null])(
'rejects %s expanded access before revealing match existence',
async (role) => {
const actor = role ? await user(role) : null;
for (const scoped of [false, true]) {
const response = await api(actor, scoped);
expect(response.status).toBe(403);
expect(response.headers.get('Cache-Control')).toBe('no-store');
}
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
await expect(page(actor, 'detail', '999')).rejects.toMatchObject({ status: 403 });
expect((await api(actor, false, '0')).status).toBe(200);
await expect(page(actor, 'detail', '1', '0')).rejects.toMatchObject({ status: 404 });
expect((await page(actor, 'detail', '5', '0')).result).toMatchObject({ match: { id: 5 } });
}
);
it.each(["role='viewer'", 'disabled=1', 'must_change_password=1'])(
'revalidates stale moderator locals after %s',
async (change) => {
const actor = await user('moderator');
handle.prepare(`UPDATE auth_users SET ${change} WHERE id='moderator'`).run();
expect((await api(actor)).status).toBe(403);
expect((await api(actor, true)).status).toBe(403);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface)).rejects.toMatchObject({ status: 403 });
}
);
it('returns validation and not-found errors without silently falling back to the standard history', async () => {
const actor = await user('moderator');
expect((await api(actor, false, 'true')).status).toBe(400);
for (const surface of ['list', 'detail', 'instance'] as const)
await expect(page(actor, surface, '1', 'true')).rejects.toMatchObject({ status: 400 });
for (const id of ['0', '01', '-1', '1.5', '999'])
await expect(page(actor, 'detail', id)).rejects.toMatchObject({ status: 404 });
});
it('attaches interrupted-match archive evidence and keeps the snapshot after source cleanup', async () => {
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
const entry = getMatchDetail(2, true)!.chat[0];
const { id } = mutateModeration('moderator', {
op: 'create-case',
game: 't5',
guid: '123',
title: 'Interrupted match report',
evidence: { matchId: 2, messageIds: [entry.messageId] },
idempotencyKey: randomUUID()
});
handle.prepare('DELETE FROM leaderboard_matches WHERE id=2').run();
const evidence = moderationCaseDetail('moderator', id).case.evidence[0];
expect(JSON.parse(evidence.snapshot!).entry.message.message).toBe('Message from match 2');
});
+28 -2
View File
@@ -50,7 +50,7 @@ async function create(overrides: Record<string, unknown> = {}) {
}); });
} }
async function archive(messages: Array<{ guid?: string; message: string }>) { async function archive(messages: Array<{ guid?: string; message: string; channel?: 'team' }>) {
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js'); const { matchChatTimeline } = await import('../../src/lib/server/fleet/chat.js');
saveTelemetry({ saveTelemetry({
@@ -77,7 +77,8 @@ async function archive(messages: Array<{ guid?: string; message: string }>) {
guid: message.guid ?? '123', guid: message.guid ?? '123',
slot: message.guid ? 2 : 1, slot: message.guid ? 2 : 1,
player: message.guid ? 'Witness' : 'Subject', player: message.guid ? 'Witness' : 'Subject',
message: message.message message: message.message,
...(message.channel ? { channel: message.channel } : {})
} }
}) })
); );
@@ -155,6 +156,31 @@ it('allows a new witness reply alongside an attached subject and deduplicates la
expect(await detail(id)).toEqual(attached); expect(await detail(id)).toEqual(attached);
}); });
it('preserves team chat separately when matching evidence saved before message identifiers', async () => {
const { matchId, entries } = await archive([
{ message: 'Same words', channel: 'team' },
{ message: 'Same words' }
]);
const { id } = await create({
evidence: { matchId, messageIds: [entries[1].messageId] }
});
// A legacy public-chat snapshot has no selected IDs or channel field.
handle
.prepare('UPDATE moderation_evidence SET snapshot=? WHERE case_id=?')
.run(JSON.stringify({ matchId, entry: entries[1] }), id);
await mutate({
op: 'attach-evidence',
id,
revision: 1,
evidence: { matchId, messageIds: [entries[0].messageId] }
});
const attached = await detail(id);
expect(attached.case.evidence).toHaveLength(2);
const snapshot = JSON.parse(attached.case.evidence[1].snapshot!);
expect(snapshot.entry.message.channel).toBe('team');
expect(snapshot.selectedMessageIds).toEqual([entries[0].messageId]);
});
it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => { it('validates selection and context bounds and rolls back opening a case with invalid evidence', async () => {
const { matchId, entries } = await archive([{ message: 'Subject statement' }]); const { matchId, entries } = await archive([{ message: 'Subject statement' }]);
const messageId = entries[0].messageId!; const messageId = entries[0].messageId!;
+107 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js'; import { newFixture, type Fixture } from '../helpers/fixture.js';
import type { ContextualChatEntry } from '../../src/lib/game-chat.js'; import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
import type { TailResult } from '../../src/lib/server/fleet/logs.js'; import type { LogChunk, TailResult } from '../../src/lib/server/fleet/logs.js';
const SERVER = 't5_zombie_theater_1'; const SERVER = 't5_zombie_theater_1';
const OTHER_SERVER = 't6_zombie_town_1'; const OTHER_SERVER = 't6_zombie_town_1';
@@ -19,6 +19,7 @@ beforeEach(async () => {
db() db()
.prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'") .prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'")
.run(new Date(baseline - 120_000).toISOString()); .run(new Date(baseline - 120_000).toISOString());
db().prepare("UPDATE servers SET container_state='stopped' WHERE name=?").run(OTHER_SERVER);
}); });
afterEach(async () => { afterEach(async () => {
@@ -43,6 +44,25 @@ function messages(body: { entries: ContextualChatEntry[] }): string[] {
); );
} }
function chunk(content: string, offset: number, size: number, reset = false): LogChunk {
const mtime = new Date(baseline + 60_000).toISOString();
return {
contentBase64: Buffer.from(content).toString('base64'),
size,
mtime,
truncated: false,
reset,
cursor: {
fileId: 'chat-log',
offset,
size,
modified: mtime,
anchorLength: 0,
anchorHash: ''
}
};
}
function advance(milliseconds: number) { function advance(milliseconds: number) {
vi.setSystemTime(Date.now() + milliseconds); vi.setSystemTime(Date.now() + milliseconds);
} }
@@ -82,6 +102,92 @@ async function addInstance() {
} }
describe('server chat refresh sharing', () => { describe('server chat refresh sharing', () => {
it('picks up surviving logs once when collection starts on an idle server', async () => {
const s = await setup();
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['first']);
advance(60_000);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
});
it('drains all remaining chunks after players leave before anchoring messages', async () => {
const s = await setup();
const lines = [
'0:30 say;abc;0;Player;first\n',
'0:40 say;abc;0;Player;middle\n',
'1:00 say;abc;0;Player;last\n'
];
const sizes = lines.map((line) => Buffer.byteLength(line));
const total = sizes.reduce((a, b) => a + b, 0);
s.read
.mockResolvedValueOnce(chunk(lines[0], sizes[0], total, true))
.mockResolvedValueOnce(chunk(lines[1], sizes[0] + sizes[1], total))
.mockResolvedValueOnce(chunk(lines[2], total, total));
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual([]);
s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual([]);
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['first', 'middle', 'last']);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(3);
expect(s.read.mock.calls.map((call) => call[1]?.offset)).toEqual([
undefined,
sizes[0],
sizes[0] + sizes[1]
]);
});
it('bounds stalled partial-line retries and resumes collection when players return', async () => {
const s = await setup();
const partial = '1:00 say;abc;0;Player;incomplete';
const size = Buffer.byteLength(partial);
s.read
.mockResolvedValueOnce(chunk(partial, size, size, true))
.mockResolvedValue(chunk('', size, size));
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
for (let attempt = 0; attempt < 5; attempt++) {
advance(15_000);
await s.collectFleetChat();
}
expect(s.read).toHaveBeenCalledTimes(4);
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
s.read.mockResolvedValue(chunk('\n', size + 1, size + 1));
await s.collectFleetChat();
expect(messages(await (await s.get()).json())).toEqual(['incomplete']);
expect(s.read).toHaveBeenCalledTimes(5);
});
it('stops idle reads for missing logs and bounds retries for read failures', async () => {
const s = await setup();
s.read.mockResolvedValueOnce(null);
await s.collectFleetChat();
advance(60_000);
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(1);
// A new registration gets its own initial attempt and retry budget.
s.db
.prepare('UPDATE servers SET created_at=? WHERE name=?')
.run(new Date(baseline - 60_000).toISOString(), SERVER);
s.read.mockRejectedValue(new Error('Unavailable source'));
vi.spyOn(console, 'error').mockImplementation(() => {});
await s.collectFleetChat();
await s.collectFleetChat();
expect(s.read).toHaveBeenCalledTimes(2);
for (let attempt = 0; attempt < 5; attempt++) {
advance(15_000);
await s.collectFleetChat();
}
expect(s.read).toHaveBeenCalledTimes(4);
});
it('serves worker state to repeated viewers and still collects after players leave', async () => { it('serves worker state to repeated viewers and still collects after players leave', async () => {
const s = await setup(); const s = await setup();
s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
+33 -8
View File
@@ -139,7 +139,7 @@ describe('server chat API', () => {
).toEqual(['first', 'retry me']); ).toEqual(['first', 'retry me']);
}); });
it('skips empty, stopped and offline servers and collects one last time after players leave', async () => { it('checks idle servers once, skips stopped and offline servers and collects after players leave', async () => {
const { db } = await import('../../src/lib/server/db/index.js'); const { db } = await import('../../src/lib/server/db/index.js');
const { createHost } = await import('../../src/lib/server/hosts/index.js'); const { createHost } = await import('../../src/lib/server/hosts/index.js');
const logs = await import('../../src/lib/server/fleet/logs.js'); const logs = await import('../../src/lib/server/fleet/logs.js');
@@ -147,25 +147,27 @@ describe('server chat API', () => {
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js'); const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
db().prepare("UPDATE servers SET last_players=0,container_state='running'").run(); db().prepare("UPDATE servers SET last_players=0,container_state='running'").run();
await collectFleetChat(); await collectFleetChat();
expect(read).not.toHaveBeenCalled(); expect(read).toHaveBeenCalledTimes(2);
await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2);
db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
await log(['60:50 say;abc;0;Player;playing'], 50); await log(['60:50 say;abc;0;Player;playing'], 50);
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(1); expect(read).toHaveBeenCalledTimes(3);
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER); db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']); await log(['60:50 say;abc;0;Player;playing', '61:00 say;abc;0;Player;goodbye']);
expect((await (await get()).json()).entries).toHaveLength(1); expect((await (await get()).json()).entries).toHaveLength(1);
expect(read).toHaveBeenCalledTimes(1); expect(read).toHaveBeenCalledTimes(3);
await collectFleetChat(); await collectFleetChat();
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
expect((await (await get()).json()).entries).toHaveLength(2); expect((await (await get()).json()).entries).toHaveLength(2);
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
db() db()
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?") .prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
.run(SERVER); .run(SERVER);
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
const host = createHost({ name: 'Offline' }); const host = createHost({ name: 'Offline' });
db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER); db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER);
db() db()
@@ -175,7 +177,30 @@ describe('server chat API', () => {
) )
.run(SERVER, host.id, at(0)); .run(SERVER, host.id, at(0));
await collectFleetChat(); await collectFleetChat();
expect(read).toHaveBeenCalledTimes(2); expect(read).toHaveBeenCalledTimes(4);
});
it('archives surviving idle match logs with distinct stable public and team message identities', async () => {
await report('game_start', 0);
await report('heartbeat', 20, 4);
await report('game_end', 60, 4);
await log(['60:30 say;abc;0;Player;hello', '60:30 sayteam;abc;0;Player;hello'], 30);
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=0').run();
const { collectFleetChat, matchChatTimeline } =
await import('../../src/lib/server/fleet/chat.js');
await collectFleetChat();
const saved = matchChatTimeline(1);
expect(saved).toHaveLength(2);
expect(saved[0]).toMatchObject({ message: { message: 'hello' } });
expect(saved[0].type === 'message' && saved[0].message.channel).toBeUndefined();
expect(saved[1]).toMatchObject({ message: { message: 'hello', channel: 'team' } });
expect(new Set(saved.map((entry) => entry.messageId)).size).toBe(2);
expect(saved.every((entry) => typeof entry.messageId === 'string')).toBe(true);
advanceTime();
await get();
await collectFleetChat();
expect(matchChatTimeline(1)).toEqual(saved);
}); });
it('keeps real timestamps and historical match context stable across refreshes', async () => { it('keeps real timestamps and historical match context stable across refreshes', async () => {
+13
View File
@@ -3,6 +3,7 @@ import { render } from 'svelte/server';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import MatchPage from '../../src/routes/matches/[id]/+page.svelte'; import MatchPage from '../../src/routes/matches/[id]/+page.svelte';
import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte'; import ChatPanel from '../../src/lib/ui/components/ChatPanel.svelte';
import EvidenceMessages from '../../src/lib/ui/components/moderation/EvidenceMessages.svelte';
import type { MatchDetail } from '../../src/lib/match-history.js'; import type { MatchDetail } from '../../src/lib/match-history.js';
vi.mock('$app/stores', () => ({ vi.mock('$app/stores', () => ({
@@ -45,6 +46,18 @@ const match: MatchDetail = {
}; };
describe('historical match chat', () => { describe('historical match chat', () => {
it('identifies team chat in both archived messages and preserved evidence excerpts', () => {
const entry = match.chat[0];
if (entry.type !== 'message') throw new Error('Expected message fixture');
const history = [{ ...entry, message: { ...entry.message, channel: 'team' as const } }];
expect(render(ChatPanel, { props: { history } }).body).toContain('Team chat');
expect(render(EvidenceMessages, { props: { entries: history } }).body).toContain(
'Team chat'
);
expect(render(ChatPanel, { props: { history: match.chat } }).body).not.toContain(
'Team chat'
);
});
it('renders saved messages with their context and no live controls on the match page', () => { it('renders saved messages with their context and no live controls on the match page', () => {
const body = render(MatchPage, { props: { data: { match } } }).body; const body = render(MatchPage, { props: { data: { match } } }).body;
expect(body).toContain('Match history'); expect(body).toContain('Match history');