Scope live chat to the current match and save match history
This commit is contained in:
@@ -40,6 +40,15 @@ CREATE INDEX IF NOT EXISTS idx_leaderboard_server ON leaderboard_matches(server,
|
||||
CREATE INDEX IF NOT EXISTS idx_leaderboard_started ON leaderboard_matches(started_at DESC, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_leaderboard_map ON leaderboard_matches(game, map_key);
|
||||
|
||||
-- Chat follows match retention and survives deletion of the live server.
|
||||
CREATE TABLE IF NOT EXISTS match_chat (
|
||||
match_id INTEGER NOT NULL REFERENCES leaderboard_matches(id) ON DELETE CASCADE,
|
||||
message_key TEXT NOT NULL,
|
||||
occurrence INTEGER NOT NULL,
|
||||
entry TEXT NOT NULL,
|
||||
PRIMARY KEY (match_id, message_key, occurrence)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS leaderboard_players (
|
||||
game TEXT NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { db } from '../db/index.js';
|
||||
import { enrichGameChatTimeline, type ChatMatch, type ChatRoundReport, type ContextualChatEntry } from '../../game-chat.js';
|
||||
import { parseGameEvent } from '../../telemetry.js';
|
||||
import { telemetryRetention, telemetryCutoff } from './telemetry-requests.js';
|
||||
import type { ServerRow } from './servers.js';
|
||||
import type { TailResult } from './logs.js';
|
||||
import { getServer, listServers, type ServerRow } from './servers.js';
|
||||
import { gameLogKey, resolveLogPath, tailLog, 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,
|
||||
const matches = handle.prepare(`SELECT id, 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 }>;
|
||||
@@ -47,3 +48,89 @@ export function serverChatTimeline(server: ServerRow, tail: TailResult): Context
|
||||
reports
|
||||
});
|
||||
}
|
||||
|
||||
export function matchChatTimeline(matchId: number): ContextualChatEntry[] {
|
||||
return (db().prepare('SELECT entry FROM match_chat WHERE match_id = ? ORDER BY rowid')
|
||||
.all(matchId) as { entry: string }[]).map(row => JSON.parse(row.entry));
|
||||
}
|
||||
|
||||
/** Save before filtering the live view so completed matches retain their messages. */
|
||||
function archiveChat(entries: ContextualChatEntry[]): void {
|
||||
const handle = db();
|
||||
const insert = handle.prepare(`INSERT INTO match_chat (match_id, message_key, occurrence, entry)
|
||||
VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING`);
|
||||
const occurrences = new Map<string, number>();
|
||||
handle.transaction(() => {
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== 'message' || entry.matchId === undefined) continue;
|
||||
// Engine time is stable across reads; estimated wall time is not.
|
||||
// Count identical messages separately, including repeats in one second.
|
||||
const key = createHash('sha256').update(JSON.stringify(entry.message)).digest('hex');
|
||||
const scope = `${entry.matchId}:${key}`;
|
||||
const occurrence = (occurrences.get(scope) ?? 0) + 1;
|
||||
occurrences.set(scope, occurrence);
|
||||
insert.run(entry.matchId, key, occurrence, JSON.stringify(entry));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export function currentMatchChat(server: ServerRow, tail: TailResult | null): ContextualChatEntry[] {
|
||||
const entries = tail ? serverChatTimeline(server, tail) : [];
|
||||
archiveChat(entries);
|
||||
const match = db().prepare(`SELECT id, started_at AS startedAt FROM leaderboard_matches
|
||||
WHERE server = ? AND server_deleted_at IS NULL ORDER BY id DESC LIMIT 1`)
|
||||
.get(server.name) as { id: number; startedAt: string } | undefined;
|
||||
let startIndex = entries.length - 1;
|
||||
while (startIndex >= 0) {
|
||||
const entry = entries[startIndex];
|
||||
if (entry.type === 'boundary' && entry.event !== 'end') break;
|
||||
startIndex--;
|
||||
}
|
||||
const current = entries.slice(Math.max(0, startIndex));
|
||||
if (!match) {
|
||||
// Servers without gameplay telemetry still have log-defined sessions.
|
||||
return current.filter(entry => entry.context.realTime !== null
|
||||
&& Date.parse(entry.context.realTime) >= Date.parse(server.created_at));
|
||||
}
|
||||
const start = entries[startIndex];
|
||||
// A log restart can arrive before game_start telemetry. Clear immediately
|
||||
// instead of falling back to messages from the previous recorded match.
|
||||
if (start && !current.some(entry => entry.matchId === match.id)
|
||||
&& (!start.context.realTime || Date.parse(start.context.realTime) > Date.parse(match.startedAt) + 1000)) return [];
|
||||
return matchChatTimeline(match.id);
|
||||
}
|
||||
|
||||
const pending = new Map<string, Promise<{ entries: ContextualChatEntry[]; missing: boolean }>>();
|
||||
|
||||
/** Shared by the live API and the worker; collection does not require a viewer. */
|
||||
export function collectServerChat(server: ServerRow): Promise<{ entries: ContextualChatEntry[]; missing: boolean }> {
|
||||
const key = `${server.name}:${server.created_at}:${server.native_instance_id}:${server.instance_generation}`;
|
||||
const existing = pending.get(key);
|
||||
if (existing) return existing;
|
||||
const work = (async () => {
|
||||
const path = resolveLogPath(server.name, gameLogKey(server.name));
|
||||
if (!path) throw new Error('Invalid server log path');
|
||||
const tail = await tailLog(path, 2000);
|
||||
const current = getServer(server.name);
|
||||
// A remote read may finish after destruction or replacement.
|
||||
if (!current || current.destroyed_at || current.created_at !== server.created_at
|
||||
|| current.native_instance_id !== server.native_instance_id || current.instance_generation !== server.instance_generation) {
|
||||
return { entries: [], missing: true };
|
||||
}
|
||||
return { entries: currentMatchChat(current, tail), missing: tail === null };
|
||||
})().finally(() => pending.delete(key));
|
||||
pending.set(key, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
export async function collectFleetChat(): Promise<void> {
|
||||
const servers = listServers().filter(server => !server.destroyed_at);
|
||||
// Bound agent traffic and isolate failures to their own server.
|
||||
async function collect() {
|
||||
for (let server = servers.shift(); server; server = servers.shift()) {
|
||||
try { await collectServerChat(server); }
|
||||
catch (error) { console.error(`[chat] Could not collect chat for ${server.name}`, error); }
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(4, servers.length) }, collect));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { db } from '../db/index.js';
|
||||
import { matchChatTimeline } from './chat.js';
|
||||
import { MIN_RECORDED_ROUND, type MatchDetail, type MatchPlayer, type MatchHistoryResponse, type ServerMatch } from '../../match-history.js';
|
||||
|
||||
import { readMatchQuery, type MatchHistoryQuery } from '../../match-history-query.js';
|
||||
@@ -48,7 +49,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
||||
const match = handle.prepare(`SELECT ${matchSummary}, m.start_observed AS startObserved
|
||||
FROM leaderboard_matches m WHERE m.id = ? AND m.status = 'completed'
|
||||
AND m.highest_round >= ${MIN_RECORDED_ROUND}`)
|
||||
.get(id) as (Omit<MatchDetail, 'players' | 'startObserved'> & { startObserved: number }) | undefined;
|
||||
.get(id) as (Omit<MatchDetail, 'players' | 'chat' | 'startObserved'> & { startObserved: number }) | undefined;
|
||||
if (!match) return null;
|
||||
|
||||
const players = handle.prepare(`SELECT p.guid,
|
||||
@@ -79,7 +80,7 @@ export function getMatchDetail(id: number): MatchDetail | null {
|
||||
.all(match.game, id) as Array<Omit<MatchPlayer, 'hasStats' | 'presentAtEnd' | 'canViewProfile'> & {
|
||||
hasStats: number; presentAtEnd: number; canViewProfile: number;
|
||||
}>;
|
||||
return { ...match, startObserved: Boolean(match.startObserved), players: players.map(player => ({
|
||||
return { ...match, chat: matchChatTimeline(id), startObserved: Boolean(match.startObserved), players: players.map(player => ({
|
||||
...player, hasStats: Boolean(player.hasStats), presentAtEnd: Boolean(player.presentAtEnd),
|
||||
canViewProfile: Boolean(player.canViewProfile)
|
||||
})) };
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { pruneHistory, pruneSampleHistory } from '../retention.js';
|
||||
import { collectFleetChat } from '../fleet/chat.js';
|
||||
import { runAutoscaling, pruneAutoscaling } from '../autoscaling/index.js';
|
||||
import { config } from '../config.js';
|
||||
import { processProvisioning } from '../provisioning/worker.js';
|
||||
@@ -57,6 +58,7 @@ function reconcileRequesters(): void {
|
||||
['maintenance', 'Checking maintenance and recovery', runMaintenance],
|
||||
['autoscaling', 'Evaluating lobby autoscaling', runAutoscaling],
|
||||
['game-files', 'Removing unused game files', cleanupControllerLibraries],
|
||||
['chat', 'Saving match chat', collectFleetChat],
|
||||
['retention', 'Cleaning expired history', async () => { pruneHistory(); pruneSampleHistory(); pruneAutoscaling(); }]
|
||||
];
|
||||
for (const [key, label, work] of requesters) {
|
||||
@@ -67,7 +69,7 @@ function reconcileRequesters(): void {
|
||||
}
|
||||
const flight = trackActivity(key, label, work)
|
||||
.catch(err => { console.error(`[worker] ${key} error:`, err); })
|
||||
.then(async result => { if (key !== 'game-files' && key !== 'retention' && (key !== 'autoscaling' || result === true)) await tick(true); })
|
||||
.then(async result => { if (key !== 'game-files' && key !== 'retention' && key !== 'chat' && (key !== 'autoscaling' || result === true)) await tick(true); })
|
||||
.finally(() => { requesterFlights.delete(key); });
|
||||
requesterFlights.set(key, flight);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user