Files
electric-cherry/src/lib/game-chat.ts
T
2026-10-12 00:58:05 -04:00

239 lines
9.8 KiB
TypeScript

export interface ChatMessage {
time: string;
guid: string;
slot: number;
player: string;
message: string;
}
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; matchId?: number };
export interface ChatMatch {
id?: number;
startedAt: string;
endedAt: string | null;
lastReceivedAt: string;
status: 'active' | 'completed' | 'interrupted' | 'end-report-missing';
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, '')
// eslint-disable-next-line no-control-regex -- Strip control characters from displayed chat text.
.replace(/[\x00-\x1f\x7f]/g, '')
.trim()
);
}
/** Preserve log order: elapsed timestamps can reset between matches. */
export function parseGameChat(lines: readonly string[]): ChatMessage[] {
return parseGameChatTimeline(lines).flatMap((entry) =>
entry.type === 'message' ? [entry.message] : []
);
}
/** Keep match boundaries alongside messages, including when the log clock resets. */
export function parseGameChatTimeline(lines: readonly string[]): 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 [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 : logSeconds(time);
const event = line.slice(timestamp?.[0].length ?? 0).trimStart();
if (/^InitGame(?:\s|:|$)/.test(event)) {
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) push({ type: 'boundary', event: 'end', time });
ended = true;
} else if (seconds !== null && previousSeconds !== null && seconds < previousSeconds) {
push({ type: 'boundary', event: 'reset', time });
ended = false;
}
// Untimed lifecycle events also clear the previous match's clock.
if (seconds !== null || /^(?:InitGame|ExitLevel|ShutdownGame)(?:\s|:|$)/.test(event)) {
previousSeconds = seconds;
}
const match = /^\s*(\d+:[0-5]\d)\s+say;([^;]+);(\d+);([^;]*);(.*)$/.exec(line);
if (!match) continue;
const message = plainText(match[5]);
if (!message) continue;
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, ...(match?.id === undefined ? {} : { matchId: match.id }) };
});
}