Add real time, match duration, and round context to chat
This commit is contained in:
@@ -39,6 +39,13 @@ lobby stays occupied. An empty report clears the match timer and begins the
|
|||||||
inactive period; a later join begins a new active period. These are observed
|
inactive period; a later join begins a new active period. These are observed
|
||||||
times, not exact player connection timestamps.
|
times, not exact player connection timestamps.
|
||||||
|
|
||||||
|
Server chat shows real time in your selected timezone, elapsed match time using
|
||||||
|
the same observed start as Fleet, and the round last reported when each message
|
||||||
|
was sent. Real time is approximate: the latest log write anchors the engine clock.
|
||||||
|
Earlier clock segments, missing match starts, and unavailable or stale round
|
||||||
|
reports show `—` instead of borrowing context from another match. Round history
|
||||||
|
uses retained accepted telemetry (up to 200 requests per server or seven days).
|
||||||
|
|
||||||
## Fleet overview
|
## Fleet overview
|
||||||
|
|
||||||
Fleet searches server names, map names, and the full player roster. Filter occupied,
|
Fleet searches server names, map names, and the full player roster. Filter occupied,
|
||||||
|
|||||||
+130
-8
@@ -10,6 +10,39 @@ export type ChatEntry =
|
|||||||
| { type: 'message'; message: ChatMessage }
|
| { type: 'message'; message: ChatMessage }
|
||||||
| { type: 'boundary'; event: 'start' | 'end' | 'reset'; time: string | null };
|
| { type: 'boundary'; event: 'start' | 'end' | 'reset'; time: string | null };
|
||||||
|
|
||||||
|
export interface ChatContext {
|
||||||
|
/** Estimated from the final log write, never from the time chat is fetched. */
|
||||||
|
realTime: string | null;
|
||||||
|
matchElapsedMs: number | null;
|
||||||
|
round: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ContextualChatEntry = ChatEntry & { context: ChatContext };
|
||||||
|
|
||||||
|
export interface ChatMatch {
|
||||||
|
startedAt: string;
|
||||||
|
endedAt: string | null;
|
||||||
|
lastReceivedAt: string;
|
||||||
|
status: 'active' | 'completed' | 'interrupted';
|
||||||
|
startObserved: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatRoundReport {
|
||||||
|
receivedAt: string;
|
||||||
|
event: 'game_start' | 'round_change' | 'heartbeat' | 'game_end';
|
||||||
|
round: number | null;
|
||||||
|
playerCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logTimestamp(line: string) {
|
||||||
|
return /^\s*(\d+:[0-5]\d)\s+/.exec(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSeconds(time: string): number {
|
||||||
|
const [minutes, seconds] = time.split(':').map(Number);
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
function plainText(value: string): string {
|
function plainText(value: string): string {
|
||||||
return value.replace(/\^[0-9]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
return value.replace(/\^[0-9]/g, '').replace(/[\x00-\x1f\x7f]/g, '').trim();
|
||||||
}
|
}
|
||||||
@@ -21,24 +54,29 @@ export function parseGameChat(lines: readonly string[]): ChatMessage[] {
|
|||||||
|
|
||||||
/** Keep match boundaries alongside messages, including when the log clock resets. */
|
/** Keep match boundaries alongside messages, including when the log clock resets. */
|
||||||
export function parseGameChatTimeline(lines: readonly string[]): ChatEntry[] {
|
export function parseGameChatTimeline(lines: readonly string[]): ChatEntry[] {
|
||||||
const entries: ChatEntry[] = [];
|
return indexedTimeline(lines).map(({ entry }) => entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexedTimeline(lines: readonly string[]): { entry: ChatEntry; lineIndex: number }[] {
|
||||||
|
const entries: { entry: ChatEntry; lineIndex: number }[] = [];
|
||||||
let previousSeconds: number | null = null;
|
let previousSeconds: number | null = null;
|
||||||
let ended = false;
|
let ended = false;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const [lineIndex, line] of lines.entries()) {
|
||||||
const timestamp = /^\s*(\d+:[0-5]\d)\s+/.exec(line);
|
const push = (entry: ChatEntry) => entries.push({ entry, lineIndex });
|
||||||
|
const timestamp = logTimestamp(line);
|
||||||
const time = timestamp?.[1] ?? null;
|
const time = timestamp?.[1] ?? null;
|
||||||
const seconds = time === null ? null : Number(time.split(':')[0]) * 60 + Number(time.split(':')[1]);
|
const seconds = time === null ? null : logSeconds(time);
|
||||||
const event = line.slice(timestamp?.[0].length ?? 0).trimStart();
|
const event = line.slice(timestamp?.[0].length ?? 0).trimStart();
|
||||||
if (/^InitGame(?:\s|:|$)/.test(event)) {
|
if (/^InitGame(?:\s|:|$)/.test(event)) {
|
||||||
entries.push({ type: 'boundary', event: 'start', time });
|
push({ type: 'boundary', event: 'start', time });
|
||||||
ended = false;
|
ended = false;
|
||||||
} else if (/^(?:ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event)) {
|
} else if (/^(?:ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event)) {
|
||||||
// Engines may emit both end events for the same match.
|
// Engines may emit both end events for the same match.
|
||||||
if (!ended) entries.push({ type: 'boundary', event: 'end', time });
|
if (!ended) push({ type: 'boundary', event: 'end', time });
|
||||||
ended = true;
|
ended = true;
|
||||||
} else if (seconds !== null && previousSeconds !== null && seconds < previousSeconds) {
|
} else if (seconds !== null && previousSeconds !== null && seconds < previousSeconds) {
|
||||||
entries.push({ type: 'boundary', event: 'reset', time });
|
push({ type: 'boundary', event: 'reset', time });
|
||||||
ended = false;
|
ended = false;
|
||||||
}
|
}
|
||||||
// Untimed lifecycle events also clear the previous match's clock.
|
// Untimed lifecycle events also clear the previous match's clock.
|
||||||
@@ -50,8 +88,92 @@ export function parseGameChatTimeline(lines: readonly string[]): ChatEntry[] {
|
|||||||
if (!match) continue;
|
if (!match) continue;
|
||||||
const message = plainText(match[5]);
|
const message = plainText(match[5]);
|
||||||
if (!message) continue;
|
if (!message) continue;
|
||||||
entries.push({ type: 'message', message: { time: match[1], guid: match[2], slot: Number(match[3]),
|
push({ type: 'message', message: { time: match[1], guid: match[2], slot: Number(match[3]),
|
||||||
player: plainText(match[4]) || 'Unknown player', message } });
|
player: plainText(match[4]) || 'Unknown player', message } });
|
||||||
}
|
}
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The engine clock is not the match clock. Anchor only its final uninterrupted
|
||||||
|
* segment to the file's last write, then join observed gameplay at that time.
|
||||||
|
* Resets have unknown downtime, so earlier segments cannot share this anchor.
|
||||||
|
*/
|
||||||
|
export function enrichGameChatTimeline(lines: readonly string[], options: {
|
||||||
|
mtime: string | null;
|
||||||
|
matches: readonly ChatMatch[];
|
||||||
|
reports: readonly ChatRoundReport[];
|
||||||
|
}): ContextualChatEntry[] {
|
||||||
|
let segmentStart = 0;
|
||||||
|
let previousSeconds: number | null = null;
|
||||||
|
let lastLine = -1;
|
||||||
|
for (const [index, line] of lines.entries()) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
lastLine = index;
|
||||||
|
const timestamp = logTimestamp(line);
|
||||||
|
const event = line.slice(timestamp?.[0].length ?? 0).trimStart();
|
||||||
|
const lifecycle = /^(?:InitGame|ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event);
|
||||||
|
if (!timestamp) {
|
||||||
|
if (lifecycle) { segmentStart = index + 1; previousSeconds = null; }
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const seconds = logSeconds(timestamp[1]);
|
||||||
|
if (previousSeconds !== null && (seconds < previousSeconds
|
||||||
|
|| (seconds === 0 && /^InitGame(?:\s|:|$)/.test(event)))) segmentStart = index;
|
||||||
|
previousSeconds = seconds;
|
||||||
|
}
|
||||||
|
// An untimed final write cannot locate the preceding timed event in real time.
|
||||||
|
const finalTimestamp = lastLine < 0 ? null : logTimestamp(lines[lastLine]);
|
||||||
|
const writtenAt = options.mtime ? Date.parse(options.mtime) : NaN;
|
||||||
|
const offset = finalTimestamp ? writtenAt - logSeconds(finalTimestamp[1]) * 1000 : NaN;
|
||||||
|
const reports = options.reports.map(report => ({ ...report, at: Date.parse(report.receivedAt) }))
|
||||||
|
.sort((a, b) => b.at - a.at);
|
||||||
|
const matches = options.matches.map(match => ({ ...match, start: Date.parse(match.startedAt) }))
|
||||||
|
.sort((a, b) => b.start - a.start)
|
||||||
|
.map((match, index, sorted) => {
|
||||||
|
const nextStart = sorted[index - 1]?.start ?? Infinity;
|
||||||
|
// Identical heartbeats are deduplicated by the leaderboard, but still
|
||||||
|
// establish that this match was running when the message was sent.
|
||||||
|
const latest = reports.find(report => report.at >= match.start && report.at < nextStart);
|
||||||
|
const lastSeen = Math.max(Date.parse(match.lastReceivedAt), latest?.at ?? -Infinity);
|
||||||
|
const end = match.endedAt ? Date.parse(match.endedAt)
|
||||||
|
: match.status === 'active' ? lastSeen + 120_000 : Date.parse(match.lastReceivedAt);
|
||||||
|
return { ...match, end: Math.min(end, nextStart) };
|
||||||
|
});
|
||||||
|
let logStartedAt: number | null = null;
|
||||||
|
let logEnded = false;
|
||||||
|
|
||||||
|
return indexedTimeline(lines).map(({ entry, lineIndex }) => {
|
||||||
|
const context: ChatContext = { realTime: null, matchElapsedMs: null, round: null };
|
||||||
|
const time = entry.type === 'message' ? entry.message.time : entry.time;
|
||||||
|
const at = time !== null && lineIndex >= segmentStart ? offset + logSeconds(time) * 1000 : NaN;
|
||||||
|
if (Number.isFinite(at) && Number.isFinite(new Date(at).getTime())) context.realTime = new Date(at).toISOString();
|
||||||
|
if (entry.type === 'boundary' && entry.event !== 'end') {
|
||||||
|
// The first event after an implicit reset can be several seconds into
|
||||||
|
// the new process. Its clock origin is the lower bound, not that event.
|
||||||
|
// Untimed starts provide no safe bound for attaching an older match.
|
||||||
|
logStartedAt = context.realTime ? (entry.event === 'reset' ? offset : at) : Infinity;
|
||||||
|
logEnded = false;
|
||||||
|
}
|
||||||
|
const match = context.realTime && !logEnded ? matches.find(match => at >= match.start && at <= match.end
|
||||||
|
// Allow the log's one-second precision at a start, but never cross a later InitGame.
|
||||||
|
&& (logStartedAt === null || match.start + 1000 >= logStartedAt
|
||||||
|
|| reports.some(report => report.event === 'game_start' && report.playerCount > 0
|
||||||
|
&& report.at >= match.start && report.at <= at && report.at + 1000 >= logStartedAt!))) : undefined;
|
||||||
|
if (match) {
|
||||||
|
const report = reports.find(report => report.at <= at && report.at >= match.start);
|
||||||
|
if (!report || (report.event !== 'game_end' && report.playerCount > 0)) {
|
||||||
|
// A repeated start updates the fleet timer even if the leaderboard
|
||||||
|
// deduplicates it. Use that same observed start for later messages.
|
||||||
|
const start = reports.find(report => report.event === 'game_start' && report.playerCount > 0
|
||||||
|
&& report.at >= match.start && report.at <= at)?.at ?? (match.startObserved ? match.start : null);
|
||||||
|
if (start !== null) context.matchElapsedMs = at - start;
|
||||||
|
if (report && at - report.at <= 120_000 && (report.event === 'round_change' || report.event === 'heartbeat')) {
|
||||||
|
context.round = report.round;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (entry.type === 'boundary' && entry.event === 'end') logEnded = true;
|
||||||
|
return { ...entry, context };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { enrichGameChatTimeline, type ChatMatch, type ChatRoundReport, type ContextualChatEntry } from '../../game-chat.js';
|
||||||
|
import { parseGameEvent } from '../../telemetry.js';
|
||||||
|
import { TELEMETRY_REQUEST_RETENTION } from '../../telemetry-diagnostics.js';
|
||||||
|
import type { ServerRow } from './servers.js';
|
||||||
|
import type { TailResult } from './logs.js';
|
||||||
|
|
||||||
|
/** Historical context is scoped to this registration, including round-one matches. */
|
||||||
|
export function serverChatTimeline(server: ServerRow, tail: TailResult): ContextualChatEntry[] {
|
||||||
|
const handle = db();
|
||||||
|
const matches = handle.prepare(`SELECT started_at AS startedAt, ended_at AS endedAt,
|
||||||
|
last_received_at AS lastReceivedAt, status, start_observed AS startObserved
|
||||||
|
FROM leaderboard_matches WHERE server = ? AND server_deleted_at IS NULL
|
||||||
|
ORDER BY id DESC LIMIT 2000`).all(server.name) as Array<Omit<ChatMatch, 'startObserved'> & { startObserved: number }>;
|
||||||
|
|
||||||
|
// Diagnostic history survives deletion. Its receipt time must belong to the
|
||||||
|
// current registration so a reused server name cannot inherit old rounds.
|
||||||
|
const cutoff = new Date(Date.now() - TELEMETRY_REQUEST_RETENTION.maxAgeDays * 86_400_000).toISOString();
|
||||||
|
const reports = handle.prepare(`SELECT received_at AS receivedAt, event, round,
|
||||||
|
player_count AS playerCount FROM telemetry_requests
|
||||||
|
WHERE server = ? AND status = 200 AND received_at >= ?
|
||||||
|
AND julianday(received_at) >= julianday(?)
|
||||||
|
AND event IN ('game_start', 'round_change', 'heartbeat', 'game_end')
|
||||||
|
AND player_count >= 0 AND (event = 'game_start' OR round >= 0)
|
||||||
|
ORDER BY id DESC LIMIT ?`).all(server.name, cutoff, server.created_at,
|
||||||
|
TELEMETRY_REQUEST_RETENTION.maxPerServer) as ChatRoundReport[];
|
||||||
|
|
||||||
|
// Saving gameplay does not depend on diagnostic logging succeeding. Keep the
|
||||||
|
// surviving accepted snapshot useful when its request log was not recorded.
|
||||||
|
const latest = handle.prepare(`SELECT received_at AS receivedAt, payload FROM server_telemetry
|
||||||
|
WHERE server = ? AND received_at >= ? AND julianday(received_at) >= julianday(?)`)
|
||||||
|
.get(server.name, cutoff, server.created_at) as { receivedAt: string; payload: string | null } | undefined;
|
||||||
|
if (latest?.payload) {
|
||||||
|
try {
|
||||||
|
const event = parseGameEvent(JSON.parse(latest.payload));
|
||||||
|
const round = event.event === 'game_start' ? null : event.round;
|
||||||
|
if (event.server === server.name && !reports.some(report => report.receivedAt === latest.receivedAt
|
||||||
|
&& report.event === event.event && report.round === round && report.playerCount === event.players.length)) {
|
||||||
|
reports.push({ receivedAt: latest.receivedAt, event: event.event, round, playerCount: event.players.length });
|
||||||
|
}
|
||||||
|
} catch { /* Legacy malformed snapshots cannot establish chat context. */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
return enrichGameChatTimeline(tail.lines, {
|
||||||
|
mtime: tail.mtime,
|
||||||
|
matches: matches.map(match => ({ ...match, startObserved: Boolean(match.startObserved) })),
|
||||||
|
reports
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, tick } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import { parseGameChatTimeline, type ChatEntry } from '$lib/game-chat.js';
|
import type { ChatContext, ContextualChatEntry } from '$lib/game-chat.js';
|
||||||
|
import { formatDuration } from '$ui/duration.js';
|
||||||
import { pollVisible } from '$ui/poll.js';
|
import { pollVisible } from '$ui/poll.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
|
|
||||||
let { server, game }: { server: string; game: string | null } = $props();
|
let { server }: { server: string } = $props();
|
||||||
let entries = $state<ChatEntry[]>([]);
|
let entries = $state<ContextualChatEntry[]>([]);
|
||||||
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
|
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
|
||||||
let loaded = $state(false);
|
let loaded = $state(false);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -21,12 +23,11 @@
|
|||||||
const request = new AbortController();
|
const request = new AbortController();
|
||||||
controller = request;
|
controller = request;
|
||||||
try {
|
try {
|
||||||
const key = game === 't6' ? 'games_zm' : 'games_sp';
|
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
|
||||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/logs/${key}?lines=2000`, { signal: request.signal });
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.');
|
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.');
|
||||||
if (request.signal.aborted) return;
|
if (request.signal.aborted) return;
|
||||||
entries = parseGameChatTimeline(data.lines ?? []);
|
entries = data.entries ?? [];
|
||||||
missing = !!data.missing;
|
missing = !!data.missing;
|
||||||
loaded = true;
|
loaded = true;
|
||||||
error = '';
|
error = '';
|
||||||
@@ -51,6 +52,17 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#snippet chatContext(context: ChatContext)}
|
||||||
|
<dl class="context" aria-label="Message context">
|
||||||
|
<div>
|
||||||
|
<dt>Real time</dt>
|
||||||
|
<dd>{#if context.realTime}<time datetime={context.realTime} title={`Estimated real time: ${$timeFormat.dateTime(context.realTime)}`}>≈ {$timeFormat.time(context.realTime)}</time>{:else}—{/if}</dd>
|
||||||
|
</div>
|
||||||
|
<div><dt>Match</dt><dd>{context.matchElapsedMs === null ? '—' : formatDuration(context.matchElapsedMs)}</dd></div>
|
||||||
|
<div><dt>Round</dt><dd>{context.round ?? '—'}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
<section aria-label="Player chat">
|
<section aria-label="Player chat">
|
||||||
<div class="chat-head">
|
<div class="chat-head">
|
||||||
<div><h2>Chat</h2><p>Recent messages from the game log · refreshes every 5 seconds</p></div>
|
<div><h2>Chat</h2><p>Recent messages from the game log · refreshes every 5 seconds</p></div>
|
||||||
@@ -71,22 +83,22 @@
|
|||||||
{#each entries as entry}
|
{#each entries as entry}
|
||||||
{#if entry.type === 'boundary'}
|
{#if entry.type === 'boundary'}
|
||||||
<li class="match-divider" class:match-end={entry.event === 'end'}>
|
<li class="match-divider" class:match-end={entry.event === 'end'}>
|
||||||
<span class="match-label">
|
<div class="match-label">
|
||||||
{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'}
|
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'}</span>
|
||||||
{#if entry.time}<span class="time" title="Elapsed game-log time">{entry.time}</span>{/if}
|
{@render chatContext(entry.context)}
|
||||||
</span>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
{:else}
|
{:else}
|
||||||
<li class="message">
|
<li class="message">
|
||||||
<span class="time" title="Elapsed game-log time">{entry.message.time}</span>
|
<div class="message-head"><strong>{entry.message.player}</strong>{@render chatContext(entry.context)}</div>
|
||||||
<div><strong>{entry.message.player}</strong><p>{entry.message.message}</p></div>
|
<p>{entry.message.message}</p>
|
||||||
</li>
|
</li>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="note">Times are elapsed game-log times. Older messages may fall outside the recent log window.</p>
|
<p class="note">Real times are estimated from log writes. — means context is unavailable. Older messages may fall outside the recent log window.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -95,12 +107,17 @@
|
|||||||
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); }
|
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; }
|
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; }
|
||||||
ol { list-style: none; padding: 0; margin: 0; }
|
ol { list-style: none; padding: 0; margin: 0; }
|
||||||
.message { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
.message { padding: 12px 0; border-bottom: 1px solid var(--border-subtle); }
|
||||||
|
.message-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px 20px; }
|
||||||
|
.context { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; margin: 0; font-size: var(--text-sm); font-weight: 400; }
|
||||||
|
.context > div { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }
|
||||||
|
.context dt { color: var(--text-tertiary); }
|
||||||
|
.context dd { margin: 0; color: var(--text-secondary); font-family: var(--font-mono); font-variant-numeric: tabular-nums; }
|
||||||
.match-divider { display: flex; align-items: center; gap: 12px; padding: 20px 0; color: var(--text-primary); font-size: var(--text-sm); }
|
.match-divider { display: flex; align-items: center; gap: 12px; padding: 20px 0; color: var(--text-primary); font-size: var(--text-sm); }
|
||||||
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; background: var(--border-subtle); }
|
.match-divider::before, .match-divider::after { content: ''; height: 1px; flex: 1; background: var(--border-subtle); }
|
||||||
.match-label { display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px 12px; padding: 6px 12px; border: 1px solid var(--border-subtle); border-radius: 999px; font-weight: 600; }
|
.match-label { display: flex; align-items: center; flex-direction: column; gap: 6px; max-width: 100%; padding: 8px 14px; border: 1px solid var(--border-subtle); border-radius: 12px; font-weight: 600; text-align: center; }
|
||||||
|
.match-label .context { justify-content: center; }
|
||||||
.match-end { color: var(--text-secondary); }
|
.match-end { color: var(--text-secondary); }
|
||||||
.time { font-family: var(--font-mono); color: var(--text-tertiary); font-size: var(--text-sm); }
|
|
||||||
strong { color: var(--text-primary); overflow-wrap: anywhere; }
|
strong { color: var(--text-primary); overflow-wrap: anywhere; }
|
||||||
li p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
li p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
.empty { padding: 24px 0; }
|
.empty { padding: 24px 0; }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { getServer } from '$lib/server/fleet/servers.js';
|
||||||
|
import { gameLogKey, resolveLogPath, tailLog } from '$lib/server/fleet/logs.js';
|
||||||
|
import { serverChatTimeline } from '$lib/server/fleet/chat.js';
|
||||||
|
|
||||||
|
export async function GET({ params }: { params: { name: string } }) {
|
||||||
|
const headers = { 'Cache-Control': 'no-store' };
|
||||||
|
const server = getServer(params.name);
|
||||||
|
if (!server || server.destroyed_at) {
|
||||||
|
return json({ ok: false, reason: 'Server not found' }, { status: 404, headers });
|
||||||
|
}
|
||||||
|
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
||||||
|
if (!path) return json({ ok: false, reason: 'Path traversal rejected' }, { status: 400, headers });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tail = await tailLog(path, 2000);
|
||||||
|
return json({ ok: true, entries: tail ? serverChatTimeline(server, tail) : [], missing: tail === null }, { headers });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Could not read server chat', error);
|
||||||
|
return json({ ok: false, reason: 'Could not read server chat' }, { status: 500, headers });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -327,7 +327,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
{:else if activeTab === 'chat'}
|
{:else if activeTab === 'chat'}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
{#key name}<ChatPanel server={name} game={server.game} />{/key}
|
{#key name}<ChatPanel server={name} />{/key}
|
||||||
</Card>
|
</Card>
|
||||||
{:else if activeTab === 'logs'}
|
{:else if activeTab === 'logs'}
|
||||||
<Card padding="lg">
|
<Card padding="lg">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { parseGameChat, parseGameChatTimeline } from '../../src/lib/game-chat.js';
|
import { enrichGameChatTimeline, parseGameChat, parseGameChatTimeline, type ChatMatch, type ChatRoundReport } from '../../src/lib/game-chat.js';
|
||||||
|
|
||||||
describe('game chat', () => {
|
describe('game chat', () => {
|
||||||
it('extracts say messages and ignores joins', () => {
|
it('extracts say messages and ignores joins', () => {
|
||||||
@@ -78,3 +78,121 @@ describe('game chat', () => {
|
|||||||
expect(entries.every(entry => entry.type === 'message')).toBe(true);
|
expect(entries.every(entry => entry.type === 'message')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('chat time and round context', () => {
|
||||||
|
const at = (seconds: number) => new Date(Date.parse('2026-10-02T12:00:00Z') + seconds * 1000).toISOString();
|
||||||
|
const match = (start: number, end: number, overrides: Partial<ChatMatch> = {}): ChatMatch => ({
|
||||||
|
startedAt: at(start), endedAt: at(end), lastReceivedAt: at(end), status: 'completed', startObserved: true, ...overrides
|
||||||
|
});
|
||||||
|
const report = (seconds: number, round: number, overrides: Partial<ChatRoundReport> = {}): ChatRoundReport => ({
|
||||||
|
receivedAt: at(seconds), event: 'round_change', round, playerCount: 2, ...overrides
|
||||||
|
});
|
||||||
|
const say = (time: string, message = 'hello') => `${time} say;abc;0;Player;${message}`;
|
||||||
|
const unavailable = { realTime: null, matchElapsedMs: null, round: null };
|
||||||
|
|
||||||
|
it('anchors to the final log event and uses gameplay start, excluding engine idle time', () => {
|
||||||
|
const lines = ['60:00 InitGame:', say('62:07'), say('62:15'), '62:30 J;def;1;Other'];
|
||||||
|
const entries = enrichGameChatTimeline(lines, { mtime: at(150), matches: [match(120, 300)],
|
||||||
|
reports: [report(125, 3), report(130, 4)] });
|
||||||
|
expect(entries[1].context).toEqual({ realTime: at(127), matchElapsedMs: 7000, round: 3 });
|
||||||
|
expect(entries[2].context).toEqual({ realTime: at(135), matchElapsedMs: 15000, round: 4 });
|
||||||
|
expect(entries[0].context).toEqual({ realTime: at(0), matchElapsedMs: null, round: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('associates messages with their own match when the engine clock continues across matches', () => {
|
||||||
|
const entries = enrichGameChatTimeline(['60:00 InitGame:', say('60:30', 'old'), '61:00 ExitLevel:',
|
||||||
|
'62:00 InitGame:', say('62:30', 'new')], { mtime: at(150), matches: [match(10, 60), match(130, 200)],
|
||||||
|
reports: [report(20, 8), report(140, 2)] });
|
||||||
|
expect(entries[1].context).toEqual({ realTime: at(30), matchElapsedMs: 20000, round: 8 });
|
||||||
|
expect(entries[4].context).toEqual({ realTime: at(150), matchElapsedMs: 20000, round: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not carry wall time backward across a clock reset on a non-chat line', () => {
|
||||||
|
const entries = enrichGameChatTimeline([say('80:00', 'old'), '0:00 J;abc;0;Player', say('0:30', 'new')],
|
||||||
|
{ mtime: at(300), matches: [match(0, 500)], reports: [report(280, 10)] });
|
||||||
|
expect(entries[0].context).toEqual(unavailable);
|
||||||
|
// The preceding match cannot leak past the inferred new match at 12:04:30.
|
||||||
|
expect(entries[2].context).toEqual({ realTime: at(300), matchElapsedMs: null, round: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats repeated zero-time InitGame and untimed lifecycle events as unanchored earlier sessions', () => {
|
||||||
|
for (const boundary of ['0:00 InitGame:', 'InitGame:', 'ShutdownGame:']) {
|
||||||
|
const entries = enrichGameChatTimeline([say('0:00', 'old'), boundary, say('0:30', 'new')],
|
||||||
|
{ mtime: at(300), matches: [], reports: [] });
|
||||||
|
expect(entries[0].context).toEqual(unavailable);
|
||||||
|
expect(entries.at(-1)?.context.realTime).toBe(at(300));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can enrich a tail without InitGame but never invents a start for a partially observed match', () => {
|
||||||
|
const options = { mtime: at(150), matches: [match(100, 300)], reports: [report(140, 8)] };
|
||||||
|
expect(enrichGameChatTimeline([say('800:00')], options)[0].context)
|
||||||
|
.toEqual({ realTime: at(150), matchElapsedMs: 50000, round: 8 });
|
||||||
|
options.matches[0].startObserved = false;
|
||||||
|
expect(enrichGameChatTimeline([say('800:00')], options)[0].context)
|
||||||
|
.toEqual({ realTime: at(150), matchElapsedMs: null, round: 8 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat old or future reports, game starts, or the highest round as the message round', () => {
|
||||||
|
for (const reports of [[report(29, 5)], [report(151, 10)],
|
||||||
|
[report(140, 0, { event: 'game_start', round: null })], []]) {
|
||||||
|
expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [match(0, 300)], reports })[0].context)
|
||||||
|
.toMatchObject({ realTime: at(150), round: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not continue match context after an empty report, game end, or an unreported new log session', () => {
|
||||||
|
for (const change of [report(140, 8, { playerCount: 0 }), report(140, 8, { event: 'game_end' })]) {
|
||||||
|
expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [match(0, 300)],
|
||||||
|
reports: [report(130, 7), change] })[0].context)
|
||||||
|
.toEqual({ realTime: at(150), matchElapsedMs: null, round: null });
|
||||||
|
}
|
||||||
|
for (const boundary of ['62:00 InitGame:', '62:00 ExitLevel:', 'InitGame:']) {
|
||||||
|
expect(enrichGameChatTimeline([boundary, say('62:30')], { mtime: at(150), matches: [match(0, 300)],
|
||||||
|
reports: [report(130, 7)] }).at(-1)?.context)
|
||||||
|
.toEqual({ realTime: at(150), matchElapsedMs: null, round: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bounds active matches by recent evidence and interrupted matches by their last observation', () => {
|
||||||
|
for (const recorded of [match(0, 300, { status: 'active', endedAt: null, lastReceivedAt: at(29) }),
|
||||||
|
match(0, 300, { status: 'interrupted', endedAt: null, lastReceivedAt: at(140) })]) {
|
||||||
|
expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150), matches: [recorded], reports: [report(29, 7)] })[0].context)
|
||||||
|
.toEqual({ realTime: at(150), matchElapsedMs: null, round: null });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps active context from fresh heartbeats even when leaderboard deduplication stopped its clock', () => {
|
||||||
|
expect(enrichGameChatTimeline([say('62:30')], { mtime: at(150),
|
||||||
|
matches: [match(0, 300, { status: 'active', endedAt: null, lastReceivedAt: at(10) })],
|
||||||
|
reports: [report(140, 7, { event: 'heartbeat' })] })[0].context)
|
||||||
|
.toEqual({ realTime: at(150), matchElapsedMs: 150000, round: 7 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a start before the first observed event following an implicit clock reset', () => {
|
||||||
|
expect(enrichGameChatTimeline([say('80:00'), '0:05 J;abc;0;Player', say('0:20')], {
|
||||||
|
mtime: at(300), matches: [match(280, 400)], reports: [report(290, 1)]
|
||||||
|
}).at(-1)?.context).toEqual({ realTime: at(300), matchElapsedMs: 20000, round: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the same latest game_start as fleet when the leaderboard deduplicates start retries', () => {
|
||||||
|
const entries = enrichGameChatTimeline([say('62:00'), say('62:30')], { mtime: at(150),
|
||||||
|
matches: [match(0, 300)], reports: [report(0, 0, { event: 'game_start', round: null }),
|
||||||
|
report(130, 0, { event: 'game_start', round: null }), report(140, 1)] });
|
||||||
|
expect(entries[0].context.matchElapsedMs).toBe(120000);
|
||||||
|
expect(entries[1].context).toEqual({ realTime: at(150), matchElapsedMs: 20000, round: 1 });
|
||||||
|
expect(enrichGameChatTimeline(['62:00 InitGame:', say('62:30')], { mtime: at(150),
|
||||||
|
matches: [match(0, 300)], reports: [report(130, 0, { event: 'game_start', round: null }), report(140, 1)]
|
||||||
|
}).at(-1)?.context).toEqual({ realTime: at(150), matchElapsedMs: 20000, round: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves context unknown without a usable final write timestamp', () => {
|
||||||
|
for (const mtime of [null, 'bad date']) {
|
||||||
|
expect(enrichGameChatTimeline([say('62:30')], { mtime, matches: [match(0, 300)], reports: [report(140, 7)] })[0].context)
|
||||||
|
.toEqual(unavailable);
|
||||||
|
}
|
||||||
|
expect(enrichGameChatTimeline([say('62:30'), 'untimed write'], { mtime: at(150), matches: [], reports: [] })[0].context)
|
||||||
|
.toEqual(unavailable);
|
||||||
|
expect(enrichGameChatTimeline([], { mtime: at(150), matches: [], reports: [] })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { mkdir, utimes, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { gameEvent } from '../helpers/telemetry.js';
|
||||||
|
import type { GameEvent } from '../../src/lib/telemetry.js';
|
||||||
|
|
||||||
|
const SERVER = 't5_zombie_theater_1';
|
||||||
|
const T6_SERVER = 't6_zombie_town_1';
|
||||||
|
let fixture: Fixture;
|
||||||
|
let baseline: number;
|
||||||
|
const at = (seconds: number) => new Date(baseline + seconds * 1000).toISOString();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
baseline = Math.floor(Date.now() / 1000) * 1000 - 600_000;
|
||||||
|
fixture = await newFixture([SERVER, T6_SERVER]);
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
db().prepare('UPDATE servers SET created_at = ?').run(at(-120));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function log(lines: string[], lastWrite = 60, server = SERVER, filename?: string) {
|
||||||
|
const folder = join(server.startsWith('t6_') ? fixture.t6 : fixture.t5, server, 'logs');
|
||||||
|
await mkdir(folder, { recursive: true });
|
||||||
|
const path = join(folder, filename ?? (server.startsWith('t6_') ? 'games_zm.log' : 'games_sp.log'));
|
||||||
|
await writeFile(path, lines.join('\n') + '\n');
|
||||||
|
await utimes(path, new Date(at(lastWrite)), new Date(at(lastWrite)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function report(event: GameEvent['event'], seconds: number, round = 1, status = 200, diagnostic = true) {
|
||||||
|
const value = gameEvent(event, round, 1, SERVER);
|
||||||
|
if (status === 200) {
|
||||||
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||||
|
saveTelemetry({ ...value, receivedAt: at(seconds) });
|
||||||
|
}
|
||||||
|
if (diagnostic) {
|
||||||
|
const { recordTelemetryRequest } = await import('../../src/lib/server/fleet/telemetry-requests.js');
|
||||||
|
recordTelemetryRequest({ value, receivedAt: at(seconds), status,
|
||||||
|
error: status === 200 ? null : 'Rejected request', contentType: 'application/json' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function get(server = SERVER) {
|
||||||
|
const { GET } = await import('../../src/routes/api/servers/[name]/chat/+server.js');
|
||||||
|
return GET({ params: { name: server } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('server chat API', () => {
|
||||||
|
it('keeps real timestamps and historical match context stable across refreshes', async () => {
|
||||||
|
await report('game_start', 0);
|
||||||
|
await report('round_change', 40, 4);
|
||||||
|
await report('round_change', 45, 99, 400);
|
||||||
|
await log(['60:50 say;abc;0;Player;hello', '61:00 J;abc;0;Player']);
|
||||||
|
const response = await get();
|
||||||
|
const body = await response.json();
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
expect(body).toMatchObject({ ok: true, missing: false, entries: [
|
||||||
|
{ type: 'message', message: { player: 'Player', message: 'hello' },
|
||||||
|
context: { realTime: at(50), matchElapsedMs: 50_000, round: 4 } }
|
||||||
|
] });
|
||||||
|
expect(JSON.stringify(body)).not.toContain('headshots');
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(baseline + 900_000);
|
||||||
|
expect(await (await get()).json()).toEqual(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the correct game log for each game', async () => {
|
||||||
|
await log(['1:00 say;abc;0;T5 player;t5 message']);
|
||||||
|
await log(['1:00 say;abc;0;Wrong;wrong t5 file'], 60, SERVER, 'games_zm.log');
|
||||||
|
await log(['1:00 say;abc;0;T6 player;t6 message'], 60, T6_SERVER);
|
||||||
|
await log(['1:00 say;abc;0;Wrong;wrong t6 file'], 60, T6_SERVER, 'games_sp.log');
|
||||||
|
expect((await (await get()).json()).entries[0].message.message).toBe('t5 message');
|
||||||
|
expect((await (await get(T6_SERVER)).json()).entries[0].message.message).toBe('t6 message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty successful response until the game log exists', async () => {
|
||||||
|
expect(await (await get()).json()).toEqual({ ok: true, entries: [], missing: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown and destroyed servers even when files remain', async () => {
|
||||||
|
expect((await get('t5_zombie_theater_99')).status).toBe(404);
|
||||||
|
await log(['1:00 say;abc;0;Player;hello']);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE servers SET destroyed_at = ? WHERE name = ?').run(at(60), SERVER);
|
||||||
|
expect((await get()).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects registered names that escape the server directory', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('INSERT INTO servers (name, komodo_name) VALUES (?, ?)').run('..', '..');
|
||||||
|
expect((await get('..')).status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports read failures without returning server filesystem paths', async () => {
|
||||||
|
const logs = await import('../../src/lib/server/fleet/logs.js');
|
||||||
|
vi.spyOn(logs, 'tailLog').mockRejectedValue(new Error('read failed at /private/server/log'));
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
const response = await get();
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(await response.json()).toEqual({ ok: false, reason: 'Could not read server chat' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps latest accepted gameplay available when diagnostic logging was unavailable', async () => {
|
||||||
|
await report('game_start', 0, 1, 200, false);
|
||||||
|
await report('heartbeat', 40, 1, 200, false);
|
||||||
|
await log(['60:50 say;abc;0;Player;first round', '61:00 J;abc;0;Player']);
|
||||||
|
const body = await (await get()).json();
|
||||||
|
expect(body.entries).toHaveLength(1);
|
||||||
|
expect(body.entries[0].context).toEqual({ realTime: at(50), matchElapsedMs: 50_000, round: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reuse round diagnostics after their retention window', async () => {
|
||||||
|
await report('game_start', 0);
|
||||||
|
await report('heartbeat', 40, 4);
|
||||||
|
await log(['60:50 say;abc;0;Player;older message', '61:00 J;abc;0;Player']);
|
||||||
|
vi.spyOn(Date, 'now').mockReturnValue(baseline + 8 * 86_400_000);
|
||||||
|
const body = await (await get()).json();
|
||||||
|
expect(body.entries[0].context).toEqual({ realTime: at(50), matchElapsedMs: 50_000, round: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not associate retained messages with a deleted server incarnation', async () => {
|
||||||
|
await report('game_start', 0);
|
||||||
|
await report('heartbeat', 40, 8);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('DELETE FROM servers WHERE name = ?').run(SERVER);
|
||||||
|
db().prepare(`INSERT INTO servers (name, komodo_name, game, map, created_at)
|
||||||
|
VALUES (?, ?, 't5', 'zombie_theater', ?)`).run(SERVER, SERVER, at(70));
|
||||||
|
await report('game_start', 80);
|
||||||
|
await report('heartbeat', 90, 2);
|
||||||
|
await log(['60:50 say;abc;0;Player;previous server', '61:40 say;abc;0;Player;current server'], 100);
|
||||||
|
const body = await (await get()).json();
|
||||||
|
const messages = body.entries.filter((entry: { type: string }) => entry.type === 'message');
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages[0].context).toEqual({ realTime: at(50), matchElapsedMs: null, round: null });
|
||||||
|
expect(messages[1].context).toEqual({ realTime: at(100), matchElapsedMs: 20_000, round: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user