Skip to content

Stats logger #9

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import * as Presets from './presets';
import { RegisterRoutes } from './routes';
import * as Storage from './storage';
import * as WebSocket from './webSocket';
import * as StatsLogger from './statsLogger';

export const TMT_LOG_ADDRESS: string | null = (() => {
if (!process.env['TMT_LOG_ADDRESS']) {
Expand Down Expand Up @@ -126,6 +127,7 @@ const main = async () => {
console.info(`App dir: ${APP_DIR}, frontend dir: ${FRONTEND_DIR}`);
await Storage.setup();
await ManagedGameServers.setup();
await StatsLogger.setup();
await Auth.setup();
await WebSocket.setup(httpServer);
await Presets.setup();
Expand Down
82 changes: 82 additions & 0 deletions backend/src/match.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Rcon } from './rcon-client';
import { Settings } from './settings';
import * as Storage from './storage';
import * as Team from './team';
import * as StatsLogger from './statsLogger';

const STORAGE_LOGS_PREFIX = 'logs_';
const STORAGE_LOGS_SUFFIX = '.jsonl';
Expand Down Expand Up @@ -226,6 +227,7 @@ const setup = async (match: Match) => {
await setTeamNames(match);

await execRcon(match, 'log on');
await execRcon(match, 'mp_logdetail 1');
await execRcon(match, 'mp_warmuptime 600');
await execRcon(match, 'mp_warmup_pausetimer 1');
await execRcon(match, 'mp_autokick 0');
Expand Down Expand Up @@ -596,6 +598,48 @@ const onPlayerLogLine = async (
player = match.data.players.find((p) => p.steamId64 === steamId64);
if (!player) {
player = Player.create(match, steamId, name);
const playerExists =
(
(await Storage.queryDB(
`SELECT * FROM ${StatsLogger.PLAYERS_TABLE} WHERE steamId = '${player.steamId64}'`
)) as any[]
).length > 0;
if (!playerExists) {
await Storage.insertDB(
StatsLogger.PLAYERS_TABLE,
new Map<string, string | number>([
['steamId', player.steamId64],
['name', player.name],
['tKills', 0],
['tDeaths', 0],
['tAssists', 0],
['tDiff', 0],
['tHits', 0],
['tHeadshots', 0],
['tHsPct', 0],
['tRounds', 0],
['tDamages', 0],
['tAdr', 0],
])
);
}
await Storage.insertDB(
StatsLogger.PLAYERS_TABLE,
new Map<string, string | number>([
['steamId', player.steamId64],
['matchId', match.data.id],
['kills', 0],
['deaths', 0],
['assists', 0],
['diff', 0],
['hits', 0],
['headshots', 0],
['hsPct', 0],
['rounds', 0],
['damages', 0],
['adr', 0],
])
);
match.log(`Player ${player.steamId64} (${name}) created`);
match.data.players.push(player);
player = match.data.players[match.data.players.length - 1]!; // re-assign to work nicely with changeListener (ProxyHandler)
Expand Down Expand Up @@ -669,6 +713,44 @@ const onPlayerLogLine = async (
await onPlayerSay(match, player, message, isTeamChat, teamString);
return;
}

//attacked "PlayerName<1><U:1:12345678><CT>" [2397 2079 133] with "glock" (damage "117") (damage_armor "0") (health "0") (armor "0") (hitgroup "head")
const damageMatch = remainingLine.match(
/^attacked ".+<\d+><([\[\]\w:]+)><(?:TERRORIST|CT)>" \[-?\d+ -?\d+ -?\d+\] with "\w+" \(damage "(\d+)"\) \(damage_armor "(\d+)"\) \(health "(\d+)"\) \(armor "(\d+)"\) \(hitgroup "([\w ]+)"\)$/
);
if (damageMatch) {
const victimId = damageMatch[1]!;
const damage = Number(damageMatch[2]);
const damageArmor = Number(damageMatch[3]);
const headshot = damageMatch[4] == 'head';
await StatsLogger.onDamage(match.data.id, steamId, victimId, damage, damageArmor, headshot);
return;
}

//killed "PlayerName<2><STEAM_1:1:12345678><TERRORIST>" [-100 150 60] with "ak47" (headshot)
const killMatch = remainingLine.match(
/^killed ".+<\d+><([\[\]\w:]+)><(?:|Unassigned|TERRORIST|CT)>" \[-?\d+ -?\d+ -?\d+\] with "\w+" ?\(?(headshot|penetrated|headshot penetrated)?\)?$/
);
if (killMatch) {
const victimId = killMatch[1]!;
await StatsLogger.onKill(match.data.id, steamId, victimId);
return;
}

//assisted killing "PlayerName2<3><STEAM_1:1:87654321><CT>"
const assistMatch = remainingLine.match(/^assisted killing/);
if (assistMatch) {
await StatsLogger.onAssist(match.data.id, steamId);
return;
}

//committed suicide with "world"
//was killed by the bomb
const bombKillMatch = remainingLine.match(/^(?:was killed by the bomb|committed suicide with)/);
if (bombKillMatch) {
await StatsLogger.onOtherDeath(match.data.id, steamId);
return;
}
};

const onPlayerSay = async (
Expand Down
165 changes: 165 additions & 0 deletions backend/src/statsLogger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { SqlAttribute, TableSchema } from './tableSchema';
import { createTableDB, queryDB, updateDB } from './storage';

export const PLAYERS_TABLE = 'players';
export const MATCHES_TABLE = 'matches';
export const PLAYER_MATCH_STATS_TABLE = 'playerMatchStats';

export const setup = async () => {
// Create players global stats table
const playersAttributes = [
{ name: 'steamId', type: 'TEXT' },
{ name: 'name', type: 'TEXT' },
{ name: 'tKills', type: 'INTEGER' },
{ name: 'tDeaths', type: 'INTEGER' },
{ name: 'tAssists', type: 'INTEGER' },
{ name: 'tDiff', type: 'INTEGER' },
{ name: 'tHits', type: 'INTEGER' },
{ name: 'tHeadshots', type: 'INTEGER' },
{
name: 'tHsPct',
type: 'FLOAT',
constraints: 'CHECK (tHeadshots >= 0 AND tHeadshots <= 100)',
},
{ name: 'tRounds', type: 'INTEGER' },
{ name: 'tDamages', type: 'INTEGER' },
{ name: 'tAdr', type: 'INTEGER' },
] as SqlAttribute[];
const playersTableSchema = new TableSchema(PLAYERS_TABLE, playersAttributes, ['steamId']);
await createTableDB(playersTableSchema);

// Create matches table
const matchesAttributes = [
{ name: 'matchId', type: 'TEXT' },
{ name: 'teamA', type: 'TEXT' },
{ name: 'teamAScore', type: 'TEXT' },
{ name: 'teamB', type: 'TEXT' },
{ name: 'teamBScore', type: 'TEXT' },
{ name: 'map', type: 'TEXT' },
{ name: 'winner', type: 'TEXT' },
] as SqlAttribute[];
const matchesTableSchema = new TableSchema(MATCHES_TABLE, matchesAttributes, ['matchId']);
await createTableDB(matchesTableSchema);

// Create player match stats table
const playerMatchStatsAttributes = [
{
name: 'steamId',
type: 'TEXT',
constraints: `REFERENCES ${PLAYERS_TABLE}(steamId)`,
},
{
name: 'matchId',
type: 'TEXT',
constraints: `REFERENCES ${MATCHES_TABLE}(matchId)`,
},
{ name: 'kills', type: 'INTEGER' },
{ name: 'deaths', type: 'INTEGER' },
{ name: 'assists', type: 'INTEGER' },
{ name: 'diff', type: 'INTEGER' },
{ name: 'hits', type: 'INTEGER' },
{ name: 'headshots', type: 'INTEGER' },
{
name: 'hsPct',
type: 'FLOAT',
constraints: 'CHECK (headshots >= 0 AND headshots <= 100)',
},
{ name: 'rounds', type: 'INTEGER' },
{ name: 'damages', type: 'INTEGER' },
{ name: 'adr', type: 'FLOAT' },
] as SqlAttribute[];
const playerMatchStatsTableSchema = new TableSchema(
PLAYER_MATCH_STATS_TABLE,
playerMatchStatsAttributes,
['steamId', 'matchId']
);
await createTableDB(playerMatchStatsTableSchema);
};

export const onDamage = async (
matchId: string,
attackerId: string,
victimId: string,
damage: number,
damageArmor: number,
headshot: boolean
) => {
// TODO
};

export const onKill = async (matchId: string, killerId: string, victimId: string) => {
const currentKillerMatchStats = (await queryDB(
`SELECT kills FROM ${PLAYER_MATCH_STATS_TABLE} WHERE steamId = '${killerId}' AND matchId = '${matchId}'`
)) as number;
const currentVictimMatchStats = (await queryDB(
`SELECT deaths FROM ${PLAYER_MATCH_STATS_TABLE} WHERE steamId = '${victimId}' AND matchId = '${matchId}'`
)) as number;
const currentKillerGlobalStats = (await queryDB(
`SELECT tKills FROM ${PLAYERS_TABLE} WHERE steamId = '${killerId}'`
)) as number;
const currentVictimGlobalStats = (await queryDB(
`SELECT tDeaths FROM ${PLAYERS_TABLE} WHERE steamId = '${victimId}'`
)) as number;
await updateDB(
PLAYER_MATCH_STATS_TABLE,
new Map<string, number>([['kills', currentKillerMatchStats + 1]]),
`steamId = '${killerId}' AND matchId = '${matchId}'`
);
await updateDB(
PLAYER_MATCH_STATS_TABLE,
new Map<string, number>([['deaths', currentVictimMatchStats + 1]]),
`steamId = '${victimId}' AND matchId = '${matchId}'`
);
await updateDB(
PLAYERS_TABLE,
new Map<string, number>([['tKills', currentKillerGlobalStats + 1]]),
`steamId = '${killerId}'`
);
await updateDB(
PLAYERS_TABLE,
new Map<string, number>([['tDeaths', currentVictimGlobalStats + 1]]),
`steamId = '${victimId}'`
);
};

export const onAssist = async (matchId: string, attackerId: string) => {
const currentAttackerMatchStats = (await queryDB(
`SELECT assists FROM ${PLAYER_MATCH_STATS_TABLE} WHERE steamId = '${attackerId}' AND matchId = '${matchId}'`
)) as number;
const currentAttackerGlobalStats = (await queryDB(
`SELECT tAssists FROM ${PLAYERS_TABLE} WHERE steamId = '${attackerId}'`
)) as number;
await updateDB(
PLAYER_MATCH_STATS_TABLE,
new Map<string, number>([['assists', currentAttackerMatchStats + 1]]),
`steamId = '${attackerId}' AND matchId = '${matchId}'`
);
await updateDB(
PLAYERS_TABLE,
new Map<string, number>([['tAssists', currentAttackerGlobalStats + 1]]),
`steamId = '${attackerId}'`
);
};

export const onOtherDeath = async (matchId: string, victimId: string) => {
const currentVictimMatchStats = (await queryDB(
`SELECT deaths FROM ${PLAYER_MATCH_STATS_TABLE} WHERE steamId = '${victimId}' AND matchId = '${matchId}'`
)) as number;
const currentVictimGlobalStats = (await queryDB(
`SELECT tDeaths FROM ${PLAYERS_TABLE} WHERE steamId = '${victimId}'`
)) as number;
await updateDB(
PLAYER_MATCH_STATS_TABLE,
new Map<string, number>([['deaths', currentVictimMatchStats + 1]]),
`steamId = '${victimId}' AND matchId = '${matchId}'`
);
await updateDB(
PLAYERS_TABLE,
new Map<string, number>([['tDeaths', currentVictimGlobalStats + 1]]),
`steamId = '${victimId}'`
);
};

export const updateRoundCount = async (matchId: string) => {
//TODO
};
50 changes: 32 additions & 18 deletions backend/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Database } from 'sqlite3';
import { TableSchema } from './tableSchema';

export const STORAGE_FOLDER = process.env['TMT_STORAGE_FOLDER'] || 'storage';
export const DATABASE_PATH = path.join(STORAGE_FOLDER, 'database.sqlite');
const DATABASE_PATH = path.join(STORAGE_FOLDER, 'database.sqlite');

export const setup = async () => {
await fsp.mkdir(STORAGE_FOLDER, {
Expand Down Expand Up @@ -81,12 +81,6 @@ export const createTableDB = async (tableSchema: TableSchema): Promise<void> =>
}
);
});
db.close((err) => {
if (err) {
console.error('Error closing the database:', err.message);
reject(err);
}
});
});
};

Expand All @@ -103,12 +97,6 @@ export const flushDB = async (table: string): Promise<void> => {
resolve();
});
});
db.close((err) => {
if (err) {
console.error('Error closing the database:', err.message);
reject(err);
}
});
});
};

