diff --git a/src/database/collections.ts b/src/database/collections.ts index 8628a3484..95cde5362 100644 --- a/src/database/collections.ts +++ b/src/database/collections.ts @@ -29,7 +29,7 @@ import type { FuturePlayerSkillModel } from './models/future-player-skill.model' import type { PendingImportModel } from './models/pending-import.model' import type { LogsTfLogModel } from './models/logs-tf-log.model' import type { DeferredKickModel } from './models/deferred-kick.model' -import type { GameRoundProgressModel } from './models/game-round-progress.model' +import type { GameLogParseStateModel } from './models/game-log-parse-state.model' import type { TelemetryStatModel } from './models/telemetry-stat.model' import { ensureIndexes } from './ensure-indexes' @@ -48,7 +48,7 @@ export const collections = { gameLogs: database.collection('gamelogs'), games: database.collection('games'), gamesDeferredKicks: database.collection('games.deferredkicks'), - gamesRoundProgress: database.collection('games.roundprogress'), + gamesLogParseState: database.collection('games.logparsestate'), gamesSubstituteRequests: database.collection('games.substituterequests'), keys: database.collection('keys'), logsTfLogs: database.collection('logstf.logs'), diff --git a/src/database/ensure-indexes.ts b/src/database/ensure-indexes.ts index 7897096f3..3138792d7 100644 --- a/src/database/ensure-indexes.ts +++ b/src/database/ensure-indexes.ts @@ -6,6 +6,7 @@ import { type IndexSpecification, } from 'mongodb' import { logger } from '../logger' +import { hoursToSeconds } from 'date-fns' interface IndexDefinition { spec: IndexSpecification @@ -62,7 +63,10 @@ const definitions: Partial> { spec: { gameNumber: 1, slotId: 1 }, options: { unique: true } }, { spec: { gameNumber: 1, replacement: 1 } }, ], - gamesRoundProgress: [{ spec: { gameNumber: 1 }, options: { unique: true } }], + gamesLogParseState: [ + { spec: { gameNumber: 1 }, options: { unique: true } }, + { spec: { at: 1 }, options: { expireAfterSeconds: hoursToSeconds(24) } }, + ], futurePlayerSkills: [{ spec: { steamId: 1 }, options: { unique: true } }], pendingImports: [{ spec: { actor: 1 }, options: { unique: true } }], logsTfLogs: [ diff --git a/src/database/models/game-log-parse-state.model.ts b/src/database/models/game-log-parse-state.model.ts new file mode 100644 index 000000000..5d4ab539a --- /dev/null +++ b/src/database/models/game-log-parse-state.model.ts @@ -0,0 +1,17 @@ +import type { GameContext } from '../../tf2-game-analyzer/game-context' +import type { GameNumber } from './game.model' + +/** + * Persisted parse state for a single game's log stream. The tf2-game-analyzer + * {@link GameContext} is a plain, serializable blob that the log parser reads, + * mutates and writes back for every log line. It supersedes the old + * games.roundprogress document: all in-progress state (round assembly, the + * pending stopwatch side-swap, restart detection, the running score) lives + * inside `context`. `at` is refreshed on every write and drives a TTL index that + * sweeps the document a day after the game's log traffic stops. + */ +export interface GameLogParseStateModel { + gameNumber: GameNumber + context: GameContext + at: Date +} diff --git a/src/database/models/game-round-progress.model.ts b/src/database/models/game-round-progress.model.ts deleted file mode 100644 index 7ff61b841..000000000 --- a/src/database/models/game-round-progress.model.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Tf2Team } from '../../shared/types/tf2-team' -import type { GameNumber } from './game.model' - -/** - * The in-progress assembly of a single round, persisted so that a partially - * observed round — and the pending side-swap on stopwatch maps — survives an - * app restart. TF2 reports a round's outcome across several log lines - * (Round_Win, Round_Length, the per-team score) that may arrive at any time, so - * we accumulate them here and commit a `roundEnded` game event once complete. - * The document is cleared between rounds and removed when the match ends. - */ -export interface GameRoundProgressModel { - gameNumber: GameNumber - - // fields of the round currently being assembled; absent once committed - round?: { - winner?: Tf2Team - lengthMs?: number - score?: Partial> - captures?: Partial> - } - - // the teams will switch sides when the next round starts (stopwatch maps); - // deferred to the next round start so the final round produces no trailing swap - swapPending?: boolean -} diff --git a/src/events.ts b/src/events.ts index 796d7c0a8..4657ef42e 100644 --- a/src/events.ts +++ b/src/events.ts @@ -83,14 +83,6 @@ export interface Events { 'match:started': { gameNumber: GameNumber } - 'match:roundWon': { - gameNumber: GameNumber - winner: Tf2Team - } - 'match:roundLength': { - gameNumber: GameNumber - lengthMs: number - } 'match:ended': { gameNumber: GameNumber } @@ -113,11 +105,6 @@ export interface Events { steamId: SteamId64 message: string } - 'match/score:reported': { - gameNumber: GameNumber - teamName: Tf2Team - score: number - } 'match/score:final': { gameNumber: GameNumber team: Tf2Team @@ -128,11 +115,6 @@ export interface Events { 'match/score:reset': { gameNumber: GameNumber } - 'match/controlPoint:captured': { - gameNumber: GameNumber - team: Tf2Team - controlPoint: number - } 'match/logs:uploaded': { gameNumber: GameNumber logsUrl: string diff --git a/src/games/plugins/match-event-listener.test.ts b/src/games/plugins/match-event-listener.test.ts deleted file mode 100644 index edb430b06..000000000 --- a/src/games/plugins/match-event-listener.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('fastify-plugin', () => ({ - default: (fn: T): T => fn, -})) - -vi.mock('../../events', () => ({ - events: { - on: vi.fn(), - emit: vi.fn(), - }, -})) - -vi.mock('../../logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - }, -})) - -vi.mock('../../database/collections', () => ({ - collections: { - games: { - findOne: vi.fn(), - }, - }, -})) - -vi.mock('../../otel', () => ({ - meter: { - createCounter: () => ({ add: vi.fn() }), - }, -})) - -import { events } from '../../events' -import { collections } from '../../database/collections' -import plugin from './match-event-listener' - -const gameNumber = 7615 -const logSecret = '12345' - -describe('match-event-listener', () => { - let onGameLogMessage: (params: { - message: { payload: string; password: string } - }) => Promise - - beforeEach(async () => { - vi.clearAllMocks() - vi.mocked(collections.games.findOne).mockResolvedValue({ number: gameNumber } as never) - await (plugin as unknown as () => Promise)() - - const call = vi - .mocked(events.on) - .mock.calls.find(([event]: [string, ...unknown[]]) => event === 'gamelog:message') - onGameLogMessage = call![1] as typeof onGameLogMessage - - // reset the module-level round start tracking between tests - await onGameLogMessage({ - message: { - payload: '07/13/2026 - 00:00:00: World triggered "Game_Over" reason "test reset"', - password: logSecret, - }, - }) - vi.mocked(events.emit).mockClear() - }) - - const roundStart = (timestamp: string) => ({ - message: { payload: `${timestamp}: World triggered "Round_Start"`, password: logSecret }, - }) - - it('emits match:started for a regular round start', async () => { - await onGameLogMessage(roundStart('07/13/2026 - 17:44:53')) - expect(events.emit).toHaveBeenCalledWith('match:started', { gameNumber }) - expect(events.emit).not.toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('emits match/score:reset for a doubled round start mid-game', async () => { - // the real sequence of https://tf2pickup.pl/games/7615: the round started - // at 17:35:10 was aborted (everyone left to spectator) and the match was - // restarted at 17:39:30 - await onGameLogMessage(roundStart('07/13/2026 - 17:35:10')) - await onGameLogMessage(roundStart('07/13/2026 - 17:39:30')) - await onGameLogMessage(roundStart('07/13/2026 - 17:39:30')) - expect(events.emit).toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('does not emit match/score:reset for the doubled round start at the initial match start', async () => { - await onGameLogMessage(roundStart('07/13/2026 - 17:33:28')) - await onGameLogMessage(roundStart('07/13/2026 - 17:33:28')) - expect(events.emit).not.toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('does not emit match/score:reset for round starts at different times', async () => { - await onGameLogMessage(roundStart('07/13/2026 - 18:01:44')) - await onGameLogMessage(roundStart('07/13/2026 - 18:07:40')) - expect(events.emit).not.toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('does not emit match/score:reset when a round ended in a stalemate in between', async () => { - // stalemates end a round with no Round_Win line; in compressed-timestamp - // log replays the post-stalemate round start shares the second with the - // previous one - await onGameLogMessage(roundStart('05/16/2026 - 16:46:17')) - await onGameLogMessage({ - message: { - payload: '05/16/2026 - 16:46:17: World triggered "Round_Stalemate"', - password: logSecret, - }, - }) - await onGameLogMessage(roundStart('05/16/2026 - 16:46:17')) - expect(events.emit).not.toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('emits match/score:reset for a doubled round start straddling a second boundary mid-game', async () => { - await onGameLogMessage(roundStart('07/13/2026 - 17:30:00')) - await onGameLogMessage(roundStart('07/13/2026 - 17:35:10')) - await onGameLogMessage(roundStart('07/13/2026 - 17:35:11')) - expect(events.emit).toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('does not emit match/score:reset for the initial doubled round start straddling a second boundary', async () => { - // real case: https://logs.tf/4084159 - await onGameLogMessage(roundStart('07/13/2026 - 16:35:01')) - await onGameLogMessage(roundStart('07/13/2026 - 16:35:02')) - expect(events.emit).not.toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) - - it('does not emit match/score:reset when a round was won in between', async () => { - // log replays re-stamp lines with the current time, so consecutive rounds - // can share a timestamp — a completed round marks a regular transition - await onGameLogMessage(roundStart('06/16/2026 - 10:38:23')) - await onGameLogMessage({ - message: { - payload: '06/16/2026 - 10:38:23: World triggered "Round_Win" (winner "Blue")', - password: logSecret, - }, - }) - await onGameLogMessage(roundStart('06/16/2026 - 10:38:23')) - expect(events.emit).not.toHaveBeenCalledWith('match/score:reset', { gameNumber }) - }) -}) diff --git a/src/games/plugins/match-event-listener.ts b/src/games/plugins/match-event-listener.ts deleted file mode 100644 index e7450555c..000000000 --- a/src/games/plugins/match-event-listener.ts +++ /dev/null @@ -1,306 +0,0 @@ -import fp from 'fastify-plugin' -import { differenceInSeconds, parse } from 'date-fns' -import type { GameNumber } from '../../database/models/game.model' -import { events } from '../../events' -import type { SteamId64 } from '../../shared/types/steam-id-64' -import type { Tf2Team } from '../../shared/types/tf2-team' -import SteamID from 'steamid' -import { collections } from '../../database/collections' -import { logger } from '../../logger' -import { meter } from '../../otel' -import { ValueType } from '@opentelemetry/api' - -interface GameEvent { - /* name of the game event */ - name: string - - /* the event is triggered if a log line matches this regex */ - regex: RegExp - - /* handle the event being triggered */ - handle: (number: GameNumber, matches: RegExpMatchArray) => void -} - -// converts 'Red' and 'Blue' to valid team names -const fixTeamName = (teamName: string): Tf2Team => teamName.toLowerCase().substring(0, 3) as Tf2Team - -// the last Round_Start line seen per game (cleared when a round ends with a -// win or a stalemate) and whether any Round_Start was seen at all this match; -// used to detect the doubled Round_Start below -const lastRoundStart = new Map() -const seenRoundStart = new Set() - -const parseLogTimestamp = (timestamp: string) => - parse(timestamp, 'MM/dd/yyyy - HH:mm:ss', new Date()) - -const gameEvents: GameEvent[] = [ - { - // TODO rename to "round start" - name: 'match started', - // TF2 logs Round_Start once per regular round, but twice within the same - // second (occasionally straddling a second boundary) when a tournament - // match (re)starts. A regular round transition always has a Round_Win or a - // Round_Stalemate between two Round_Starts (both clear the remembered line - // below), so a pair ≤1s apart can only be the (re)start doubling — even - // when log lines arrive with compressed timestamps, as in e2e log replays. - // The pair at the initial match start is expected; a pair preceded by an - // earlier Round_Start means the match was restarted mid-game (everyone - // left to spectator, an admin re-exec'd the config, - // mp_tournament_restart) and the server reset its scoreboard. - regex: /^(\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}):\sWorld triggered "Round_Start"$/, - handle: (gameNumber, matches) => { - events.emit('match:started', { gameNumber }) - if (!matches[1]) { - return - } - const at = parseLogTimestamp(matches[1]) - const last = lastRoundStart.get(gameNumber) - if (last && Math.abs(differenceInSeconds(at, last.at)) <= 1) { - if (last.precededByRoundStart) { - lastRoundStart.delete(gameNumber) - events.emit('match/score:reset', { gameNumber }) - } - } else { - lastRoundStart.set(gameNumber, { - at, - precededByRoundStart: seenRoundStart.has(gameNumber), - }) - } - seenRoundStart.add(gameNumber) - }, - }, - { - name: 'round win', - // https://regex101.com/r/41LfKS/2 - regex: - /^\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}:\sWorld triggered "Round_Win" \(winner "(.+)"\)$/, - handle: (gameNumber, matches) => { - lastRoundStart.delete(gameNumber) - if (matches[1]) { - const winner = fixTeamName(matches[1]) - events.emit('match:roundWon', { gameNumber, winner }) - } - }, - }, - { - name: 'round stalemate', - // a stalemate ends a round with no Round_Win line; clear the remembered - // Round_Start so the next one is not mistaken for a restart doubling - regex: /^\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}:\sWorld triggered "Round_Stalemate"$/, - handle: gameNumber => { - lastRoundStart.delete(gameNumber) - }, - }, - { - name: 'round length', - // payload/attack-defend maps emit "Mini_Round_Length" instead of "Round_Length" - // https://regex101.com/r/mvOYMz/3 - regex: - /^\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}:\sWorld triggered "(?:Mini_)?Round_Length" \(seconds "([\d.]+)"\)$/, - handle: (gameNumber, matches) => { - if (matches[1]) { - const seconds = parseFloat(matches[1]) - events.emit('match:roundLength', { gameNumber, lengthMs: seconds * 1000 }) - } - }, - }, - { - // TODO rename to "game over" - name: 'match ended', - regex: /^[\d/\s-:]+World triggered "Game_Over" reason ".*"$/, - handle: gameNumber => { - lastRoundStart.delete(gameNumber) - seenRoundStart.delete(gameNumber) - events.emit('match:ended', { gameNumber }) - }, - }, - { - name: 'logs uploaded', - regex: /^[\d/\s-:]+\[TFTrue\].+\shttp:\/\/logs\.tf\/(\d+)\..*$/, - handle: (gameNumber, matches) => { - const logsUrl = `http://logs.tf/${matches[1]}` - events.emit('match/logs:uploaded', { gameNumber, logsUrl }) - }, - }, - { - name: 'player connected', - // https://regex101.com/r/uyPW8m/5 - regex: - /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><>"\sconnected,\saddress\s"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})"$/, - handle: (gameNumber, matches) => { - if (!matches[5] || !matches[6]) { - return - } - const steamId = new SteamID(matches[5]) - if (steamId.isValid()) { - events.emit('match/player:connected', { - gameNumber, - steamId: steamId.getSteamID64() as SteamId64, - ipAddress: matches[6], - }) - } - }, - }, - { - name: 'player joined team', - // https://regex101.com/r/yzX9zG/1 - regex: - /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><(.+)>"\sjoined\steam\s"(.+)"/, - handle: (gameNumber, matches) => { - if (!matches[5] || !matches[7]) { - return - } - const steamId = new SteamID(matches[5]) - if (steamId.isValid()) { - events.emit('match/player:joinedTeam', { - gameNumber, - steamId: steamId.getSteamID64() as SteamId64, - team: fixTeamName(matches[7]), - }) - } - }, - }, - { - name: 'player disconnected', - // https://regex101.com/r/x4AMTG/1 - regex: - /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><(.[^>]+)>"\sdisconnected\s\(reason\s"(.[^"]+)"\)$/, - handle: (gameNumber, matches) => { - if (!matches[5] || !matches[6]) { - return - } - const steamId = new SteamID(matches[5]) - if (steamId.isValid()) { - events.emit('match/player:disconnected', { - gameNumber, - steamId: steamId.getSteamID64() as SteamId64, - }) - } - }, - }, - { - name: 'score reported', - // https://regex101.com/r/ZD6eLb/1 - regex: /^[\d/\s\-:]+Team "(.[^"]+)" current score "(\d)" with "(\d)" players$/, - handle: (gameNumber, matches) => { - const [, teamName, score] = matches - if (teamName && score) { - events.emit('match/score:reported', { - gameNumber, - teamName: fixTeamName(teamName), - score: Number(score), - }) - } - }, - }, - { - name: 'final score reported', - // https://regex101.com/r/RAUdTe/1 - regex: /^[\d/\s\-:]+Team "(.[^"]+)" final score "(\d)" with "(\d)" players$/, - handle: (gameNumber, matches) => { - const [, teamName, score] = matches - if (teamName && score) { - events.emit('match/score:final', { - gameNumber, - team: fixTeamName(teamName), - score: Number(score), - }) - } - }, - }, - { - name: 'point captured', - // https://regex101.com/r/3fJZ4r/1 - regex: /^[\d/\s\-:]+Team "(.[^"]+)" triggered "pointcaptured" \(cp "(\d+)"\)/, - handle: (gameNumber, matches) => { - const [, teamName, controlPoint] = matches - if (teamName && controlPoint) { - events.emit('match/controlPoint:captured', { - gameNumber, - team: fixTeamName(teamName), - controlPoint: Number(controlPoint), - }) - } - }, - }, - { - name: 'demo uploaded', - // https://regex101.com/r/JLGRYa/2 - regex: /^[\d/\s-:]+\[demos\.tf\]:\sSTV\savailable\sat:\s(.+)$/, - handle: (gameNumber, matches) => { - const demoUrl = matches[1] - if (demoUrl) { - events.emit('match/demo:uploaded', { gameNumber, demoUrl }) - } - }, - }, - { - name: 'player said', - // https://regex101.com/r/zpFkkA/1 - regex: - /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><(.[^>]+)>"\ssay\s"(.+)"$/, - handle: (gameNumber, matches) => { - if (!matches[5] || !matches[7]) { - return - } - const steamId = new SteamID(matches[5]) - if (steamId.isValid()) { - const message = matches[7] - events.emit('match/player:said', { - gameNumber, - steamId: steamId.getSteamID64() as SteamId64, - message, - }) - } - }, - }, -] - -const eventCounter = meter.createCounter('tf2pickup.games.events.count', { - description: 'Game events that come from the gameserver', - unit: '1', - valueType: ValueType.INT, -}) - -type EventHandledResult = - | { - handled: true - gameNumber: GameNumber - } - | { - handled: false - } - -async function testForGameEvent(message: string, logSecret: string): Promise { - for (const gameEvent of gameEvents) { - const matches = message.match(gameEvent.regex) - if (matches) { - const game = await collections.games.findOne({ logSecret }, { projection: { number: 1 } }) - if (game === null) { - logger.error({ message }, `error handling game event: no such game`) - return { handled: false } - } - gameEvent.handle(game.number, matches) - return { handled: true, gameNumber: game.number } - } - } - - return { handled: false } -} - -export default fp( - // eslint-disable-next-line @typescript-eslint/require-await - async () => { - events.on('gamelog:message', async ({ message }) => { - const result = await testForGameEvent(message.payload, message.password) - eventCounter.add(1, { - 'tf2pickup.games.event.handled': result.handled, - ...(result.handled ? { 'tf2pickup.game.number': result.gameNumber } : {}), - }) - }) - }, - { - name: 'match event listener', - encapsulate: true, - }, -) diff --git a/src/games/plugins/parse-game-log.test.ts b/src/games/plugins/parse-game-log.test.ts new file mode 100644 index 000000000..50c39e79e --- /dev/null +++ b/src/games/plugins/parse-game-log.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('fastify-plugin', () => ({ + default: (fn: T): T => fn, +})) + +vi.mock('../../events', () => ({ + events: { on: vi.fn(), emit: vi.fn() }, +})) + +vi.mock('../../logger', () => ({ + logger: { info: vi.fn(), error: vi.fn() }, +})) + +vi.mock('../../otel', () => ({ + meter: { createCounter: () => ({ add: vi.fn() }) }, +})) + +vi.mock('../update', () => ({ + update: vi.fn().mockResolvedValue({}), +})) + +vi.mock('../../database/collections', () => ({ + collections: { + games: { findOne: vi.fn() }, + gamesLogParseState: { findOne: vi.fn(), updateOne: vi.fn() }, + }, +})) + +import { events } from '../../events' +import { collections } from '../../database/collections' +import plugin from './parse-game-log' + +const gameNumber = 4242 +const logSecret = 'secret-1' + +// let the fire-and-forget per-game queue drain; every mocked db call resolves +// immediately, so one macrotask flush is enough +const flush = () => new Promise(resolve => setImmediate(resolve)) + +type Handler = (params: { message: { payload: string; password: string } }) => void + +describe('parse-game-log', () => { + let onMessage: Handler + + beforeEach(async () => { + vi.clearAllMocks() + vi.mocked(collections.games.findOne).mockResolvedValue({ number: gameNumber } as never) + vi.mocked(collections.gamesLogParseState.findOne).mockResolvedValue(null as never) + vi.mocked(collections.gamesLogParseState.updateOne).mockResolvedValue({} as never) + + await (plugin as unknown as () => Promise)() + const call = vi + .mocked(events.on) + .mock.calls.find(([event]: [string, ...unknown[]]) => event === 'gamelog:message') + onMessage = call![1] as Handler + }) + + const feed = async (payload: string) => { + onMessage({ message: { payload, password: logSecret } }) + await flush() + } + + it('persists the context when a line changes it', async () => { + await feed('07/13/2026 - 17:00:00: World triggered "Round_Start"') + expect(collections.gamesLogParseState.updateOne).toHaveBeenCalledWith( + { gameNumber }, + expect.objectContaining({ $set: expect.objectContaining({ context: expect.anything() }) }), + { upsert: true }, + ) + }) + + it('does not touch the database for a line that leaves the context unchanged', async () => { + await feed( + '07/13/2026 - 17:00:00: "Foo<3><[U:1:1]>" killed "Bar<4><[U:1:2]>" with "scattergun"', + ) + expect(collections.gamesLogParseState.updateOne).not.toHaveBeenCalled() + }) + + it('reads the game number and context once, then serves them from memory', async () => { + await feed('an unrecognized log line') + await feed('another unrecognized log line') + expect(collections.games.findOne).toHaveBeenCalledTimes(1) + expect(collections.gamesLogParseState.findOne).toHaveBeenCalledTimes(1) + }) + + it('marks the line unhandled and does nothing when the game is unknown', async () => { + vi.mocked(collections.games.findOne).mockResolvedValue(null as never) + await feed('07/13/2026 - 17:00:00: World triggered "Round_Start"') + expect(collections.gamesLogParseState.findOne).not.toHaveBeenCalled() + expect(collections.gamesLogParseState.updateOne).not.toHaveBeenCalled() + }) + + it('does not re-query the database for a still-unknown game within the retry window', async () => { + vi.mocked(collections.games.findOne).mockResolvedValue(null as never) + await feed('a log line') + await feed('another log line') + expect(collections.games.findOne).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/games/plugins/parse-game-log.ts b/src/games/plugins/parse-game-log.ts new file mode 100644 index 000000000..3c709ae06 --- /dev/null +++ b/src/games/plugins/parse-game-log.ts @@ -0,0 +1,191 @@ +import fp from 'fastify-plugin' +import { ValueType } from '@opentelemetry/api' +import { minutesToMilliseconds } from 'date-fns' +import { events } from '../../events' +import { collections } from '../../database/collections' +import { logger } from '../../logger' +import { meter } from '../../otel' +import { Tf2GameAnalyzer } from '../../tf2-game-analyzer/tf2-game-analyzer' +import type { LogEvent } from '../../tf2-game-analyzer/log-event' +import type { GameNumber } from '../../database/models/game.model' +import { Tf2Team } from '../../shared/types/tf2-team' +import { GameEventType } from '../../database/models/game-event.model' +import { update } from '../update' + +export default fp( + // eslint-disable-next-line @typescript-eslint/require-await + async () => { + const eventCounter = meter.createCounter('tf2pickup.games.events.count', { + description: 'Game events that come from the gameserver', + unit: '1', + valueType: ValueType.INT, + }) + + const queues = new Map>() + function enqueue(logSecret: string, operation: () => Promise): void { + const previous = queues.get(logSecret) ?? Promise.resolve() + const current = previous.then(operation).catch((error: unknown) => { + logger.error(error, 'error parsing game log line') + }) + queues.set(logSecret, current) + } + + const gameNumbers = new Map() // logSecret -> game number + const unknownUntil = new Map() // logSecret -> epoch ms to retry a miss + const analyzers = new Map() + + async function resolveGameNumber(logSecret: string): Promise { + const cached = gameNumbers.get(logSecret) + if (cached !== undefined) { + return cached + } + const retryAt = unknownUntil.get(logSecret) + if (retryAt !== undefined && retryAt > Date.now()) { + return null + } + const game = await collections.games.findOne({ logSecret }, { projection: { number: 1 } }) + if (game === null) { + unknownUntil.set(logSecret, Date.now() + minutesToMilliseconds(1)) + return null + } + unknownUntil.delete(logSecret) + gameNumbers.set(logSecret, game.number) + return game.number + } + + async function loadAnalyzer(gameNumber: GameNumber): Promise { + const cached = analyzers.get(gameNumber) + if (cached) { + return cached + } + const state = await collections.gamesLogParseState.findOne({ gameNumber }) + const analyzer = new Tf2GameAnalyzer(state?.context) + analyzers.set(gameNumber, analyzer) + return analyzer + } + + async function apply(gameNumber: GameNumber, event: LogEvent): Promise { + switch (event.event) { + case 'round started': + events.emit('match:started', { gameNumber }) + break + case 'round ended': + logger.info({ gameNumber, ...event }, 'round ended') + await update( + { number: gameNumber }, + { + $set: { + 'score.blu': event.score[Tf2Team.blu], + 'score.red': event.score[Tf2Team.red], + }, + $push: { + events: { + at: new Date(), + event: GameEventType.roundEnded, + winner: event.winner, + lengthMs: event.lengthMs, + score: { + [Tf2Team.red]: event.score[Tf2Team.red], + [Tf2Team.blu]: event.score[Tf2Team.blu], + }, + captures: event.captures, + }, + }, + }, + ) + break + case 'teams swapped': + logger.info({ gameNumber }, 'teams swapped sides') + await update( + { number: gameNumber }, + { $push: { events: { at: new Date(), event: GameEventType.teamsSwapped } } }, + ) + break + case 'score reset': + events.emit('match/score:reset', { gameNumber }) + break + case 'match ended': + events.emit('match:ended', { gameNumber }) + break + case 'final score': + events.emit('match/score:final', { gameNumber, team: event.team, score: event.score }) + break + case 'player connected': + events.emit('match/player:connected', { + gameNumber, + steamId: event.steamId, + ipAddress: event.ipAddress, + }) + break + case 'player joined team': + events.emit('match/player:joinedTeam', { + gameNumber, + steamId: event.steamId, + team: event.team, + }) + break + case 'player disconnected': + events.emit('match/player:disconnected', { gameNumber, steamId: event.steamId }) + break + case 'player said': + events.emit('match/player:said', { + gameNumber, + steamId: event.steamId, + message: event.message, + }) + break + case 'logs uploaded': + events.emit('match/logs:uploaded', { gameNumber, logsUrl: event.logsUrl }) + break + case 'demo uploaded': + events.emit('match/demo:uploaded', { gameNumber, demoUrl: event.demoUrl }) + break + } + } + + events.on('gamelog:message', ({ message }) => { + enqueue(message.password, async () => { + const gameNumber = await resolveGameNumber(message.password) + if (gameNumber === null) { + eventCounter.add(1, { 'tf2pickup.games.event.handled': false }) + return + } + + const analyzer = await loadAnalyzer(gameNumber) + const logEvents = analyzer.parseLine(message.payload) + + if (analyzer.contextDirty()) { + await collections.gamesLogParseState.updateOne( + { gameNumber }, + { $set: { context: analyzer.context, at: new Date() } }, + { upsert: true }, + ) + analyzer.markPersisted() + } + + eventCounter.add(1, { + 'tf2pickup.games.event.handled': logEvents.length > 0, + 'tf2pickup.game.number': gameNumber, + }) + + for (const event of logEvents) { + await apply(gameNumber, event) + } + }) + }) + + // Events keep arriving after a game ends (logs.tf and demos.tf uploads), so + // free the in-memory caches only after a grace period; the database document + // is left for the TTL index to sweep. + events.on('game:ended', ({ game }) => { + setTimeout(() => { + analyzers.delete(game.number) + if (game.logSecret) { + gameNumbers.delete(game.logSecret) + queues.delete(game.logSecret) + } + }, minutesToMilliseconds(10)).unref() + }) + }, + { name: 'parse game log', encapsulate: true }, +) diff --git a/src/games/plugins/track-match-rounds.test.ts b/src/games/plugins/track-match-rounds.test.ts deleted file mode 100644 index 21dbe1f0f..000000000 --- a/src/games/plugins/track-match-rounds.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' - -vi.mock('fastify-plugin', () => ({ - default: (fn: T): T => fn, -})) - -vi.mock('../../events', () => ({ - events: { - on: vi.fn(), - emit: vi.fn(), - }, -})) - -vi.mock('../../logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - }, -})) - -vi.mock('../update', () => ({ - update: vi.fn(), -})) - -vi.mock('../find-one', () => ({ - findOne: vi.fn(), -})) - -// A tiny in-memory stand-in for the games.roundprogress collection, supporting -// just the MongoDB operators track-match-rounds relies on ($set/$push/$unset -// with dotted paths, $exists filters, and findOneAndUpdate's before-image). This -// lets the test drive the real claim/commit logic without a live database. -const { roundProgressStore, gamesRoundProgress } = vi.hoisted(() => { - type Doc = Record - const store = new Map() - - const getPath = (obj: Doc, path: string): unknown => - path.split('.').reduce((o, k) => (o == null ? undefined : (o as Doc)[k]), obj) - - const setPath = (obj: Doc, path: string, value: unknown) => { - const keys = path.split('.') - let o = obj - for (const k of keys.slice(0, -1)) { - o[k] ??= {} - o = o[k] as Doc - } - o[keys[keys.length - 1]!] = value - } - - const unsetPath = (obj: Doc, path: string) => { - const keys = path.split('.') - let o: Doc | undefined = obj - for (const k of keys.slice(0, -1)) { - o = o?.[k] as Doc | undefined - } - if (o) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete o[keys[keys.length - 1]!] - } - } - - const matches = (doc: Doc, filter: Doc): boolean => - Object.entries(filter).every(([key, expected]) => { - const actual = getPath(doc, key) - if (expected && typeof expected === 'object' && '$exists' in expected) { - return (actual !== undefined) === (expected as { $exists: boolean }).$exists - } - return actual === expected - }) - - const applyUpdate = (doc: Doc, update: Doc) => { - for (const [k, v] of Object.entries((update['$set'] as Doc) ?? {})) { - setPath(doc, k, v) - } - for (const [k, v] of Object.entries((update['$push'] as Doc) ?? {})) { - setPath(doc, k, [...((getPath(doc, k) as unknown[]) ?? []), v]) - } - for (const k of Object.keys((update['$unset'] as Doc) ?? {})) { - unsetPath(doc, k) - } - } - - const find = (filter: Doc): Doc | undefined => - [...store.values()].find(doc => matches(doc, filter)) - - return { - roundProgressStore: store, - gamesRoundProgress: { - updateOne: (filter: Doc, update: Doc, options?: { upsert?: boolean }) => { - let doc = find(filter) - if (!doc) { - if (!options?.upsert) { - return Promise.resolve() - } - doc = { gameNumber: filter['gameNumber'] } - store.set(filter['gameNumber'] as number, doc) - } - applyUpdate(doc, update) - return Promise.resolve() - }, - findOneAndUpdate: (filter: Doc, update: Doc, options?: { returnDocument?: string }) => { - const doc = find(filter) - if (!doc) { - return Promise.resolve(null) - } - const before = structuredClone(doc) - applyUpdate(doc, update) - return Promise.resolve(options?.returnDocument === 'before' ? before : doc) - }, - deleteOne: (filter: Doc) => { - store.delete(filter['gameNumber'] as number) - return Promise.resolve() - }, - }, - } -}) - -vi.mock('../../database/collections', () => ({ - collections: { gamesRoundProgress }, -})) - -import { events } from '../../events' -import { update } from '../update' -import { findOne } from '../find-one' -import { Tf2Team } from '../../shared/types/tf2-team' -import { GameEventType } from '../../database/models/game-event.model' -import plugin from './track-match-rounds' -import type { GameNumber } from '../../database/models/game.model' - -const gameNumber = 2784 as GameNumber - -// grab the handler registered for a given event via events.on() -type Handler = (params: never) => void | Promise -function handlerFor(event: string): Handler { - const call = vi - .mocked(events.on) - .mock.calls.find(([name]: [string, ...unknown[]]) => name === event) - return call![1] as Handler -} - -describe('track-match-rounds', () => { - // feeds a complete round (winner + length + both reported scores) and the - // control points captured by each team, then ends the round - async function playRound(opts: { - winner: Tf2Team - score: { blu: number; red: number } - captures: { blu: number[]; red: number[] } - }) { - for (const cp of opts.captures.blu) { - await handlerFor('match/controlPoint:captured')({ - gameNumber, - team: Tf2Team.blu, - controlPoint: cp, - } as never) - } - for (const cp of opts.captures.red) { - await handlerFor('match/controlPoint:captured')({ - gameNumber, - team: Tf2Team.red, - controlPoint: cp, - } as never) - } - await handlerFor('match:roundWon')({ gameNumber, winner: opts.winner } as never) - await handlerFor('match:roundLength')({ gameNumber, lengthMs: 300_000 } as never) - await handlerFor('match/score:reported')({ - gameNumber, - teamName: Tf2Team.blu, - score: opts.score.blu, - } as never) - await handlerFor('match/score:reported')({ - gameNumber, - teamName: Tf2Team.red, - score: opts.score.red, - } as never) - } - - beforeEach(async () => { - vi.resetAllMocks() - roundProgressStore.clear() - vi.mocked(update).mockResolvedValue({} as never) - await (plugin as unknown as () => Promise)() - }) - - it('commits a roundEnded event once the round is complete', async () => { - vi.mocked(findOne).mockResolvedValue({ score: { [Tf2Team.blu]: 0, [Tf2Team.red]: 0 } } as never) - await playRound({ - winner: Tf2Team.blu, - score: { blu: 4, red: 0 }, - captures: { blu: [0, 1, 2, 3], red: [] }, - }) - - expect(update).toHaveBeenCalledWith( - { number: gameNumber }, - expect.objectContaining({ - $set: { 'score.blu': 4, 'score.red': 0 }, - $push: { - events: expect.objectContaining({ - event: GameEventType.roundEnded, - winner: Tf2Team.blu, - lengthMs: 300_000, - score: { [Tf2Team.blu]: 4, [Tf2Team.red]: 0 }, - captures: { [Tf2Team.blu]: [0, 1, 2, 3], [Tf2Team.red]: [] }, - }), - }, - }), - ) - }) - - it('commits the round exactly once even though several events complete it', async () => { - vi.mocked(findOne).mockResolvedValue({ score: { [Tf2Team.blu]: 0, [Tf2Team.red]: 0 } } as never) - await playRound({ - winner: Tf2Team.blu, - score: { blu: 4, red: 0 }, - captures: { blu: [0, 1, 2, 3], red: [] }, - }) - - const roundEndedCalls = vi - .mocked(update) - .mock.calls.filter( - ([, u]: [unknown, unknown]) => - (u as { $push?: { events?: { event?: string } } }).$push?.events?.event === - GameEventType.roundEnded, - ) - expect(roundEndedCalls).toHaveLength(1) - }) - - it('emits a teams swapped event when a stopwatch round ends and the next round starts', async () => { - // attack/defend round: blu caps 4 control points, score jumps 0 -> 4 - vi.mocked(findOne).mockResolvedValue({ score: { [Tf2Team.blu]: 0, [Tf2Team.red]: 0 } } as never) - await playRound({ - winner: Tf2Team.blu, - score: { blu: 4, red: 0 }, - captures: { blu: [0, 1, 2, 3], red: [] }, - }) - - // the swap is only recorded once the next round actually starts - expect(update).not.toHaveBeenCalledWith( - { number: gameNumber }, - { $push: { events: { at: expect.any(Date), event: GameEventType.teamsSwapped } } }, - ) - - await handlerFor('match:started')({ gameNumber } as never) - - expect(update).toHaveBeenCalledWith( - { number: gameNumber }, - { $push: { events: { at: expect.any(Date), event: GameEventType.teamsSwapped } } }, - ) - }) - - it('does not emit a teams swapped event on a cp/koth round', async () => { - // both teams capture and the score only grows by 1 (round counter) - vi.mocked(findOne).mockResolvedValue({ score: { [Tf2Team.blu]: 0, [Tf2Team.red]: 0 } } as never) - await playRound({ - winner: Tf2Team.blu, - score: { blu: 1, red: 0 }, - captures: { blu: [0, 1], red: [2, 3] }, - }) - - await handlerFor('match:started')({ gameNumber } as never) - - expect(update).not.toHaveBeenCalledWith( - { number: gameNumber }, - { $push: { events: { at: expect.any(Date), event: GameEventType.teamsSwapped } } }, - ) - }) - - it('does not emit a teams swapped event when no control points were captured', async () => { - // a score jump > 1 without any captures (e.g. ctf/bball) is not a swap - vi.mocked(findOne).mockResolvedValue({ score: { [Tf2Team.blu]: 0, [Tf2Team.red]: 0 } } as never) - await playRound({ - winner: Tf2Team.blu, - score: { blu: 3, red: 0 }, - captures: { blu: [], red: [] }, - }) - - await handlerFor('match:started')({ gameNumber } as never) - - expect(update).not.toHaveBeenCalledWith( - { number: gameNumber }, - { $push: { events: { at: expect.any(Date), event: GameEventType.teamsSwapped } } }, - ) - }) - - it('does not emit a trailing swap when the match ends instead of starting a new round', async () => { - vi.mocked(findOne).mockResolvedValue({ score: { [Tf2Team.blu]: 0, [Tf2Team.red]: 0 } } as never) - await playRound({ - winner: Tf2Team.blu, - score: { blu: 4, red: 0 }, - captures: { blu: [0, 1, 2, 3], red: [] }, - }) - - // match ends; the pending swap must be discarded, not flushed on a later start - await handlerFor('match:ended')({ gameNumber } as never) - await handlerFor('match:started')({ gameNumber } as never) - - expect(update).not.toHaveBeenCalledWith( - { number: gameNumber }, - { $push: { events: { at: expect.any(Date), event: GameEventType.teamsSwapped } } }, - ) - }) -}) diff --git a/src/games/plugins/track-match-rounds.ts b/src/games/plugins/track-match-rounds.ts deleted file mode 100644 index f97b5dec6..000000000 --- a/src/games/plugins/track-match-rounds.ts +++ /dev/null @@ -1,170 +0,0 @@ -import fp from 'fastify-plugin' -import { MongoError, type UpdateFilter } from 'mongodb' -import { Tf2Team } from '../../shared/types/tf2-team' -import type { GameNumber } from '../../database/models/game.model' -import type { GameRoundProgressModel } from '../../database/models/game-round-progress.model' -import { events } from '../../events' -import { logger } from '../../logger' -import { update } from '../update' -import { GameEventType } from '../../database/models/game-event.model' -import { findOne } from '../find-one' -import { isStopwatchRound } from '../is-stopwatch-round' -import { collections } from '../../database/collections' - -// Upserting into the progress document races on its unique gameNumber index: -// when two log lines for a brand-new game arrive near-simultaneously, both -// upserts try to insert and one fails with a duplicate key error. Retrying once -// succeeds, since the document now exists (same pattern as game-log-sink). -async function upsertRoundProgress( - gameNumber: GameNumber, - patch: UpdateFilter, -) { - try { - await collections.gamesRoundProgress.updateOne({ gameNumber }, patch, { upsert: true }) - } catch (error) { - if (error instanceof MongoError && error.code === 11000) { - await collections.gamesRoundProgress.updateOne({ gameNumber }, patch, { upsert: true }) - } else { - throw error - } - } -} - -// TF2 reports a round's outcome across several log lines that may arrive in any -// order — Round_Win, Round_Length and the per-team score. We accumulate them in -// the games.roundprogress collection (instead of in memory) so that a partially -// observed round, and the pending side-swap on stopwatch maps, survive an app -// restart. A round is committed as a `roundEnded` game event once complete. -export default fp( - // eslint-disable-next-line @typescript-eslint/require-await - async () => { - // Atomically claim a complete round: clear it from the progress document and - // return its pre-clear snapshot. Only the caller that observes the round as - // complete gets a non-null result, so the round is committed exactly once - // even if several log lines complete it near-simultaneously. - async function maybeRoundEnded(gameNumber: GameNumber) { - const claimed = await collections.gamesRoundProgress.findOneAndUpdate( - { - gameNumber, - 'round.winner': { $exists: true }, - 'round.lengthMs': { $exists: true }, - 'round.score.red': { $exists: true }, - 'round.score.blu': { $exists: true }, - }, - { $unset: { round: '' } }, - { returnDocument: 'before' }, - ) - - const round = claimed?.round - if ( - round?.winner === undefined || - round.lengthMs === undefined || - round.score?.blu === undefined || - round.score.red === undefined - ) { - return - } - - const { winner, lengthMs } = round - const score = { [Tf2Team.blu]: round.score.blu, [Tf2Team.red]: round.score.red } - const captures = { - [Tf2Team.blu]: round.captures?.[Tf2Team.blu] ?? [], - [Tf2Team.red]: round.captures?.[Tf2Team.red] ?? [], - } - logger.info({ gameNumber, winner, lengthMs, score, captures }, `round ended`) - - const game = await findOne({ number: gameNumber }, ['score']) - if (score.red === game.score?.red && score.blu === game.score.blu) { - logger.info(`score is the same, not updating`) - return - } - - // On stopwatch (attack/defend & payload) rounds TF2 switches the teams' - // sides afterwards. We defer the swap event to the next round start so the - // final round doesn't produce a trailing swap. - if ( - isStopwatchRound({ - previousScore: { - [Tf2Team.blu]: game.score?.blu ?? 0, - [Tf2Team.red]: game.score?.red ?? 0, - }, - score, - captures, - }) - ) { - await upsertRoundProgress(gameNumber, { $set: { swapPending: true } }) - } - - await update( - { number: gameNumber }, - { - $set: { - 'score.blu': score.blu, - 'score.red': score.red, - }, - $push: { - events: { - at: new Date(), - event: GameEventType.roundEnded, - winner, - lengthMs, - score: { - [Tf2Team.red]: score.red, - [Tf2Team.blu]: score.blu, - }, - captures, - }, - }, - }, - ) - } - - events.on('match:roundWon', async ({ gameNumber, winner }) => { - await upsertRoundProgress(gameNumber, { $set: { 'round.winner': winner } }) - await maybeRoundEnded(gameNumber) - }) - - events.on('match:roundLength', async ({ gameNumber, lengthMs }) => { - await upsertRoundProgress(gameNumber, { $set: { 'round.lengthMs': lengthMs } }) - await maybeRoundEnded(gameNumber) - }) - - events.on('match/score:reported', async ({ gameNumber, teamName, score }) => { - await upsertRoundProgress(gameNumber, { $set: { [`round.score.${teamName}`]: score } }) - await maybeRoundEnded(gameNumber) - }) - - events.on('match/controlPoint:captured', async ({ gameNumber, team, controlPoint }) => { - await upsertRoundProgress(gameNumber, { $push: { [`round.captures.${team}`]: controlPoint } }) - }) - - events.on('match:started', async ({ gameNumber }) => { - const before = await collections.gamesRoundProgress.findOneAndUpdate( - { gameNumber, swapPending: true }, - { $set: { swapPending: false } }, - ) - if (!before) { - return - } - logger.info({ gameNumber }, 'teams swapped sides') - await update( - { number: gameNumber }, - { $push: { events: { at: new Date(), event: GameEventType.teamsSwapped } } }, - ) - }) - - events.on('match:ended', async ({ gameNumber }) => { - await collections.gamesRoundProgress.deleteOne({ gameNumber }) - }) - - // a match restart aborts the round in progress; drop any partially - // observed round data so it doesn't leak into the first post-restart round - events.on('match/score:reset', async ({ gameNumber }) => { - await collections.gamesRoundProgress.deleteOne({ gameNumber }) - }) - }, - { - name: 'track match rounds', - encapsulate: true, - }, -) diff --git a/src/tf2-game-analyzer/analyze.test.ts b/src/tf2-game-analyzer/analyze.test.ts new file mode 100644 index 000000000..d58af709a --- /dev/null +++ b/src/tf2-game-analyzer/analyze.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { analyze } from './analyze' +import { createGameContext } from './create-game-context' +import type { GameContext } from './game-context' +import type { LogEvent } from './log-event' + +const logSecretLine = (timestamp: string, rest: string) => `${timestamp}: ${rest}` +const names = (events: LogEvent[]) => events.map(e => e.event) + +describe('analyze', () => { + let context: GameContext + + beforeEach(() => { + context = createGameContext() + }) + + const roundStart = (timestamp: string) => `${timestamp}: World triggered "Round_Start"` + + describe('restart detection', () => { + it('emits "round started" for a regular round start', () => { + expect(analyze(context, roundStart('07/13/2026 - 17:44:53'))).toEqual([ + { event: 'round started' }, + ]) + }) + + it('emits "score reset" for a doubled round start mid-game', () => { + // the real sequence of https://tf2pickup.pl/games/7615: the round started + // at 17:35:10 was aborted and the match was restarted at 17:39:30 + analyze(context, roundStart('07/13/2026 - 17:35:10')) + analyze(context, roundStart('07/13/2026 - 17:39:30')) + const events = analyze(context, roundStart('07/13/2026 - 17:39:30')) + expect(names(events)).toContain('score reset') + }) + + it('does not emit "score reset" for the doubled round start at the initial match start', () => { + analyze(context, roundStart('07/13/2026 - 17:33:28')) + const events = analyze(context, roundStart('07/13/2026 - 17:33:28')) + expect(names(events)).not.toContain('score reset') + }) + + it('does not emit "score reset" for round starts at different times', () => { + analyze(context, roundStart('07/13/2026 - 18:01:44')) + const events = analyze(context, roundStart('07/13/2026 - 18:07:40')) + expect(names(events)).not.toContain('score reset') + }) + + it('does not emit "score reset" when a round ended in a stalemate in between', () => { + analyze(context, roundStart('05/16/2026 - 16:46:17')) + analyze(context, logSecretLine('05/16/2026 - 16:46:17', 'World triggered "Round_Stalemate"')) + const events = analyze(context, roundStart('05/16/2026 - 16:46:17')) + expect(names(events)).not.toContain('score reset') + }) + + it('emits "score reset" for a doubled round start straddling a second boundary mid-game', () => { + analyze(context, roundStart('07/13/2026 - 17:30:00')) + analyze(context, roundStart('07/13/2026 - 17:35:10')) + const events = analyze(context, roundStart('07/13/2026 - 17:35:11')) + expect(names(events)).toContain('score reset') + }) + + it('does not emit "score reset" for the initial doubled round start straddling a second boundary', () => { + // real case: https://logs.tf/4084159 + analyze(context, roundStart('07/13/2026 - 16:35:01')) + const events = analyze(context, roundStart('07/13/2026 - 16:35:02')) + expect(names(events)).not.toContain('score reset') + }) + + it('does not emit "score reset" when a round was won in between', () => { + analyze(context, roundStart('06/16/2026 - 10:38:23')) + analyze( + context, + logSecretLine('06/16/2026 - 10:38:23', 'World triggered "Round_Win" (winner "Blue")'), + ) + const events = analyze(context, roundStart('06/16/2026 - 10:38:23')) + expect(names(events)).not.toContain('score reset') + }) + }) + + describe('round assembly', () => { + const at = '07/13/2026 - 17:05:00' + + it('assembles a round from separate lines into "round ended"', () => { + analyze(context, roundStart('07/13/2026 - 17:00:00')) + analyze(context, logSecretLine(at, 'World triggered "Round_Win" (winner "Blue")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Length" (seconds "300.5")')) + analyze(context, logSecretLine(at, 'Team "Blue" current score "1" with "6" players')) + const events = analyze( + context, + logSecretLine(at, 'Team "Red" current score "0" with "6" players'), + ) + expect(events).toEqual([ + { + event: 'round ended', + winner: 'blu', + lengthMs: 300500, + score: { red: 0, blu: 1 }, + captures: { red: [], blu: [] }, + }, + ]) + }) + + it('does not emit "round ended" until the round is complete', () => { + analyze(context, logSecretLine(at, 'World triggered "Round_Win" (winner "Blue")')) + const events = analyze( + context, + logSecretLine(at, 'Team "Blue" current score "1" with "6" players'), + ) + expect(events).toEqual([]) + }) + + it('tracks the running score across rounds', () => { + const endRound = (winner: string, red: number, blu: number) => { + analyze(context, logSecretLine(at, `World triggered "Round_Win" (winner "${winner}")`)) + analyze(context, logSecretLine(at, 'World triggered "Round_Length" (seconds "120")')) + analyze(context, logSecretLine(at, `Team "Blue" current score "${blu}" with "6" players`)) + return analyze( + context, + logSecretLine(at, `Team "Red" current score "${red}" with "6" players`), + ) + } + endRound('Blue', 0, 1) + const events = endRound('Red', 1, 1) + expect(events).toEqual([ + { + event: 'round ended', + winner: 'red', + lengthMs: 120000, + score: { red: 1, blu: 1 }, + captures: { red: [], blu: [] }, + }, + ]) + }) + }) + + describe('stopwatch side-swap', () => { + const at = '07/13/2026 - 17:05:00' + + it('emits "teams swapped" at the next round start after a stopwatch round', () => { + analyze(context, roundStart('07/13/2026 - 17:00:00')) + analyze(context, logSecretLine(at, 'Team "Blue" triggered "pointcaptured" (cp "1")')) + analyze(context, logSecretLine(at, 'Team "Blue" triggered "pointcaptured" (cp "2")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Win" (winner "Blue")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Length" (seconds "120")')) + analyze(context, logSecretLine(at, 'Team "Blue" current score "2" with "6" players')) + const ended = analyze( + context, + logSecretLine(at, 'Team "Red" current score "0" with "6" players'), + ) + expect(names(ended)).toContain('round ended') + + const started = analyze(context, roundStart('07/13/2026 - 17:10:00')) + expect(names(started)).toEqual(['teams swapped', 'round started']) + }) + + it('does not swap on a cp/koth round (no captures, score grows by one)', () => { + analyze(context, roundStart('07/13/2026 - 17:00:00')) + analyze(context, logSecretLine(at, 'World triggered "Round_Win" (winner "Blue")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Length" (seconds "120")')) + analyze(context, logSecretLine(at, 'Team "Blue" current score "1" with "6" players')) + analyze(context, logSecretLine(at, 'Team "Red" current score "0" with "6" players')) + + const started = analyze(context, roundStart('07/13/2026 - 17:10:00')) + expect(names(started)).toEqual(['round started']) + }) + + it('clears a pending swap on Game_Over so the next map does not swap', () => { + analyze(context, roundStart('07/13/2026 - 17:00:00')) + analyze(context, logSecretLine(at, 'Team "Blue" triggered "pointcaptured" (cp "1")')) + analyze(context, logSecretLine(at, 'Team "Blue" triggered "pointcaptured" (cp "2")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Win" (winner "Blue")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Length" (seconds "120")')) + analyze(context, logSecretLine(at, 'Team "Blue" current score "2" with "6" players')) + analyze(context, logSecretLine(at, 'Team "Red" current score "0" with "6" players')) + analyze(context, logSecretLine(at, 'World triggered "Game_Over" reason "test"')) + + const started = analyze(context, roundStart('07/13/2026 - 18:00:00')) + expect(names(started)).toEqual(['round started']) + }) + }) + + describe('idempotency', () => { + const at = '07/13/2026 - 17:05:00' + + it('does not re-emit "round ended" when the final score line repeats', () => { + analyze(context, roundStart('07/13/2026 - 17:00:00')) + analyze(context, logSecretLine(at, 'World triggered "Round_Win" (winner "Blue")')) + analyze(context, logSecretLine(at, 'World triggered "Round_Length" (seconds "120")')) + analyze(context, logSecretLine(at, 'Team "Blue" current score "1" with "6" players')) + const first = analyze( + context, + logSecretLine(at, 'Team "Red" current score "0" with "6" players'), + ) + expect(names(first)).toContain('round ended') + + // the same cumulative score arriving again must not commit a second round + const repeat = analyze( + context, + logSecretLine(at, 'Team "Blue" current score "1" with "6" players'), + ) + expect(names(repeat)).not.toContain('round ended') + }) + }) + + describe('player and upload events', () => { + it('emits "player connected"', () => { + const line = + '07/13/2026 - 17:00:00: "foo<12><[U:1:1234567]><>" connected, address "1.2.3.4:27005"' + const events = analyze(context, line) + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ event: 'player connected', ipAddress: '1.2.3.4' }) + expect((events[0] as { steamId: string }).steamId).toMatch(/^7656\d+$/) + }) + + it('emits "player joined team"', () => { + const line = '07/13/2026 - 17:00:00: "foo<12><[U:1:1234567]>" joined team "Red"' + const events = analyze(context, line) + expect(events[0]).toMatchObject({ event: 'player joined team', team: 'red' }) + }) + + it('emits "player disconnected"', () => { + const line = + '07/13/2026 - 17:00:00: "foo<12><[U:1:1234567]>" disconnected (reason "Disconnect")' + const events = analyze(context, line) + expect(events[0]).toMatchObject({ event: 'player disconnected' }) + }) + + it('emits "player said"', () => { + const line = '07/13/2026 - 17:00:00: "foo<12><[U:1:1234567]>" say "gg"' + const events = analyze(context, line) + expect(events[0]).toMatchObject({ event: 'player said', message: 'gg' }) + }) + + it('emits "final score"', () => { + const line = '07/13/2026 - 17:00:00: Team "Red" final score "3" with "6" players' + expect(analyze(context, line)).toEqual([{ event: 'final score', team: 'red', score: 3 }]) + }) + + it('emits "match ended" for Game_Over', () => { + const line = '07/13/2026 - 17:00:00: World triggered "Game_Over" reason "test"' + expect(analyze(context, line)).toEqual([{ event: 'match ended' }]) + }) + + it('emits "logs uploaded"', () => { + const line = + '07/13/2026 - 17:00:00: [TFTrue] The logs are available here: http://logs.tf/123456. Blah' + expect(analyze(context, line)).toEqual([ + { event: 'logs uploaded', logsUrl: 'http://logs.tf/123456' }, + ]) + }) + + it('emits "demo uploaded"', () => { + const line = '07/13/2026 - 17:00:00: [demos.tf]: STV available at: https://demos.tf/123' + expect(analyze(context, line)).toEqual([ + { event: 'demo uploaded', demoUrl: 'https://demos.tf/123' }, + ]) + }) + + it('returns [] for an unrecognized line', () => { + expect(analyze(context, '07/13/2026 - 17:00:00: some random log line')).toEqual([]) + }) + }) +}) diff --git a/src/tf2-game-analyzer/analyze.ts b/src/tf2-game-analyzer/analyze.ts new file mode 100644 index 000000000..68cda24c1 --- /dev/null +++ b/src/tf2-game-analyzer/analyze.ts @@ -0,0 +1,298 @@ +import { differenceInSeconds, parse } from 'date-fns' +import SteamID from 'steamid' +import { Tf2Team } from '../shared/types/tf2-team' +import type { SteamId64 } from '../shared/types/steam-id-64' +// TODO: move is-stopwatch-round into this module when tf2-game-analyzer is extracted; +// for now we reuse the app's copy. +import { isStopwatchRound } from '../games/is-stopwatch-round' +import type { GameContext } from './game-context' +import type { LogEvent } from './log-event' + +// converts 'Red' and 'Blue' to valid team names +const fixTeamName = (teamName: string): Tf2Team => teamName.toLowerCase().substring(0, 3) as Tf2Team + +const parseLogTimestamp = (timestamp: string) => + parse(timestamp, 'MM/dd/yyyy - HH:mm:ss', new Date()) + +function ensureRound(context: GameContext): NonNullable { + context.round ??= { captures: { [Tf2Team.red]: [], [Tf2Team.blu]: [] } } + return context.round +} + +// Commit the round in progress if all its parts have arrived (winner, length and +// both teams' scores). Returns the `round ended` event, or nothing if the round +// is still incomplete or its score matches what we already have. +function maybeRoundEnded(context: GameContext): LogEvent[] { + const round = context.round + if (!round) { + return [] + } + const blu = round.score?.[Tf2Team.blu] + const red = round.score?.[Tf2Team.red] + if ( + round.winner === undefined || + round.lengthMs === undefined || + blu === undefined || + red === undefined + ) { + return [] + } + + const { winner, lengthMs } = round + const score: Record = { [Tf2Team.blu]: blu, [Tf2Team.red]: red } + const captures: Record = { + [Tf2Team.blu]: round.captures[Tf2Team.blu], + [Tf2Team.red]: round.captures[Tf2Team.red], + } + + // the round is consumed whether or not it changes the score + context.round = undefined + + if ( + score[Tf2Team.red] === context.score[Tf2Team.red] && + score[Tf2Team.blu] === context.score[Tf2Team.blu] + ) { + return [] + } + + // On stopwatch (attack/defend & payload) maps TF2 switches the teams' sides + // afterwards. Defer the swap to the next round start so the final round + // doesn't produce a trailing swap. + if (isStopwatchRound({ previousScore: context.score, score, captures })) { + context.swapPending = true + } + + context.score = { [Tf2Team.red]: score[Tf2Team.red], [Tf2Team.blu]: score[Tf2Team.blu] } + + return [{ event: 'round ended', winner, lengthMs, score, captures }] +} + +interface Matcher { + regex: RegExp + handle: (context: GameContext, matches: RegExpMatchArray) => LogEvent[] +} + +const matchers: Matcher[] = [ + { + // TF2 logs Round_Start once per regular round, but twice within the same + // second (occasionally straddling a second boundary) when a tournament match + // (re)starts. A regular round transition always has a Round_Win or a + // Round_Stalemate between two Round_Starts (both clear the remembered line), + // so a pair ≤1s apart can only be the (re)start doubling. The pair at the + // initial match start is expected; a pair preceded by an earlier Round_Start + // means the match was restarted mid-game and the server reset its scoreboard. + regex: /^(\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}):\sWorld triggered "Round_Start"$/, + handle: (context, matches) => { + const events: LogEvent[] = [] + if (context.swapPending) { + context.swapPending = false + events.push({ event: 'teams swapped' }) + } + events.push({ event: 'round started' }) + + if (matches[1]) { + const at = parseLogTimestamp(matches[1]) + const last = context.lastRoundStart + if (last && Math.abs(differenceInSeconds(at, new Date(last.at))) <= 1) { + if (last.precededByRoundStart) { + context.lastRoundStart = undefined + context.round = undefined + context.swapPending = false + context.score = { [Tf2Team.red]: 0, [Tf2Team.blu]: 0 } + events.push({ event: 'score reset' }) + } + } else { + context.lastRoundStart = { + at: at.toISOString(), + precededByRoundStart: context.seenRoundStart, + } + } + } + context.seenRoundStart = true + return events + }, + }, + { + regex: + /^\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}:\sWorld triggered "Round_Win" \(winner "(.+)"\)$/, + handle: (context, matches) => { + context.lastRoundStart = undefined + if (!matches[1]) { + return [] + } + ensureRound(context).winner = fixTeamName(matches[1]) + return maybeRoundEnded(context) + }, + }, + { + // a stalemate ends a round with no Round_Win line; clear the remembered + // Round_Start so the next one is not mistaken for a restart doubling + regex: /^\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}:\sWorld triggered "Round_Stalemate"$/, + handle: context => { + context.lastRoundStart = undefined + return [] + }, + }, + { + // payload/attack-defend maps emit "Mini_Round_Length" instead of "Round_Length" + regex: + /^\d{2}\/\d{2}\/\d{4}\s-\s\d{2}:\d{2}:\d{2}:\sWorld triggered "(?:Mini_)?Round_Length" \(seconds "([\d.]+)"\)$/, + handle: (context, matches) => { + if (!matches[1]) { + return [] + } + ensureRound(context).lengthMs = parseFloat(matches[1]) * 1000 + return maybeRoundEnded(context) + }, + }, + { + regex: /^[\d/\s-:]+World triggered "Game_Over" reason ".*"$/, + handle: context => { + context.lastRoundStart = undefined + context.seenRoundStart = false + context.round = undefined + context.swapPending = false + return [{ event: 'match ended' }] + }, + }, + { + regex: /^[\d/\s-:]+\[TFTrue\].+\shttp:\/\/logs\.tf\/(\d+)\..*$/, + handle: (_context, matches) => { + if (!matches[1]) { + return [] + } + return [{ event: 'logs uploaded', logsUrl: `http://logs.tf/${matches[1]}` }] + }, + }, + { + regex: + /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><>"\sconnected,\saddress\s"(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}):(\d{1,5})"$/, + handle: (_context, matches) => { + if (!matches[5] || !matches[6]) { + return [] + } + const steamId = new SteamID(matches[5]) + if (!steamId.isValid()) { + return [] + } + return [ + { + event: 'player connected', + steamId: steamId.getSteamID64() as SteamId64, + ipAddress: matches[6], + }, + ] + }, + }, + { + regex: + /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><(.+)>"\sjoined\steam\s"(.+)"/, + handle: (_context, matches) => { + if (!matches[5] || !matches[7]) { + return [] + } + const steamId = new SteamID(matches[5]) + if (!steamId.isValid()) { + return [] + } + return [ + { + event: 'player joined team', + steamId: steamId.getSteamID64() as SteamId64, + team: fixTeamName(matches[7]), + }, + ] + }, + }, + { + regex: + /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><(.[^>]+)>"\sdisconnected\s\(reason\s"(.[^"]+)"\)$/, + handle: (_context, matches) => { + if (!matches[5] || !matches[6]) { + return [] + } + const steamId = new SteamID(matches[5]) + if (!steamId.isValid()) { + return [] + } + return [{ event: 'player disconnected', steamId: steamId.getSteamID64() as SteamId64 }] + }, + }, + { + regex: /^[\d/\s\-:]+Team "(.[^"]+)" current score "(\d)" with "(\d)" players$/, + handle: (context, matches) => { + const [, teamName, score] = matches + if (!teamName || score === undefined) { + return [] + } + const round = ensureRound(context) + round.score = { ...round.score, [fixTeamName(teamName)]: Number(score) } + return maybeRoundEnded(context) + }, + }, + { + regex: /^[\d/\s\-:]+Team "(.[^"]+)" final score "(\d)" with "(\d)" players$/, + handle: (_context, matches) => { + const [, teamName, score] = matches + if (!teamName || score === undefined) { + return [] + } + return [{ event: 'final score', team: fixTeamName(teamName), score: Number(score) }] + }, + }, + { + regex: /^[\d/\s\-:]+Team "(.[^"]+)" triggered "pointcaptured" \(cp "(\d+)"\)/, + handle: (context, matches) => { + const [, teamName, controlPoint] = matches + if (!teamName || controlPoint === undefined) { + return [] + } + ensureRound(context).captures[fixTeamName(teamName)].push(Number(controlPoint)) + return [] + }, + }, + { + regex: /^[\d/\s-:]+\[demos\.tf\]:\sSTV\savailable\sat:\s(.+)$/, + handle: (_context, matches) => { + if (!matches[1]) { + return [] + } + return [{ event: 'demo uploaded', demoUrl: matches[1] }] + }, + }, + { + regex: + /^(\d{2}\/\d{2}\/\d{4})\s-\s(\d{2}:\d{2}:\d{2}):\s"(.+)<(\d+)><(\[.[^\]]+\])><(.[^>]+)>"\ssay\s"(.+)"$/, + handle: (_context, matches) => { + if (!matches[5] || !matches[7]) { + return [] + } + const steamId = new SteamID(matches[5]) + if (!steamId.isValid()) { + return [] + } + return [ + { + event: 'player said', + steamId: steamId.getSteamID64() as SteamId64, + message: matches[7], + }, + ] + }, + }, +] + +/** + * Interpret a single TF2 game log line, updating `context` in place and + * returning the game events the line produced (empty if the line is not + * recognized). Call it once per line, strictly in order, per game. + */ +export function analyze(context: GameContext, line: string): LogEvent[] { + for (const matcher of matchers) { + const matches = line.match(matcher.regex) + if (matches) { + return matcher.handle(context, matches) + } + } + return [] +} diff --git a/src/tf2-game-analyzer/create-game-context.ts b/src/tf2-game-analyzer/create-game-context.ts new file mode 100644 index 000000000..b0640175e --- /dev/null +++ b/src/tf2-game-analyzer/create-game-context.ts @@ -0,0 +1,13 @@ +import { Tf2Team } from '../shared/types/tf2-team' +import type { GameContext } from './game-context' + +/** + * Create the initial, empty {@link GameContext} for a brand-new game. + */ +export function createGameContext(): GameContext { + return { + score: { [Tf2Team.red]: 0, [Tf2Team.blu]: 0 }, + seenRoundStart: false, + swapPending: false, + } +} diff --git a/src/tf2-game-analyzer/game-context.ts b/src/tf2-game-analyzer/game-context.ts new file mode 100644 index 000000000..9ffa259b3 --- /dev/null +++ b/src/tf2-game-analyzer/game-context.ts @@ -0,0 +1,34 @@ +import type { Tf2Team } from '../shared/types/tf2-team' + +/** + * The accumulated state of a single game, threaded through {@link analyze} for + * every log line. It is a plain, JSON-serializable object: the caller owns it, + * persists it and rehydrates it (dates are stored as ISO strings). All state + * needed to interpret a match — restart detection, round assembly, the pending + * stopwatch side-swap and the running score — lives here. + */ +export interface GameContext { + // the running committed match score + score: Record + + // whether any Round_Start has been seen this match, and the last one seen — + // together these detect the tournament-(re)start doubling (two Round_Starts + // within ~1s) + seenRoundStart: boolean + lastRoundStart?: { at: string; precededByRoundStart: boolean } | undefined + + // the round currently being assembled; its outcome arrives across several log + // lines (Round_Win, Round_Length, per-team score) that may come in any order + round?: + | { + winner?: Tf2Team + lengthMs?: number + score?: Partial> + captures: Record + } + | undefined + + // the teams switch sides at the next Round_Start (stopwatch maps); deferred so + // the final round produces no trailing swap + swapPending: boolean +} diff --git a/src/tf2-game-analyzer/log-event.ts b/src/tf2-game-analyzer/log-event.ts new file mode 100644 index 000000000..2b0e98dc3 --- /dev/null +++ b/src/tf2-game-analyzer/log-event.ts @@ -0,0 +1,28 @@ +// TODO: isolate SteamId64/Tf2Team into the module when tf2-game-analyzer is extracted +// to a standalone package; for now we reuse the app's shared types. +import type { SteamId64 } from '../shared/types/steam-id-64' +import type { Tf2Team } from '../shared/types/tf2-team' + +/** + * A single interpreted game event produced by {@link analyze} from one TF2 log + * line. A line may yield zero, one or several of these. + */ +export type LogEvent = + | { event: 'round started' } + | { + event: 'round ended' + winner: Tf2Team + lengthMs: number + score: Record + captures: Record + } + | { event: 'teams swapped' } + | { event: 'score reset' } + | { event: 'match ended' } + | { event: 'final score'; team: Tf2Team; score: number } + | { event: 'player connected'; steamId: SteamId64; ipAddress: string } + | { event: 'player joined team'; steamId: SteamId64; team: Tf2Team } + | { event: 'player disconnected'; steamId: SteamId64 } + | { event: 'player said'; steamId: SteamId64; message: string } + | { event: 'logs uploaded'; logsUrl: string } + | { event: 'demo uploaded'; demoUrl: string } diff --git a/src/tf2-game-analyzer/tf2-game-analyzer.test.ts b/src/tf2-game-analyzer/tf2-game-analyzer.test.ts new file mode 100644 index 000000000..a560fcaab --- /dev/null +++ b/src/tf2-game-analyzer/tf2-game-analyzer.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest' +import { Tf2GameAnalyzer } from './tf2-game-analyzer' +import { createGameContext } from './create-game-context' + +const roundStart = '07/13/2026 - 17:00:00: World triggered "Round_Start"' + +describe('Tf2GameAnalyzer', () => { + it('starts clean', () => { + expect(new Tf2GameAnalyzer().contextDirty()).toBe(false) + }) + + it('becomes dirty after a line that changes the context', () => { + const analyzer = new Tf2GameAnalyzer() + analyzer.parseLine(roundStart) + expect(analyzer.contextDirty()).toBe(true) + }) + + it('stays clean after a line that does not change the context', () => { + const analyzer = new Tf2GameAnalyzer() + analyzer.parseLine('07/13/2026 - 17:00:00: some unrecognized line') + expect(analyzer.contextDirty()).toBe(false) + }) + + it('is clean again after markPersisted', () => { + const analyzer = new Tf2GameAnalyzer() + analyzer.parseLine(roundStart) + analyzer.markPersisted() + expect(analyzer.contextDirty()).toBe(false) + }) + + it('parses events and exposes the mutated context', () => { + const analyzer = new Tf2GameAnalyzer() + expect(analyzer.parseLine(roundStart)).toEqual([{ event: 'round started' }]) + expect(analyzer.context.seenRoundStart).toBe(true) + }) + + it('rehydrates from an existing context and starts clean', () => { + const seed = createGameContext() + seed.score.red = 3 + const analyzer = new Tf2GameAnalyzer(seed) + expect(analyzer.context.score.red).toBe(3) + expect(analyzer.contextDirty()).toBe(false) + }) +}) diff --git a/src/tf2-game-analyzer/tf2-game-analyzer.ts b/src/tf2-game-analyzer/tf2-game-analyzer.ts new file mode 100644 index 000000000..87e619fb2 --- /dev/null +++ b/src/tf2-game-analyzer/tf2-game-analyzer.ts @@ -0,0 +1,30 @@ +import { analyze } from './analyze' +import { createGameContext } from './create-game-context' +import type { GameContext } from './game-context' +import type { LogEvent } from './log-event' + +export class Tf2GameAnalyzer { + private readonly _context: GameContext + private saved: string + + constructor(context?: GameContext) { + this._context = context ?? createGameContext() + this.saved = JSON.stringify(this._context) + } + + parseLine(line: string): LogEvent[] { + return analyze(this._context, line) + } + + contextDirty(): boolean { + return JSON.stringify(this._context) !== this.saved + } + + markPersisted(): void { + this.saved = JSON.stringify(this._context) + } + + get context(): GameContext { + return this._context + } +}