diff --git a/README.md b/README.md index 1b966eb..f89434a 100644 --- a/README.md +++ b/README.md @@ -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 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 searches server names, map names, and the full player roster. Filter occupied, diff --git a/src/lib/game-chat.ts b/src/lib/game-chat.ts index 9ea37a0..03ea07d 100644 --- a/src/lib/game-chat.ts +++ b/src/lib/game-chat.ts @@ -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 }; + }); +} diff --git a/src/lib/server/fleet/chat.ts b/src/lib/server/fleet/chat.ts new file mode 100644 index 0000000..9da4395 --- /dev/null +++ b/src/lib/server/fleet/chat.ts @@ -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 & { 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 + }); +} diff --git a/src/lib/ui/components/ChatPanel.svelte b/src/lib/ui/components/ChatPanel.svelte index ff2ec94..d855e65 100644 --- a/src/lib/ui/components/ChatPanel.svelte +++ b/src/lib/ui/components/ChatPanel.svelte @@ -1,11 +1,13 @@ +{#snippet chatContext(context: ChatContext)} +
+
+
Real time
+
{#if context.realTime}{:else}—{/if}
+
+
Match
{context.matchElapsedMs === null ? '—' : formatDuration(context.matchElapsedMs)}
+
Round
{context.round ?? '—'}
+
+{/snippet} +

Chat

Recent messages from the game log · refreshes every 5 seconds

@@ -71,22 +83,22 @@ {#each entries as entry} {#if entry.type === 'boundary'}
  • - - {entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'} - {#if entry.time}{entry.time}{/if} - +
    + {entry.event === 'start' ? 'Match started' : entry.event === 'end' ? 'Match ended' : 'New match · timer reset'} + {@render chatContext(entry.context)} +
  • {:else}
  • - {entry.message.time} -
    {entry.message.player}

    {entry.message.message}

    +
    {entry.message.player}{@render chatContext(entry.context)}
    +

    {entry.message.message}

  • {/if} {/each}
    {/if} -

    Times are elapsed game-log times. Older messages may fall outside the recent log window.

    +

    Real times are estimated from log writes. — means context is unavailable. Older messages may fall outside the recent log window.