Capture team chat and expose moderation match archives
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 } : {})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. */
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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']);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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!;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user