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