diff --git a/README.md b/README.md index cf6c796..1a66b65 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,12 @@ When the server is running, you can POST two log files to the "/parseGame" route curl -X POST -F 'logs[]=@logs/L0526012.log' -F 'logs[]=@logs/L0526013.log' http://127.0.0.1:3000/parseGame ``` +If the log fails validation, you can add a "force" form body parameter with any non-null value (e.g., "on") to force-parse the log files anyway. + +``` +curl -X POST -F force=on -F 'logs[]=@logs/L0526012.log' -F 'logs[]=@logs/L0526013.log' http://127.0.0.1:3000/parseGame +``` + ### Immediate to-do: - [ ] Sort summary view by # kills (or some other score metric) @@ -42,7 +48,7 @@ Plan: - ngnix and node.js process should run in non-privileged context - Use [jQuery POST](https://api.jquery.com/jquery.post/) and [this tutorial](https://attacomsian.com/blog/xhr-node-file-upload) to do front-end call - - [ ] Set up reverse proxy for hampalyzer script and shove under /api/ + - [x] Set up reverse proxy for hampalyzer script and shove under /api/ - [x] Move "frontend" code to /var/www/app.hampalyzer.com - [x] Remove manual file parsing - [x] Force uploaded files to be local @@ -57,7 +63,7 @@ Plan: - [ ] Handle player disconnects if they are in the middle of carrying flag (flag time / flag status) - [ ] If a player only plays one of two rounds, player stats doesn't format correctly (e.g., stats show in rd2 even though they only played rd1) -- [ ] Classes may not be assigned correctly, e.g. http://app.hampalyzer.com/parsedlogs/Inhouse-2021-Jun-6-02-22/ (hamp rd2) +- [x] Classes may not be assigned correctly, e.g. http://app.hampalyzer.com/parsedlogs/Inhouse-2021-Jun-6-02-22/ (hamp rd2) ## Building / Running diff --git a/db-schema.md b/db-schema.md new file mode 100644 index 0000000..f89b443 --- /dev/null +++ b/db-schema.md @@ -0,0 +1,72 @@ +EVENT table (table is new) + +| name | type | values | notes | +|-----------------|----------|---------|----------------------------| +| eventId | | | auto-increment, NOT NULL | +| logId | | | log table id ref, NOT NULL | +| isFirstLog | bool | | default true | +| eventType | enum | 0-71 | NOT NULL | +| rawLine | string | | NOT NULL | +| lineNumber | number | | NOT NULL | +| timestamp | datetime | | NOT NULL | +| gameTime | number | seconds | NOT NULL | +| extraData | string | | (prefer json format?) | +| playerFrom | | | player table id ref | +| playerFromClass | short | 0-9 | | +| playerTo | | | player table id ref | +| playerToClass | short | 0-9 | | +| withWeapon | short | 0-39 | | +| playerFromFlag | bool | | default false | +| playerToFlag | bool | | default false | + + +PLAYER table (table is new) + +| name | type | desc | notes | +|-----------------|----------|---------|----------------------------| +| playerId | | | auto-increment, NOT NULL | +| playerName | string | | NOT NULL | +| playerAlias | string | | | +| steamId | number | | see [SteamID doc](https://developer.valvesoftware.com/wiki/SteamID) | + + +LOGS table (* are new columns) + +| name | type | values | notes | +|-----------------|----------|----------|----------------------------| +| * logId | | | auto-increment, NOT NULL | +| parsedlog | string | | output URI slug | +| log_file1 | string | | matches name in uploads/ | +| log_file2 | string | | matches name in uploads/ | +| date_parsed | datetime | | initial upload time | +| date_match | datetime | | reported in local time | +| map | string | | can be "" | +| server | string | | | +| num_players | int | | | +| * is_valid | bool | | default false | + + +PARSEDLOGS table (table is new) + +| name | type | values | notes | +|-----------------|--------------|----------|----------------------------| +| logId | | | auto-increment, NOT NULL | +| jsonSummary | varchar(MAX) | | full json | + + +MAPLOCATIONS table (table is new) + +| name | type | values | notes | +|-----------------|--------------|----------|----------------------------| +| locationId | | | auto-increment, NOT NULL | +| map | string | | NOT NULL, add index | +| name | string | | user-provided name/callout | +| geom | geometry | | will be POLYGON Z, typ. | + +SELECT name + FROM mapLocations +WHERE ST_3DIntersects( + geom, + 'POINT Z($x, $y, $z)'::geometry +) +LIMIT 1; \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 82735ae..74d5790 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -48,6 +48,10 @@