Expand Down Expand Up @@ -140,27 +128,53 @@ export const insertDB = async (table: string, values: Map<string, any>): Promise
});
};

export const queryDB = async (query: string) => {
export const updateDB = async (
table: string,
values: Map<string, any>,
where: string
): Promise<void> => {
return new Promise((resolve, reject) => {
const db = new Database(DATABASE_PATH);
db.serialize(() => {
db.all(query, (err, rows) => {
const placeholders = Array.from(values.entries())
.map(([key]) => `${key} = ?`)
.join(', ');
const stmt = db.prepare(`UPDATE ${table} SET ${placeholders} WHERE ${where}`);
stmt.run(Array.from(values.values()), (err) => {
if (err) {
console.error('Error reading the database:', err.message);
console.error('Error updating the database:', err.message);
reject(err);
} else {
resolve(rows);
return;
}
});
stmt.finalize();
resolve();
});
db.close((err) => {
if (err) {
console.error('Error closing the database:', err.message);
reject(err);
}
});
});
};

export const queryDB = async (query: string) => {
return new Promise((resolve, reject) => {
const db = new Database(DATABASE_PATH);
db.serialize(() => {
db.all(query, (err, rows) => {
if (err) {
console.error('Error reading the database:', err.message);
reject(err);
} else {
resolve(rows);
}
});
});
});
};

/**
* Returns a list of all files in the storage folder which does match the given prefix and suffix.
* The returned file names still include the prefix and suffix.
Expand Down