Add real time, match duration, and round context to chat

This commit is contained in:
2026-10-02 13:21:55 -04:00
parent 65d3a99846
commit d7b3e124e6
8 changed files with 505 additions and 26 deletions
+7
View File
@@ -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
View File
@@ -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 };
});
}
+49
View File
@@ -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
});
}
+33 -16
View File
@@ -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 });
}
}
+1 -1
View File
@@ -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">
+119 -1
View File
@@ -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([]);
});
});
+144
View File
@@ -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 });
});
});