Add real time, match duration, and round context to chat
This commit is contained in:
+130
-8
@@ -10,6 +10,39 @@ export type ChatEntry =
|
||||
| { type: 'message'; message: ChatMessage }
|
||||
| { 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 {
|
||||
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. */
|
||||
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 ended = false;
|
||||
|
||||
for (const line of lines) {
|
||||
const timestamp = /^\s*(\d+:[0-5]\d)\s+/.exec(line);
|
||||
for (const [lineIndex, line] of lines.entries()) {
|
||||
const push = (entry: ChatEntry) => entries.push({ entry, lineIndex });
|
||||
const timestamp = logTimestamp(line);
|
||||
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();
|
||||
if (/^InitGame(?:\s|:|$)/.test(event)) {
|
||||
entries.push({ type: 'boundary', event: 'start', time });
|
||||
push({ type: 'boundary', event: 'start', time });
|
||||
ended = false;
|
||||
} else if (/^(?:ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event)) {
|
||||
// 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;
|
||||
} else if (seconds !== null && previousSeconds !== null && seconds < previousSeconds) {
|
||||
entries.push({ type: 'boundary', event: 'reset', time });
|
||||
push({ type: 'boundary', event: 'reset', time });
|
||||
ended = false;
|
||||
}
|
||||
// Untimed lifecycle events also clear the previous match's clock.
|
||||
@@ -50,8 +88,92 @@ export function parseGameChatTimeline(lines: readonly string[]): ChatEntry[] {
|
||||
if (!match) continue;
|
||||
const message = plainText(match[5]);
|
||||
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 } });
|
||||
}
|
||||
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">
|
||||
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 { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import Button from './primitives/Button.svelte';
|
||||
|
||||
let { server, game }: { server: string; game: string | null } = $props();
|
||||
let entries = $state<ChatEntry[]>([]);
|
||||
let { server }: { server: string } = $props();
|
||||
let entries = $state<ContextualChatEntry[]>([]);
|
||||
let hasMessages = $derived(entries.some(entry => entry.type === 'message'));
|
||||
let loaded = $state(false);
|
||||
let loading = $state(false);
|
||||
@@ -21,12 +23,11 @@
|
||||
const request = new AbortController();
|
||||
controller = request;
|
||||
try {
|
||||
const key = game === 't6' ? 'games_zm' : 'games_sp';
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/logs/${key}?lines=2000`, { signal: request.signal });
|
||||
const response = await fetch(`/api/servers/${encodeURIComponent(server)}/chat`, { signal: request.signal });
|
||||
const data = await response.json();
|
||||
if (!response.ok || !data.ok) throw new Error(data.reason ?? 'Could not refresh chat.');
|
||||
if (request.signal.aborted) return;
|
||||
entries = parseGameChatTimeline(data.lines ?? []);
|
||||
entries = data.entries ?? [];
|
||||
missing = !!data.missing;
|
||||
loaded = true;
|
||||
error = '';
|
||||
@@ -51,6 +52,17 @@
|
||||
});
|
||||
</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">
|
||||
<div class="chat-head">
|
||||
<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}
|
||||
{#if entry.type === 'boundary'}
|
||||
<li class="match-divider" class:match-end={entry.event === 'end'}>
|
||||
<span class="match-label">
|
||||
{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'}
|
||||
{#if entry.time}<span class="time" title="Elapsed game-log time">{entry.time}</span>{/if}
|
||||
</span>
|
||||
<div class="match-label">
|
||||
<span>{entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'}</span>
|
||||
{@render chatContext(entry.context)}
|
||||
</div>
|
||||
</li>
|
||||
{:else}
|
||||
<li class="message">
|
||||
<span class="time" title="Elapsed game-log time">{entry.message.time}</span>
|
||||
<div><strong>{entry.message.player}</strong><p>{entry.message.message}</p></div>
|
||||
<div class="message-head"><strong>{entry.message.player}</strong>{@render chatContext(entry.context)}</div>
|
||||
<p>{entry.message.message}</p>
|
||||
</li>
|
||||
{/if}
|
||||
{/each}
|
||||
</ol>
|
||||
</div>
|
||||
{/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>
|
||||
|
||||
<style>
|
||||
@@ -95,12 +107,17 @@
|
||||
.chat-head p, .note, .empty { color: var(--text-secondary); font-size: var(--text-sm); }
|
||||
.messages { max-height: 60vh; overflow: auto; margin-top: 16px; }
|
||||
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::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); }
|
||||
.time { font-family: var(--font-mono); color: var(--text-tertiary); font-size: var(--text-sm); }
|
||||
strong { color: var(--text-primary); overflow-wrap: anywhere; }
|
||||
li p { margin: 4px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.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>
|
||||
{:else if activeTab === 'chat'}
|
||||
<Card padding="lg">
|
||||
{#key name}<ChatPanel server={name} game={server.game} />{/key}
|
||||
{#key name}<ChatPanel server={name} />{/key}
|
||||
</Card>
|
||||
{:else if activeTab === 'logs'}
|
||||
<Card padding="lg">
|
||||
|
||||
Reference in New Issue
Block a user