Hampalyzer — TFC Statistics

You must select one or two .log files.
+
+ + +
@@ -137,17 +141,22 @@

Hampalyzer — TFC Statistics

cache: false, }); - let handler = function(data) { - if (data.success) { + let handler = function(response) { + if (response.success) { $('#formResponse').attr('class', 'alert alert-primary') - .html(`Your game has been parsed and is available at ${ data.success.path }!`); - } else { + .html(`Your game has been parsed and is available at ${ response.success.path }!`); + } + else if (response.failure) { + $('#formResponse').attr('class', 'alert alert-danger') + .html(`There was an error parsing the logs: ${response.failure.error_reason}.
${response.failure.message}`); + } + else { $('#formResponse').attr('class', 'alert alert-danger') - .html("There was an error parsing the logs. More info: «" + (data.responseJSON || { error: "no additional info" }).error + "»"); + .html(`An unknown error occured: either the server did not respond or it talked funny to us.`); } } - request.done(handler).fail(handler); + request.done(handler).fail((data) => handler(data.responseJSON)); }); diff --git a/src/App.ts b/src/App.ts index b7314bc..40ba762 100644 --- a/src/App.ts +++ b/src/App.ts @@ -13,6 +13,7 @@ import { FileCompression } from './fileCompression.js'; import { default as fileParser, HampalyzerTemplates } from './fileParser.js'; import { ParsedStats, Parser } from './parser.js'; import TemplateUtils from './templateUtils.js'; +import { ParseResponse, ParsingError, ParsingOptions } from './constants.js'; const envFilePath = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), "../.env"); dotenv.config({ path: envFilePath }); @@ -82,47 +83,50 @@ class App { }); router.post('/parseGame', cors(), upload.array('logs[]', 2), async (req, res) => { - if (req?.files!['logs']?.length < 2) { - console.error("expected two files"); + if (req.files && (req.files as Express.Multer.File[]).length < 2) { + res.status(400).json({ failure: { message: "expected two files in `logs[]`"}}); + return; } - let parsedResult = await this.parseLogs([ + const skipValidation = !!req.body.force; + const parserResponse = await this.parseLogs([ req.files![0].path, - req.files![1].path]); + req.files![1].path], + { skipValidation }); - if (parsedResult == null) { - res.status(500).json({ error: "Failed to parse file (please pass logs to Hampster)" }); - } - else { + if (parserResponse.success) { // sanitize the outputPath by removing the webserverRoot path // (e.g., remove /var/www/app.hampalyzer.com/html prefix) - let outputPath = parsedResult; + let outputPath = parserResponse.message; if (outputPath.startsWith(this.webserverRoot)) { outputPath = outputPath.slice(this.webserverRoot.length); } res.status(200).json({ success: { path: outputPath }}); } + else { + const { error_reason, message } = parserResponse; + res.status(400).json({ failure: { error_reason, message } }); + } }); router.post('/parseLog', cors(), upload.single('logs[]'), async (req, res) => { - // res.status(500).json({ error: "Single log parsing is still a work in progress; try uploading two rounds of a game instead." }); - - let parsedResult = await this.parseLogs([req.file!.path]); - - if (parsedResult == null) { - res.status(500).json({ error: "Failed to parse file (please pass logs to Hampster)" }); - } - else { + const skipValidation = !!req.body.force; + const parserResponse = await this.parseLogs([req.file!.path], { skipValidation }); + if (parserResponse.success) { // sanitize the outputPath by removing the webserverRoot path // (e.g., remove /var/www/app.hampalyzer.com/html prefix) - let outputPath = parsedResult; + let outputPath = parserResponse.message; if (outputPath.startsWith(this.webserverRoot)) { outputPath = outputPath.slice(this.webserverRoot.length); } res.status(200).json({ success: { path: outputPath }}); } + else { + const { error_reason, message } = parserResponse; + res.status(400).json({ failure: { error_reason, message } }); + } }); router.get('/logs/:page_num', async (req, res) => { @@ -150,8 +154,7 @@ class App { private async reparseLogs(): Promise { let result; try { - result = await this.pool.query('SELECT log_file1, log_file2 FROM logs WHERE id > 42'); // before 42, wrong log filenames - // for (const game of result.rows) { + result = await this.pool.query('SELECT id, log_file1, log_file2 FROM logs WHERE id > 42'); // before 42, wrong log filenames for (let i = 0, len = result.rows.length; i < len; i++) { const game = result.rows[i]; @@ -161,28 +164,52 @@ class App { filenames.push(game.log_file2); } - console.warn(`${i+1} / ${len} (${Math.round((i+1) / len * 1000) / 10}%) reparsing: ${filenames.join(" + ")}`); - - const parsedLog = await this.parseLogs(filenames, true /* reparse */); - if (!parsedLog) { - console.error(`failed to parse logs ${filenames.join(" + ")}; aborting`); - return false; + console.warn(`${i+1} / ${len} (${Math.round((i+1) / len * 1000) / 10}%) reparsing: ${filenames.join(" + ")}`); + + const parsedLog = await this.parseLogs(filenames, { reparse: true }); + if (!parsedLog.success) { + // if it is a validation failure, mark it and move on to the next log. + if (parsedLog.error_reason === 'MATCH_INVALID') { + console.error(`LOG ${game.id} invalid: ${parsedLog.message}`); + this.pool.query('UPDATE logs SET is_valid = FALSE WHERE id = $1', [game.id]); + continue; + } + else { + console.error(`failed to parse logs ${filenames.join(" + ")}; aborting`); + return false; + } } } } catch (error: any) { - console.error("critical error: failed to connect to DB to reparse logs: " + error?.message); + console.error("critical error: failed to connect to DB to reparse logs: " + error?.message || error); } // at least some logs must have been reparsed return result && result.rows.length !== 0; } - private async parseLogs(filenames: string[], reparse?: boolean): Promise { + private async parseLogs(filenames: string[], { reparse, skipValidation }: ParsingOptions): Promise { filenames = await FileCompression.ensureFilesCompressed(filenames, /*deleteOriginals=*/true); const parser = new Parser(...filenames) - return parser.parseRounds() - .then(allStats => fileParser(allStats, path.join(this.webserverRoot, this.outputRoot), this.templates, this.pool, reparse)); + return parser.parseRounds(skipValidation) + .then(allStats => fileParser(allStats, path.join(this.webserverRoot, this.outputRoot), this.templates, this.pool, reparse)) + .catch((error) => { + if (error instanceof ParsingError) { + return { + success: false, + error_reason: error.name, + message: error.message, + }; + } + else { + return { + success: false, + error_reason: 'PARSING_FAILURE', + message: error, + } + } + }); } } diff --git a/src/classTracker.ts b/src/classTracker.ts index e0eb175..f0b60e2 100644 --- a/src/classTracker.ts +++ b/src/classTracker.ts @@ -2,16 +2,23 @@ import { EventHandlingPhase, EventSubscriber, HandlerRequest } from './eventSubs import { RoundState } from './roundState.js' import { Event } from './parser.js'; import EventType from './eventType.js'; +import { ParsingError } from './constants.js'; export class ClassTracker extends EventSubscriber { public phaseStart(phase: EventHandlingPhase, _roundState: RoundState): void { if (phase !== EventHandlingPhase.AfterGameTimeEpochEstablished) - throw "Unexpected phase"; + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: "Unexpected phase" + }); } public phaseEnd(phase: EventHandlingPhase, _roundState: RoundState): void { if (phase !== EventHandlingPhase.AfterGameTimeEpochEstablished) - throw "Unexpected phase"; + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: "Unexpected phase" + }); } public handleEvent(event: Event, _phase: EventHandlingPhase, _roundState: RoundState): HandlerRequest { @@ -51,7 +58,10 @@ export class ClassTracker extends EventSubscriber { event.playerToClass = currentClass; return; default: - throw 'unknown playerDirection'; + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: 'unknown playerDirection' + }); } } } diff --git a/src/constants.ts b/src/constants.ts index 7ab9a68..9def3db 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -2,6 +2,22 @@ import { TeamScore } from "./parserUtils.js"; import { Event } from "./parser.js"; import Player from "./player.js"; +export type ParseResponse = ParseResponseSuccess | ParseResponseFailure; + +interface ParseResponseSuccess { + success: true; + /** parsedlog slug */ + message: string; +}; + +interface ParseResponseFailure { + success: false; + /** failure type */ + error_reason: ParsingErrorName; + /** detail about what went wrong */ + message: string; +} + export interface OutputStats { parse_name: string; // 'user-friendly' URI slug log_name: string; // original log name as uploaded @@ -324,4 +340,23 @@ export namespace TeamColor { export function toString(teamColor: TeamColor): string { return (TeamColor as any)[teamColor]; } -} \ No newline at end of file +} + +export interface ParsingOptions { + reparse?: boolean; + skipValidation?: boolean; +} + +export type ParsingErrorName = 'MATCH_INVALID' | 'PARSING_FAILURE' | 'DATABASE_FAILURE' | 'LOGIC_FAILURE'; +export class ParsingError extends Error { + name: ParsingErrorName; + message: string; + cause: any; // unused + + constructor({ name, message, cause}: { name: ParsingErrorName, message: string, cause?: any }) { + super(); + this.name = name; + this.message = message; + this.cause = cause; + } +} diff --git a/src/eventSubscriberManager.ts b/src/eventSubscriberManager.ts index 3eec441..e2b6f83 100644 --- a/src/eventSubscriberManager.ts +++ b/src/eventSubscriberManager.ts @@ -1,3 +1,4 @@ +import { ParsingError } from './constants.js'; import { Event } from './parser.js'; import { RoundState } from './roundState.js'; @@ -69,7 +70,11 @@ export class EventSubscriberManager { } catch (error: any) { console.error(`[subscriber=${subscriber.constructor.name}, phase=${EventHandlingPhase[phase]}] failed (error=${error.message}) when handling line ${event.lineNumber}: ${event.rawLine}`); - throw error; + + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: error, + }); } }); if (shouldRemoveEvent) { diff --git a/src/fileParser.ts b/src/fileParser.ts index aa2afd2..69bf461 100644 --- a/src/fileParser.ts +++ b/src/fileParser.ts @@ -3,7 +3,7 @@ import { copyFile, readFileSync, writeFile, mkdir } from 'fs'; import Handlebars from 'handlebars'; import * as pg from 'pg'; -import { OutputPlayer, PlayerOutputStatsRound, PlayerOutputStats } from './constants.js'; +import { OutputPlayer, PlayerOutputStatsRound, PlayerOutputStats, ParseResponse, ParsingError } from './constants.js'; import { ParsedStats } from "./parser.js"; import ParserUtils from './parserUtils.js'; import TemplateUtils from './templateUtils.js'; @@ -30,7 +30,7 @@ export default async function( templates?: HampalyzerTemplates, pool?: pg.Pool, reparse?: boolean, - ): Promise { + ): Promise { if (allStats) { const matchMeta: MatchMetadata = { @@ -62,7 +62,10 @@ export default async function( const isDuplicate = await checkHasDuplicate(pool, matchMeta); console.log('isDuplicate', isDuplicate); if (isDuplicate) { - return `${outputRoot}/${matchMeta.logName}`; + return { + success: true, + message: `${outputRoot}/${matchMeta.logName}` + }; } } @@ -72,7 +75,16 @@ export default async function( const outputDir = `${outputRoot}/${logName}`; // ensure directory exists; create if it doesn't - mkdir(outputDir, { mode: 0o775, recursive: true, }, err => { if (err && err.code !== "EEXIST") throw err; }); + mkdir( + outputDir, + { mode: 0o775, recursive: true, }, + err => { + if (err && err.code !== "EEXIST") + throw new ParsingError({ + name: 'PARSING_FAILURE', + message: err.message, + }); + }); // generate the summary output let flagPaceChartMarkup = ""; @@ -152,11 +164,24 @@ export default async function( // (which, when the server name had a '?', decodes %3F back into '?' which in turn results in a 404) if (dbSuccess || reparse) { console.log(`writing log to ${outputDir}`); - return `${outputDir}/`; + return { + success: true, + message: `${outputDir}/` + }; + } else { + return { + success: false, + error_reason: 'DATABASE_FAILURE', + message: "Failed to communicate to database. The logs have been rejected.", + } } } - else - console.error('no stats found to write!'); + + return { + success: false, + error_reason: 'PARSING_FAILURE', + message: 'No stats found to write! Unhandled exception likely resulted in this error.' + }; } async function checkHasDuplicate(pool: pg.Pool | undefined, matchMeta: MatchMetadata): Promise { diff --git a/src/flagMovementTracker.ts b/src/flagMovementTracker.ts index 751d0ca..283bd76 100644 --- a/src/flagMovementTracker.ts +++ b/src/flagMovementTracker.ts @@ -3,7 +3,7 @@ import EventType from './eventType.js'; import { EventSubscriber, EventHandlingPhase, HandlerRequest } from "./eventSubscriberManager.js"; import { RoundState } from "./roundState.js"; import { TeamScore } from "./parserUtils.js"; -import { FlagDrop, FlagMovement, PlayerClass, TeamColor, TeamFlagMovements } from "./constants.js"; +import { FlagDrop, FlagMovement, ParsingError, PlayerClass, TeamColor, TeamFlagMovements } from "./constants.js"; import Player from "./player.js"; import { PlayerRoundStats } from "./player.js"; @@ -76,7 +76,10 @@ export class FlagMovementTracker extends EventSubscriber { case EventHandlingPhase.Main: break; default: - throw "Unexpected phase"; + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: "Unexpected phase" + }); } } @@ -94,7 +97,10 @@ export class FlagMovementTracker extends EventSubscriber { this.computeScoreAndFlagMovements(); break; default: - throw "Unexpected phase"; + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: "Unexpected phase" + }); } } @@ -117,10 +123,16 @@ export class FlagMovementTracker extends EventSubscriber { const team = event.data && event.data.team; const score = event.data && event.data.value; if (!team) { - throw "expected team with a TeamScore event"; + throw new ParsingError({ + name: 'PARSING_FAILURE', + message: "expected team with a TeamScore event" + }); } if (!score) { - throw "expected value with a TeamScore event"; + throw new ParsingError({ + name: 'PARSING_FAILURE', + message: "expected value with a TeamScore event" + }); } this.flagRoundStatsByTeam[team].score = Number(score); this.sawTeamScoresEvent = true; @@ -242,7 +254,10 @@ export class FlagMovementTracker extends EventSubscriber { case EventHandlingPhase.PostMain: break; default: - throw "Unexpected phase"; + throw new ParsingError({ + name: 'LOGIC_FAILURE', + message: "Unexpected phase" + }); } return HandlerRequest.None; } diff --git a/src/index.ts b/src/index.ts index 2cc4e67..6348ffb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -64,7 +64,9 @@ else { // logs = ['logs/schtopr1.log', 'logs/schtopr2.log']; // incomplete // logs = ['logs/1641109721918-L0102072.log', 'logs/1641109721922-L0102073.log']; // siden // logs = ['dist/uploads/1647142313653-L0313008.log', 'dist/uploads/1647142313653-L0313009.log']; // teams fucked? - logs = ['dist/uploads/1654325677960-L0604008.log', 'dist/uploads/1654325677973-L0604009.log']; // dmg not counted? + // logs = ['dist/uploads/1654325677960-L0604008.log', 'dist/uploads/1654325677973-L0604009.log']; // dmg not counted? + logs = ['backup/uploads/uploads/1702675375064-L1215064-coacheast.log.br', 'backup/uploads/uploads/1702675375069-L1215067-coacheast.log.br']; + console.log(`parsing logs ${logs.join(" and ")} ...`); @@ -81,6 +83,11 @@ else { let parser = new Parser(...logs); // let parser = new Parser('logs/L0405005.log'); // let parser = new Parser('logs/TSq9rtLa.log'); - let parsePromise = parser.parseRounds(); - parsePromise.then(fileParser); + + parser.parseRounds(/* skipValidation */) + .then(fileParser) + .catch((reason: string) => { + console.error('Failed to parse log, message follows:'); + console.error(reason); + }); } \ No newline at end of file diff --git a/src/mapLocations/schtop.csv b/src/mapLocations/schtop.csv new file mode 100755 index 0000000..638473f --- /dev/null +++ b/src/mapLocations/schtop.csv @@ -0,0 +1,42 @@ +desc,x_min,x_max,y_min,y_max,z_min,z_max +flagroom back,-2200,-1455,3300,3700,250,500 +flagroom left railing,-2200,-1980,3000,3328,310,340 +flagroom top,-2200,-1407,2815,3000,340,500 +flagroom ceiling,-2200,-1400,2815,3700,500,1000 +button hallway,-1400,-920,2940,3200,250,400 +crows nest,-1400,-920,2650,3200,400,1000 +flagroom flat,-1990,-1530,3145,3300,250,300 +flagroom edge right,-1665,-1600,2930,3145,250,300 +flagroom edge left,-1990,-1920,2930,3145,250,300 +flagroom ramp,-1920,-1660,2870,3145,120,300 +flagroom almost out,-1920,-1660,2760,2870,120,170 +button,-920,-600,2690,3180,250,300 +button stairs,-600,-120,2680,3180,0,300 +cap point,-1040,-250,2300,2680,0,50 +mini ramp,-1530,-1040,2495,2690,0,170 +pit,-1350,-1040,2300,2495,0,50 +button spawn,-1040,-510,1665,2300,0,50 +catwalk,-1150,-510,1665,2440,200,1000 +battlements,-640,-220,1470,1665,200,1000 +battlements out,-220,950,290,1635,200,1000 +just out,-1990,-1605,2690,2760,120,170 +ramp room,-2115,-1530,2240,2690,120,170 +water,-2120,-1985,2047,2240,120,170 +actual water,-2700,-2120,2040,3200,-170,150 +near slidable ramp,-1985,-1400,2090,2240,120,170 +slidable ramp,-1985,-1600,1750,2090,0,170 +base catwalk,-1530,-1150,2300,2495,120,250 +front door left,-1985,-1280,1400,1750,0,50 +front door right,-1025,-510,1460,1665,0,50 +ramp spawn,-2700,-1985,800,1750,0,50 +front door visible,-1280,-1025,1280,1665,0,50 +yard red-side,-1670,-350,-420,1280,-50,50 +water red-side,-2440,-350,-420,2980,-500,-200 +yard rocks red-side,-1670,-350,-420,1280,150,1000 +yard mid,-350,350,-420,420,-50,1000 +water tunnel,-350,350,-420,420,-500,-200 +yard blue-side,350,1670,-1280,420,-50,50 +water blue-side,350,2440,-2980,420,-500,-200 +yard rocks blue-side,350,1670,-1280,420,150,1000 +blue base,1670,2700,-3700,-1280,-175,1000 +blue base yard batts,-950,220,-1280,-290,150,1000 diff --git a/src/parser.ts b/src/parser.ts index f07d143..764d2e7 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -1,10 +1,8 @@ -import * as fs from 'fs'; import EventType from './eventType.js'; import Player from './player.js'; import PlayerList from './playerList.js'; -import { EventHandlingPhase, EventSubscriber, EventSubscriberManager } from './eventSubscriberManager.js'; -import { PlayerTeamTracker } from './playerTeamTracker.js'; -import { OutputStats, TeamComposition, PlayerClass, TeamColor, Weapon, TeamStatsComparison, OutputPlayer } from './constants.js'; +import { EventSubscriberManager } from './eventSubscriberManager.js'; +import { OutputStats, TeamComposition, PlayerClass, TeamColor, Weapon, TeamStatsComparison, OutputPlayer, ParsingError } from './constants.js'; import { MapLocation } from './mapLocation.js'; import { RoundState } from './roundState.js'; import ParserUtils from './parserUtils.js'; @@ -16,6 +14,7 @@ export interface ParsedStats { players: TeamComposition; parsing_errors: (string[] | undefined)[]; comparison?: TeamStatsComparison; + isValid: boolean; } export class Parser { @@ -30,15 +29,20 @@ export class Parser { return this.rounds.map(round => round.stats); } - public async parseRounds(): Promise { + public async parseRounds(skipValidation?: boolean): Promise { return Promise.all(this.rounds.map(round => round.parseFile())) .then(() => { // TODO: be smarter about ensuring team composition matches, map matches, etc. between rounds const stats = this.rounds.map(round => round.stats); + const isValid = skipValidation || this.validateGame(); + if (!this.rounds[0]!.playerList) { // The log was bogus or failed to parse. Nothing more we can do. - return undefined; + throw new ParsingError({ + name: 'PARSING_FAILURE', + message: 'Player list could not be parsed.' + }); } let comparison: TeamStatsComparison | undefined; @@ -56,9 +60,64 @@ export class Parser { stats, parsing_errors: stats.map(round => round?.parsing_errors), comparison, + isValid, }; }); } + + private validateGame(): boolean { + if (this.rounds.length < 1 || this.rounds[0].stats == null || this.rounds[0].playerList == null) + throw new ParsingError({ + name: 'MATCH_INVALID', + message: 'Validation failure: could not find one good round to parse.' + }); + + if (this.rounds.length === 1) + return true; + + const firstRound = this.rounds[0].stats; + let gameTime = firstRound.scoring_activity?.game_time_as_seconds || 0; + let map = firstRound.map; + let players = this.rounds[0].playerList.players; + + if (this.rounds.length > 2 || this.rounds[1].stats == null || this.rounds[1].playerList == null) + throw new ParsingError({ + name: 'MATCH_INVALID', + message: 'Validation failure: parsed two rounds but second was not parsed.' + }); + + const secondRound = this.rounds[1].stats; + if (secondRound.map != map) + throw new ParsingError({ + name: 'MATCH_INVALID', + message: 'Validation failure: map does not match between two rounds.' + }); + + const secondGameTime = secondRound.scoring_activity?.game_time_as_seconds || 0; + if (Math.abs(secondGameTime - gameTime) > 300) + throw new ParsingError({ + name: 'MATCH_INVALID', + message: `Validation failure: game time between two rounds does not match within tolerance of 5 minutes (first round: ${gameTime}s, second: ${secondGameTime}s).` + }); + + // verify at least 50% of players from first round match + const secondPlayers = this.rounds[1].playerList.players; + const maxDiff = Math.ceil(players.length / 2); + const countDiff = players.reduce((countDiff, player) => { + if (!secondPlayers.some(secondPlayer => player.matches(secondPlayer))) + countDiff++; + + return countDiff; + }, 0); + + if (countDiff > maxDiff) + throw new ParsingError({ + name: 'MATCH_INVALID', + message: `Validation failure: several players from first round not found in second round (found ${countDiff} missing, threshold is ${maxDiff}).` + }); + + return true; + } } export class RoundParser { @@ -109,17 +168,30 @@ export class RoundParser { } }); - // + // abort early if no events found + if (this.events.length === 0) { + throw new ParsingError({ + name: 'PARSING_FAILURE', + message: 'No events found in given log.', + }); + } + // Accumulate state by progressively evaluating events. Multiple phases are supported // to enable ordering dependencies between event subscribers. - // const eventSubscriberManager = new EventSubscriberManager(this.roundState.getEventSubscribers(), this.roundState); try { eventSubscriberManager.handleEvents(this.events); } catch (error: any) { console.error(error.message); - throw error; + + if (error instanceof ParsingError) + throw error; + else + throw new ParsingError({ + name: "PARSING_FAILURE", + message: error.stack || error.message, + }); } @@ -137,56 +209,6 @@ export class RoundParser { this.summarizedStats = ParserUtils.generateOutputStats(this.roundState, this.events, playerStats, this.players, this.filename); this.summarizedStats.parsing_errors = this.parsingErrors; } - - private trimPreAndPostMatchEvents() { - const matchStartEvent = this.events.find(event => event.eventType === EventType.PrematchEnd) || this.events[0]; - const matchEndEvent = this.events.find(event => event.eventType === EventType.TeamScore) || this.events.at(-1)!; - - const matchStartLineNumber = matchStartEvent.lineNumber; - const matchEndLineNumber = matchEndEvent.lineNumber; - if (matchStartEvent) { - const eventsNotToCull = [ - EventType.MapLoading, - EventType.ServerName, - EventType.PlayerJoinTeam, - EventType.PlayerChangeRole, - EventType.PlayerMM1, - EventType.PlayerMM2, - EventType.ServerSay, - EventType.ServerCvar, - EventType.PrematchEnd, - EventType.TeamScore - ]; - - // iterate through events, but skip culling chat, role, and team messages - for (let i = 0; i < this.events.length; i++) { - const e = this.events[i]; - - // Will be negative if a pre-match event (see eventsNotToCull). - e.gameTimeAsSeconds = Math.round((e.timestamp.getTime() - matchStartEvent.timestamp.getTime()) / 1000); - - if (e.lineNumber < matchStartLineNumber || e.lineNumber > matchEndLineNumber) { - if (eventsNotToCull.indexOf(e.eventType) === -1) { - this.events.splice(i, 1); - i--; - } - } - } - - // also cull suicides/dmg due to prematch end - const prematchEndIndex = this.events.findIndex(event => event.lineNumber === matchStartLineNumber); - let i = prematchEndIndex + 1; - while (i < this.events.length && this.events[i].gameTimeAsSeconds === 0) { - const currentEvent = this.events[i]; - if (currentEvent.eventType === EventType.PlayerCommitSuicide || - currentEvent.eventType === EventType.PlayerDamage) { - this.events.splice(i, 1); - } - else - i++; - } - } - } } export interface EventCreationOptions { diff --git a/src/playerList.ts b/src/playerList.ts index 5f358c9..606866d 100644 --- a/src/playerList.ts +++ b/src/playerList.ts @@ -53,6 +53,15 @@ class PlayerList { public get teams() { return this._teams; } + + public get players() { + const players: Player[] = []; + for (const team in this._teams) { + players.push(...this._teams[team]) + } + + return players; + } } export default PlayerList; \ No newline at end of file