From 24c861d5361875ea09b7fd3c8ff06dce1eb60b43 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Tue, 18 Nov 2025 00:46:21 +0100 Subject: [PATCH 01/15] Remove (unused) logs property from match data --- backend/src/match.ts | 1 - backend/src/routes.ts | 68 ------------------------------------- backend/swagger.json | 79 ------------------------------------------- common/types/index.ts | 1 - common/types/log.ts | 23 ------------- common/types/match.ts | 2 -- 6 files changed, 174 deletions(-) delete mode 100644 common/types/log.ts diff --git a/backend/src/match.ts b/backend/src/match.ts index 219ea3b..bfc76f1 100644 --- a/backend/src/match.ts +++ b/backend/src/match.ts @@ -97,7 +97,6 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS parseIncomingLogs: false, matchMaps: [], currentMap: 0, - logs: [], players: [], rconCommands: { init: dto.rconCommands?.init ?? [], diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 08d6440..80e2cf7 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -370,64 +370,6 @@ const models: TsoaRoute.Models = { }, }, // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - TLogType: { - dataType: 'refAlias', - type: { - dataType: 'union', - subSchemas: [ - { dataType: 'enum', enums: ['CHAT'] }, - { dataType: 'enum', enums: ['SYSTEM'] }, - ], - validators: {}, - }, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - ILogChat: { - dataType: 'refObject', - properties: { - type: { dataType: 'enum', enums: ['CHAT'], required: true }, - timestamp: { dataType: 'double', required: true }, - isTeamChat: { dataType: 'boolean', required: true }, - steamId64: { dataType: 'string', required: true }, - message: { dataType: 'string', required: true }, - }, - additionalProperties: false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - TSystemLogCategory: { - dataType: 'refAlias', - type: { - dataType: 'union', - subSchemas: [ - { dataType: 'enum', enums: ['ERROR'] }, - { dataType: 'enum', enums: ['WARN'] }, - { dataType: 'enum', enums: ['INFO'] }, - { dataType: 'enum', enums: ['DEBUG'] }, - ], - validators: {}, - }, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - ILogSystem: { - dataType: 'refObject', - properties: { - type: { dataType: 'enum', enums: ['SYSTEM'], required: true }, - timestamp: { dataType: 'double', required: true }, - category: { ref: 'TSystemLogCategory', required: true }, - message: { dataType: 'string', required: true }, - }, - additionalProperties: false, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - TLogUnion: { - dataType: 'refAlias', - type: { - dataType: 'union', - subSchemas: [{ ref: 'ILogChat' }, { ref: 'ILogSystem' }], - validators: {}, - }, - }, - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa TTeamSides: { dataType: 'refAlias', type: { @@ -520,11 +462,6 @@ const models: TsoaRoute.Models = { }, canClinch: { dataType: 'boolean', required: true }, matchEndAction: { ref: 'TMatchEndAction', required: true }, - logs: { - dataType: 'array', - array: { dataType: 'refAlias', ref: 'TLogUnion' }, - required: true, - }, players: { dataType: 'array', array: { dataType: 'refObject', ref: 'IPlayer' }, @@ -657,11 +594,6 @@ const models: TsoaRoute.Models = { }, canClinch: { dataType: 'boolean', required: true }, matchEndAction: { ref: 'TMatchEndAction', required: true }, - logs: { - dataType: 'array', - array: { dataType: 'refAlias', ref: 'TLogUnion' }, - required: true, - }, players: { dataType: 'array', array: { dataType: 'refObject', ref: 'IPlayer' }, diff --git a/backend/swagger.json b/backend/swagger.json index dbb3d35..a1aa47e 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -449,71 +449,6 @@ "type": "string", "enum": ["KICK_ALL", "QUIT_SERVER", "NONE"] }, - "TLogType": { - "type": "string", - "enum": ["CHAT", "SYSTEM"] - }, - "ILogChat": { - "properties": { - "type": { - "type": "string", - "enum": ["CHAT"], - "nullable": false - }, - "timestamp": { - "type": "number", - "format": "double" - }, - "isTeamChat": { - "type": "boolean" - }, - "steamId64": { - "type": "string" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "timestamp", "isTeamChat", "steamId64", "message"], - "type": "object", - "additionalProperties": false - }, - "TSystemLogCategory": { - "type": "string", - "enum": ["ERROR", "WARN", "INFO", "DEBUG"] - }, - "ILogSystem": { - "properties": { - "type": { - "type": "string", - "enum": ["SYSTEM"], - "nullable": false - }, - "timestamp": { - "type": "number", - "format": "double" - }, - "category": { - "$ref": "#/components/schemas/TSystemLogCategory" - }, - "message": { - "type": "string" - } - }, - "required": ["type", "timestamp", "category", "message"], - "type": "object", - "additionalProperties": false - }, - "TLogUnion": { - "anyOf": [ - { - "$ref": "#/components/schemas/ILogChat" - }, - { - "$ref": "#/components/schemas/ILogSystem" - } - ] - }, "TTeamSides": { "type": "string", "enum": ["CT", "T"] @@ -674,12 +609,6 @@ "$ref": "#/components/schemas/TMatchEndAction", "description": "defaults to NONE" }, - "logs": { - "items": { - "$ref": "#/components/schemas/TLogUnion" - }, - "type": "array" - }, "players": { "items": { "$ref": "#/components/schemas/IPlayer" @@ -735,7 +664,6 @@ "rconCommands", "canClinch", "matchEndAction", - "logs", "players", "tmtSecret", "isStopped", @@ -999,12 +927,6 @@ "$ref": "#/components/schemas/TMatchEndAction", "description": "defaults to NONE" }, - "logs": { - "items": { - "$ref": "#/components/schemas/TLogUnion" - }, - "type": "array" - }, "players": { "items": { "$ref": "#/components/schemas/IPlayer" @@ -1064,7 +986,6 @@ "rconCommands", "canClinch", "matchEndAction", - "logs", "players", "tmtSecret", "isStopped", diff --git a/common/types/index.ts b/common/types/index.ts index ee427de..0b7d56b 100644 --- a/common/types/index.ts +++ b/common/types/index.ts @@ -4,7 +4,6 @@ export * from './election'; export * from './electionStep'; export * from './events'; export * from './gameServer'; -export * from './log'; export * from './match'; export * from './matchMap'; export * from './player'; diff --git a/common/types/log.ts b/common/types/log.ts deleted file mode 100644 index c24913f..0000000 --- a/common/types/log.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type TLogType = 'CHAT' | 'SYSTEM'; - -export interface ILog { - type: TLogType; - timestamp: number; -} - -export interface ILogChat extends ILog { - type: 'CHAT'; - isTeamChat: boolean; - steamId64: string; - message: string; -} - -export type TSystemLogCategory = 'ERROR' | 'WARN' | 'INFO' | 'DEBUG'; - -export interface ILogSystem extends ILog { - type: 'SYSTEM'; - category: TSystemLogCategory; - message: string; -} - -export type TLogUnion = ILogChat | ILogSystem; diff --git a/common/types/match.ts b/common/types/match.ts index af44a17..b7dc6b3 100644 --- a/common/types/match.ts +++ b/common/types/match.ts @@ -1,7 +1,6 @@ import { IElection } from './election'; import { IElectionStep, IElectionStepAdd, IElectionStepSkip } from './electionStep'; import { IGameServer } from './gameServer'; -import { TLogUnion } from './log'; import { IMatchMap } from './matchMap'; import { IPlayer } from './player'; import { ITeam, ITeamCreateDto } from './team'; @@ -96,7 +95,6 @@ export interface IMatch { canClinch: boolean; /** defaults to NONE */ matchEndAction: TMatchEndAction; - logs: TLogUnion[]; players: IPlayer[]; /** Access token to be used in the API. */ tmtSecret: string; From 71b93c8666d9db67f291af12d2dfbfe40dac20b2 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Tue, 18 Nov 2025 00:54:10 +0100 Subject: [PATCH 02/15] Remove HTTP endpoint to retrieve logs for a specific match --- backend/src/match.ts | 11 --------- backend/src/matchesController.ts | 8 ------- backend/src/routes.ts | 41 -------------------------------- backend/swagger.json | 36 ---------------------------- 4 files changed, 96 deletions(-) diff --git a/backend/src/match.ts b/backend/src/match.ts index bfc76f1..575bc50 100644 --- a/backend/src/match.ts +++ b/backend/src/match.ts @@ -29,8 +29,6 @@ import { Settings } from './settings'; import * as Storage from './storage'; import * as Team from './team'; -const STORAGE_LOGS_PREFIX = 'logs_'; -const STORAGE_LOGS_SUFFIX = '.jsonl'; const SAY_PREFIX = GameServer.colors.green + Settings.SAY_PREFIX + GameServer.colors.white; export interface Match { @@ -132,7 +130,6 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS const createLogger = (match: Match) => (msg: string) => { const ds = new Date().toISOString(); msg = GameServer.removeColors(msg); - Storage.appendLine(STORAGE_LOGS_PREFIX + match.data.id + STORAGE_LOGS_SUFFIX, `${ds} | ${msg}`); console.info(`${ds} [${match.data.id}] ${msg}`); Events.onLog(match, msg); }; @@ -141,14 +138,6 @@ const createOnDataChangeHandler = (match: Match) => (path: Array => { - return await Storage.readLines( - STORAGE_LOGS_PREFIX + matchId + STORAGE_LOGS_SUFFIX, - [], - numberOfLines - ); -}; - const connectToGameServer = async (match: Match): Promise => { const addr = `${match.data.gameServer.ip}:${match.data.gameServer.port}`; match.log(`Connect rcon ${addr}`); diff --git a/backend/src/matchesController.ts b/backend/src/matchesController.ts index f05945f..d06fbed 100644 --- a/backend/src/matchesController.ts +++ b/backend/src/matchesController.ts @@ -127,14 +127,6 @@ export class MatchesController extends Controller { return; } - /** - * Get the last 1000 log lines from a specific match. - */ - @Get('{id}/logs') - async getLogs(id: string, @Request() req: ExpressRequest): Promise { - return await Match.getLogsTail(id); - } - /** * Get the last 1000 events from a specific match. */ diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 80e2cf7..f399c1c 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -1360,47 +1360,6 @@ export function RegisterRoutes(app: Router) { } ); // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - const argsMatchesController_getLogs: Record = { - id: { in: 'path', name: 'id', required: true, dataType: 'string' }, - req: { in: 'request', name: 'req', required: true, dataType: 'object' }, - }; - app.get( - '/api/matches/:id/logs', - authenticateMiddleware([{ bearer_token: [] }]), - ...fetchMiddlewares(MatchesController), - ...fetchMiddlewares(MatchesController.prototype.getLogs), - - async function MatchesController_getLogs( - request: ExRequest, - response: ExResponse, - next: any - ) { - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa - - let validatedArgs: any[] = []; - try { - validatedArgs = templateService.getValidatedArgs({ - args: argsMatchesController_getLogs, - request, - response, - }); - - const controller = new MatchesController(); - - await templateService.apiHandler({ - methodName: 'getLogs', - controller, - response, - next, - validatedArgs, - successStatus: undefined, - }); - } catch (err) { - return next(err); - } - } - ); - // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa const argsMatchesController_getEvents: Record = { id: { in: 'path', name: 'id', required: true, dataType: 'string' }, req: { in: 'request', name: 'req', required: true, dataType: 'object' }, diff --git a/backend/swagger.json b/backend/swagger.json index a1aa47e..d9668a1 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -2271,42 +2271,6 @@ ] } }, - "/api/matches/{id}/logs": { - "get": { - "operationId": "GetLogs", - "responses": { - "200": { - "description": "Ok", - "content": { - "application/json": { - "schema": { - "items": { - "type": "string" - }, - "type": "array" - } - } - } - } - }, - "description": "Get the last 1000 log lines from a specific match.", - "security": [ - { - "bearer_token": [] - } - ], - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string" - } - } - ] - } - }, "/api/matches/{id}/events": { "get": { "operationId": "GetEvents", From ed11de4d3da4c7cc8f8093e6870f08c533679df0 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Wed, 19 Nov 2025 00:38:49 +0100 Subject: [PATCH 03/15] Make all optional fields in API responses always present (might be null then). --- backend/src/election.ts | 32 ++- backend/src/events.ts | 12 +- backend/src/gameServer.ts | 7 +- backend/src/gameServersController.ts | 2 +- backend/src/match.ts | 7 +- backend/src/matchMap.ts | 5 +- backend/src/player.ts | 6 +- backend/src/routes.ts | 261 ++++++++++++++---- backend/src/team.ts | 2 + backend/swagger.json | 183 +++++++++--- common/types/election.ts | 6 +- common/types/events.ts | 12 +- common/types/gameServer.ts | 14 +- common/types/match.ts | 40 +-- common/types/matchMap.ts | 6 +- common/types/player.ts | 6 +- common/types/preset.ts | 2 +- common/types/team.ts | 10 +- frontend/src/components/CreateUpdateMatch.tsx | 2 +- 19 files changed, 463 insertions(+), 152 deletions(-) diff --git a/backend/src/election.ts b/backend/src/election.ts index 2983695..b585ff2 100644 --- a/backend/src/election.ts +++ b/backend/src/election.ts @@ -29,10 +29,10 @@ export const create = ( state: 'NOT_STARTED', currentStep: 0, currentSubStep: 'MAP', - teamX: undefined, - teamY: undefined, + teamX: null, + teamY: null, remainingMaps: mapPool.map((map) => map.toLowerCase()), - currentStepMap: undefined, + currentStepMap: null, currentAgree: { teamA: null, teamB: null, @@ -306,7 +306,7 @@ const ensureTeamXY = (match: Match.Match, who: TWho, teamAB: TTeamAB) => { /** * returns undefined if both teams are valid */ -const getValidTeamAB = (match: Match.Match, who: TWho): TTeamAB | undefined => { +const getValidTeamAB = (match: Match.Match, who: TWho): TTeamAB | null => { switch (who) { case 'TEAM_A': return 'TEAM_A'; @@ -348,7 +348,7 @@ const onAgreeCommand: commands.CommandHandler = async (e) => { match.data.election.currentAgree.teamB !== null && match.data.election.currentAgree.teamA === match.data.election.currentAgree.teamB ) { - match.data.election.currentStepMap = match.data.election.remainingMaps[matchMap]; + match.data.election.currentStepMap = match.data.election.remainingMaps[matchMap]!; match.data.election.currentAgree.teamA = null; match.data.election.currentAgree.teamB = null; match.data.election.remainingMaps.splice(matchMap, 1); @@ -623,7 +623,7 @@ export const auto = async (match: Match.Match) => { const autoMap = async (match: Match.Match, currentElectionStep: IElectionStep) => { if (currentElectionStep.map.mode === 'FIXED') { match.data.election.currentStepMap = currentElectionStep.map.fixed; - Events.onElectionMapStep(match, 'FIXED', match.data.election.currentStepMap); + Events.onElectionMapStep(match, 'FIXED', match.data.election.currentStepMap, null); await Match.say( match, `${match.data.matchMaps.length + 1}. MAP: ${formatMapName( @@ -643,11 +643,12 @@ const autoMap = async (match: Match.Match, currentElectionStep: IElectionStep) = match.data.election.remainingMaps.length ); if (currentElectionStep.map.mode === 'RANDOM_PICK') { - match.data.election.currentStepMap = match.data.election.remainingMaps[matchMap]; + match.data.election.currentStepMap = match.data.election.remainingMaps[matchMap]!; Events.onElectionMapStep( match, 'RANDOM_PICK', - match.data.election.remainingMaps[matchMap]! + match.data.election.remainingMaps[matchMap]!, + null ); await Match.say( match, @@ -659,7 +660,8 @@ const autoMap = async (match: Match.Match, currentElectionStep: IElectionStep) = Events.onElectionMapStep( match, 'RANDOM_BAN', - match.data.election.remainingMaps[matchMap]! + match.data.election.remainingMaps[matchMap]!, + null ); await Match.say( match, @@ -685,6 +687,8 @@ const autoSide = async (match: Match.Match, currentElectionStep: IElectionStep) Events.onElectionSideStep(match, 'FIXED', { ctTeam: Match.getTeamByAB(match, 'TEAM_A'), tTeam: Match.getTeamByAB(match, 'TEAM_B'), + pickerTeam: null, + pickerSide: null, }); match.data.matchMaps.push(MatchMap.create(currentStepMap, false, 'TEAM_A')); await Match.say( @@ -701,6 +705,8 @@ const autoSide = async (match: Match.Match, currentElectionStep: IElectionStep) Events.onElectionSideStep(match, 'FIXED', { ctTeam: Match.getTeamByAB(match, 'TEAM_B'), tTeam: Match.getTeamByAB(match, 'TEAM_A'), + pickerTeam: null, + pickerSide: null, }); match.data.matchMaps.push(MatchMap.create(currentStepMap, false, 'TEAM_B')); await Match.say( @@ -718,6 +724,8 @@ const autoSide = async (match: Match.Match, currentElectionStep: IElectionStep) Events.onElectionSideStep(match, 'FIXED', { ctTeam: Match.getTeamByAB(match, match.data.election.teamX), tTeam: Match.getTeamByAB(match, match.data.election.teamY), + pickerTeam: null, + pickerSide: null, }); match.data.matchMaps.push( MatchMap.create(currentStepMap, false, match.data.election.teamX) @@ -738,6 +746,8 @@ const autoSide = async (match: Match.Match, currentElectionStep: IElectionStep) Events.onElectionSideStep(match, 'FIXED', { ctTeam: Match.getTeamByAB(match, match.data.election.teamY), tTeam: Match.getTeamByAB(match, match.data.election.teamX), + pickerTeam: null, + pickerSide: null, }); match.data.matchMaps.push( MatchMap.create(currentStepMap, false, match.data.election.teamY) @@ -757,7 +767,7 @@ const autoSide = async (match: Match.Match, currentElectionStep: IElectionStep) } if (currentElectionStep.side.mode === 'KNIFE') { - Events.onElectionSideStep(match, 'KNIFE'); + Events.onElectionSideStep(match, 'KNIFE', null); match.data.matchMaps.push(MatchMap.create(currentStepMap, true)); await Match.say( match, @@ -774,6 +784,8 @@ const autoSide = async (match: Match.Match, currentElectionStep: IElectionStep) Events.onElectionSideStep(match, 'RANDOM', { ctTeam: Match.getTeamByAB(match, startAsCtTeam), tTeam: Match.getTeamByAB(match, getOtherTeamAB(startAsCtTeam)), + pickerTeam: null, + pickerSide: null, }); match.data.matchMaps.push(MatchMap.create(currentStepMap, false, startAsCtTeam)); await Match.say( diff --git a/backend/src/events.ts b/backend/src/events.ts index 61a75b5..e9dc1f5 100644 --- a/backend/src/events.ts +++ b/backend/src/events.ts @@ -88,7 +88,7 @@ export const onElectionMapStep = ( match: Match.Match, mode: TMapMode, mapName: string, - pickerTeam?: ITeam + pickerTeam: ITeam | null ) => { const data: ElectionMapStep = { ...getBaseEvent(match, 'ELECTION_MAP_STEP'), @@ -102,14 +102,15 @@ export const onElectionMapStep = ( export const onElectionSideStep = ( match: Match.Match, mode: TSideMode, - options?: Omit + options: Omit | null ) => { const data: ElectionSideStep = { ...getBaseEvent(match, 'ELECTION_SIDE_STEP'), mode: mode, - pickerTeam: options?.pickerTeam, - ctTeam: options?.ctTeam, - tTeam: options?.tTeam, + pickerTeam: options?.pickerTeam ?? null, + pickerSide: options?.pickerSide ?? null, + ctTeam: options?.ctTeam ?? null, + tTeam: options?.tTeam ?? null, }; send(match, data); }; @@ -163,6 +164,7 @@ export const onConsoleSay = (match: Match.Match, message: string) => { playerTeam: null, message: message, isTeamChat: false, + teamString: null, }; send(match, data); }; diff --git a/backend/src/gameServer.ts b/backend/src/gameServer.ts index 7f77b2d..bee7770 100644 --- a/backend/src/gameServer.ts +++ b/backend/src/gameServer.ts @@ -21,7 +21,10 @@ export const removeColors = (string: string) => { return string; }; -export const create = async (dto: IGameServer, log: (msg: string) => void): Promise => { +export const create = async ( + dto: Pick, + log: (msg: string) => void +): Promise => { const rcon = new Rcon({ host: dto.ip, port: dto.port, @@ -120,6 +123,6 @@ export const disconnect = async (match: Match.Match) => { } }; -export const formatMapName = (mapName: string | undefined) => { +export const formatMapName = (mapName: string | null | undefined) => { return colors.grey + parseMapParts(mapName ?? '').external + colors.white; }; diff --git a/backend/src/gameServersController.ts b/backend/src/gameServersController.ts index 1d33979..967fe41 100644 --- a/backend/src/gameServersController.ts +++ b/backend/src/gameServersController.ts @@ -35,8 +35,8 @@ export class GameServersController extends Controller { @Body() requestBody: IManagedGameServerCreateDto ): Promise { const managedGameServer: IManagedGameServer = { - canBeUsed: true, ...requestBody, + canBeUsed: requestBody.canBeUsed ?? true, usedBy: null, }; if (!requestBody.ip) { diff --git a/backend/src/match.ts b/backend/src/match.ts index 575bc50..ebd572e 100644 --- a/backend/src/match.ts +++ b/backend/src/match.ts @@ -88,6 +88,7 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS ...dto, gameServer: gameServer, id: id, + passthrough: dto.passthrough ?? null, teamA: Team.createFromCreateDto(dto.teamA), teamB: Team.createFromCreateDto(dto.teamB), state: 'ELECTION', @@ -108,7 +109,7 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS isStopped: false, tmtSecret: shortUuid(), serverPassword: '', - tmtLogAddress: dto.tmtLogAddress, + tmtLogAddress: dto.tmtLogAddress ?? null, createdAt: Date.now(), lastSavedAt: 0, webhookUrl: dto.webhookUrl ?? null, @@ -1116,7 +1117,7 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { await ensureLogAddressIsRegistered(match); } - if (dto.currentMap !== undefined && dto.currentMap !== match.data.currentMap) { + if (typeof dto.currentMap === 'number' && dto.currentMap !== match.data.currentMap) { const nextMap = match.data.matchMaps[dto.currentMap]; if (nextMap) { match.data.currentMap = dto.currentMap; @@ -1149,7 +1150,7 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { match.data.rconCommands.end = dto.rconCommands.end; } - if (dto.canClinch !== undefined) { + if (typeof dto.canClinch === 'boolean') { match.data.canClinch = dto.canClinch; if (isMatchEnd(match)) { await onMatchEnd(match); diff --git a/backend/src/matchMap.ts b/backend/src/matchMap.ts index ea37df0..0b4da40 100644 --- a/backend/src/matchMap.ts +++ b/backend/src/matchMap.ts @@ -22,6 +22,7 @@ export const create = ( ): IMatchMap => { return { knifeForSide: knifeForSide, + knifeWinner: null, knifeRestart: { teamA: false, teamB: false, @@ -210,7 +211,7 @@ export const loadMap = async (match: Match.Match, matchMap: IMatchMap, useDefaul matchMap.knifeRestart.teamB = false; matchMap.score.teamA = 0; matchMap.score.teamB = 0; - matchMap.knifeWinner = undefined; + matchMap.knifeWinner = null; MatchService.scheduleSave(match); }; @@ -296,7 +297,7 @@ const startKnifeRound = async (match: Match.Match, matchMap: IMatchMap) => { matchMap.state = 'KNIFE'; matchMap.knifeRestart.teamA = false; matchMap.knifeRestart.teamB = false; - matchMap.knifeWinner = undefined; + matchMap.knifeWinner = null; MatchService.scheduleSave(match); match.log('Start knife round'); await Match.execRconCommands(match, 'knife'); diff --git a/backend/src/player.ts b/backend/src/player.ts index 9116123..244edab 100644 --- a/backend/src/player.ts +++ b/backend/src/player.ts @@ -8,6 +8,8 @@ export const create = (match: Match.Match, steamId: string, name: string): IPlay name: name, steamId64: steamId64, team: getForcedTeam(match, steamId64), + side: null, + online: null, }; }; @@ -15,13 +17,13 @@ export const getSteamID64 = (steamId: string) => { return new SteamID(steamId).getSteamID64(); }; -export const getForcedTeam = (match: Match.Match, steamId64: string): TTeamAB | undefined => { +export const getForcedTeam = (match: Match.Match, steamId64: string): TTeamAB | null => { const isTeamA = match.data.teamA.playerSteamIds64?.includes(steamId64); const isTeamB = match.data.teamB.playerSteamIds64?.includes(steamId64); if (isTeamA === isTeamB) { // either: configured for no teams // or: configured for both teams - return undefined; + return null; } return isTeamA ? 'TEAM_A' : 'TEAM_B'; }; diff --git a/backend/src/routes.ts b/backend/src/routes.ts index f399c1c..80cfcfd 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -45,10 +45,14 @@ const models: TsoaRoute.Models = { ITeam: { dataType: 'refObject', properties: { - passthrough: { dataType: 'string' }, + passthrough: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + required: true, + }, name: { dataType: 'string', required: true }, advantage: { dataType: 'double', required: true }, - playerSteamIds64: { dataType: 'array', array: { dataType: 'string' } }, + playerSteamIds64: { dataType: 'array', array: { dataType: 'string' }, required: true }, }, additionalProperties: false, }, @@ -255,12 +259,24 @@ const models: TsoaRoute.Models = { dataType: 'refObject', properties: { state: { ref: 'TElectionState', required: true }, - teamX: { ref: 'TTeamAB' }, - teamY: { ref: 'TTeamAB' }, + teamX: { + dataType: 'union', + subSchemas: [{ ref: 'TTeamAB' }, { dataType: 'enum', enums: [null] }], + required: true, + }, + teamY: { + dataType: 'union', + subSchemas: [{ ref: 'TTeamAB' }, { dataType: 'enum', enums: [null] }], + required: true, + }, remainingMaps: { dataType: 'array', array: { dataType: 'string' }, required: true }, currentStep: { dataType: 'double', required: true }, currentSubStep: { ref: 'TStep', required: true }, - currentStepMap: { dataType: 'string' }, + currentStepMap: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + required: true, + }, currentAgree: { dataType: 'nestedObjectLiteral', nestedProperties: { @@ -295,7 +311,7 @@ const models: TsoaRoute.Models = { ip: { dataType: 'string', required: true }, port: { dataType: 'double', required: true }, rconPassword: { dataType: 'string', required: true }, - hideRconPassword: { dataType: 'boolean' }, + hideRconPassword: { dataType: 'boolean', required: true }, }, additionalProperties: false, }, @@ -325,7 +341,11 @@ const models: TsoaRoute.Models = { knifeForSide: { dataType: 'boolean', required: true }, startAsCtTeam: { ref: 'TTeamAB', required: true }, state: { ref: 'TMatchMapSate', required: true }, - knifeWinner: { ref: 'TTeamAB' }, + knifeWinner: { + dataType: 'union', + subSchemas: [{ ref: 'TTeamAB' }, { dataType: 'enum', enums: [null] }], + required: true, + }, readyTeams: { dataType: 'nestedObjectLiteral', nestedProperties: { @@ -387,12 +407,21 @@ const models: TsoaRoute.Models = { properties: { steamId64: { dataType: 'string', required: true }, name: { dataType: 'string', required: true }, - team: { ref: 'TTeamAB' }, + team: { + dataType: 'union', + subSchemas: [{ ref: 'TTeamAB' }, { dataType: 'enum', enums: [null] }], + required: true, + }, side: { dataType: 'union', subSchemas: [{ ref: 'TTeamSides' }, { dataType: 'enum', enums: [null] }], + required: true, + }, + online: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + required: true, }, - online: { dataType: 'boolean' }, }, additionalProperties: false, }, @@ -414,7 +443,11 @@ const models: TsoaRoute.Models = { properties: { id: { dataType: 'string', required: true }, state: { ref: 'TMatchState', required: true }, - passthrough: { dataType: 'string' }, + passthrough: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + required: true, + }, mapPool: { dataType: 'array', array: { dataType: 'string' }, required: true }, teamA: { ref: 'ITeam', required: true }, teamB: { ref: 'ITeam', required: true }, @@ -470,9 +503,17 @@ const models: TsoaRoute.Models = { tmtSecret: { dataType: 'string', required: true }, isStopped: { dataType: 'boolean', required: true }, serverPassword: { dataType: 'string', required: true }, - tmtLogAddress: { dataType: 'string' }, + tmtLogAddress: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + required: true, + }, createdAt: { dataType: 'double', required: true }, - lastSavedAt: { dataType: 'double' }, + lastSavedAt: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + required: true, + }, mode: { ref: 'TMatchMode', required: true }, }, additionalProperties: false, @@ -482,9 +523,21 @@ const models: TsoaRoute.Models = { dataType: 'refObject', properties: { name: { dataType: 'string', required: true }, - passthrough: { dataType: 'string' }, - advantage: { dataType: 'double' }, - playerSteamIds64: { dataType: 'array', array: { dataType: 'string' } }, + passthrough: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + }, + advantage: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + }, + playerSteamIds64: { + dataType: 'union', + subSchemas: [ + { dataType: 'array', array: { dataType: 'string' } }, + { dataType: 'enum', enums: [null] }, + ], + }, }, additionalProperties: false, }, @@ -492,7 +545,10 @@ const models: TsoaRoute.Models = { IMatchCreateDto: { dataType: 'refObject', properties: { - passthrough: { dataType: 'string' }, + passthrough: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + }, mapPool: { dataType: 'array', array: { dataType: 'string' }, required: true }, teamA: { ref: 'ITeamCreateDto', required: true }, teamB: { ref: 'ITeamCreateDto', required: true }, @@ -527,16 +583,52 @@ const models: TsoaRoute.Models = { rconCommands: { dataType: 'nestedObjectLiteral', nestedProperties: { - end: { dataType: 'array', array: { dataType: 'string' } }, - match: { dataType: 'array', array: { dataType: 'string' } }, - knife: { dataType: 'array', array: { dataType: 'string' } }, - init: { dataType: 'array', array: { dataType: 'string' } }, + end: { + dataType: 'union', + subSchemas: [ + { dataType: 'array', array: { dataType: 'string' } }, + { dataType: 'enum', enums: [null] }, + ], + }, + match: { + dataType: 'union', + subSchemas: [ + { dataType: 'array', array: { dataType: 'string' } }, + { dataType: 'enum', enums: [null] }, + ], + }, + knife: { + dataType: 'union', + subSchemas: [ + { dataType: 'array', array: { dataType: 'string' } }, + { dataType: 'enum', enums: [null] }, + ], + }, + init: { + dataType: 'union', + subSchemas: [ + { dataType: 'array', array: { dataType: 'string' } }, + { dataType: 'enum', enums: [null] }, + ], + }, }, }, - canClinch: { dataType: 'boolean' }, - matchEndAction: { ref: 'TMatchEndAction' }, - tmtLogAddress: { dataType: 'string' }, - mode: { ref: 'TMatchMode' }, + canClinch: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, + matchEndAction: { + dataType: 'union', + subSchemas: [{ ref: 'TMatchEndAction' }, { dataType: 'enum', enums: [null] }], + }, + tmtLogAddress: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + }, + mode: { + dataType: 'union', + subSchemas: [{ ref: 'TMatchMode' }, { dataType: 'enum', enums: [null] }], + }, }, additionalProperties: false, }, @@ -546,7 +638,11 @@ const models: TsoaRoute.Models = { properties: { id: { dataType: 'string', required: true }, state: { ref: 'TMatchState', required: true }, - passthrough: { dataType: 'string' }, + passthrough: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + required: true, + }, mapPool: { dataType: 'array', array: { dataType: 'string' }, required: true }, teamA: { ref: 'ITeam', required: true }, teamB: { ref: 'ITeam', required: true }, @@ -602,9 +698,17 @@ const models: TsoaRoute.Models = { tmtSecret: { dataType: 'string', required: true }, isStopped: { dataType: 'boolean', required: true }, serverPassword: { dataType: 'string', required: true }, - tmtLogAddress: { dataType: 'string' }, + tmtLogAddress: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + required: true, + }, createdAt: { dataType: 'double', required: true }, - lastSavedAt: { dataType: 'double' }, + lastSavedAt: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + required: true, + }, mode: { ref: 'TMatchMode', required: true }, isLive: { dataType: 'boolean', required: true }, }, @@ -672,7 +776,11 @@ const models: TsoaRoute.Models = { }, message: { dataType: 'string', required: true }, isTeamChat: { dataType: 'boolean', required: true }, - teamString: { ref: 'TTeamString' }, + teamString: { + dataType: 'union', + subSchemas: [{ ref: 'TTeamString' }, { dataType: 'enum', enums: [null] }], + required: true, + }, }, additionalProperties: false, }, @@ -861,7 +969,11 @@ const models: TsoaRoute.Models = { type: { dataType: 'enum', enums: ['ELECTION_MAP_STEP'], required: true }, mode: { ref: 'TMapMode', required: true }, mapName: { dataType: 'string', required: true }, - pickerTeam: { ref: 'ITeam' }, + pickerTeam: { + dataType: 'union', + subSchemas: [{ ref: 'ITeam' }, { dataType: 'enum', enums: [null] }], + required: true, + }, }, additionalProperties: false, }, @@ -892,10 +1004,26 @@ const models: TsoaRoute.Models = { }, type: { dataType: 'enum', enums: ['ELECTION_SIDE_STEP'], required: true }, mode: { ref: 'TSideMode', required: true }, - pickerTeam: { ref: 'ITeam' }, - pickerSide: { ref: 'TTeamSides' }, - ctTeam: { ref: 'ITeam' }, - tTeam: { ref: 'ITeam' }, + pickerTeam: { + dataType: 'union', + subSchemas: [{ ref: 'ITeam' }, { dataType: 'enum', enums: [null] }], + required: true, + }, + pickerSide: { + dataType: 'union', + subSchemas: [{ ref: 'TTeamSides' }, { dataType: 'enum', enums: [null] }], + required: true, + }, + ctTeam: { + dataType: 'union', + subSchemas: [{ ref: 'ITeam' }, { dataType: 'enum', enums: [null] }], + required: true, + }, + tTeam: { + dataType: 'union', + subSchemas: [{ ref: 'ITeam' }, { dataType: 'enum', enums: [null] }], + required: true, + }, }, additionalProperties: false, }, @@ -1019,14 +1147,38 @@ const models: TsoaRoute.Models = { }, tmtLogAddress: { dataType: 'string' }, mode: { ref: 'TMatchMode' }, - state: { ref: 'TMatchState' }, - logSecret: { dataType: 'string' }, - currentMap: { dataType: 'double' }, - _restartElection: { dataType: 'boolean' }, - _execRconCommandsInit: { dataType: 'boolean' }, - _execRconCommandsKnife: { dataType: 'boolean' }, - _execRconCommandsMatch: { dataType: 'boolean' }, - _execRconCommandsEnd: { dataType: 'boolean' }, + state: { + dataType: 'union', + subSchemas: [{ ref: 'TMatchState' }, { dataType: 'enum', enums: [null] }], + }, + logSecret: { + dataType: 'union', + subSchemas: [{ dataType: 'string' }, { dataType: 'enum', enums: [null] }], + }, + currentMap: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + }, + _restartElection: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, + _execRconCommandsInit: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, + _execRconCommandsKnife: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, + _execRconCommandsMatch: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, + _execRconCommandsEnd: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, }, additionalProperties: false, }, @@ -1063,8 +1215,14 @@ const models: TsoaRoute.Models = { overTimeEnabled: { dataType: 'boolean' }, overTimeMaxRounds: { dataType: 'double' }, maxRounds: { dataType: 'double' }, - _refreshOvertimeAndMaxRoundsSettings: { dataType: 'boolean' }, - _switchTeamInternals: { dataType: 'boolean' }, + _refreshOvertimeAndMaxRoundsSettings: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, + _switchTeamInternals: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, }, additionalProperties: false, }, @@ -1075,7 +1233,6 @@ const models: TsoaRoute.Models = { ip: { dataType: 'string', required: true }, port: { dataType: 'double', required: true }, rconPassword: { dataType: 'string', required: true }, - hideRconPassword: { dataType: 'boolean' }, canBeUsed: { dataType: 'boolean', required: true }, usedBy: { dataType: 'union', @@ -1092,8 +1249,10 @@ const models: TsoaRoute.Models = { ip: { dataType: 'string', required: true }, port: { dataType: 'double', required: true }, rconPassword: { dataType: 'string', required: true }, - hideRconPassword: { dataType: 'boolean' }, - canBeUsed: { dataType: 'boolean' }, + canBeUsed: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, }, additionalProperties: false, }, @@ -1164,7 +1323,10 @@ const models: TsoaRoute.Models = { dataType: 'refObject', properties: { name: { dataType: 'string', required: true }, - isPublic: { dataType: 'boolean' }, + isPublic: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, data: { ref: 'IMatchCreateDto', required: true }, id: { dataType: 'string', required: true }, }, @@ -1175,7 +1337,10 @@ const models: TsoaRoute.Models = { dataType: 'refObject', properties: { name: { dataType: 'string', required: true }, - isPublic: { dataType: 'boolean' }, + isPublic: { + dataType: 'union', + subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], + }, data: { ref: 'IMatchCreateDto', required: true }, }, additionalProperties: false, diff --git a/backend/src/team.ts b/backend/src/team.ts index e7f0881..16f4f0b 100644 --- a/backend/src/team.ts +++ b/backend/src/team.ts @@ -9,5 +9,7 @@ export const createFromCreateDto = (dto: ITeamCreateDto): ITeam => { return { ...dto, advantage: dto.advantage ?? 0, + passthrough: dto.passthrough ?? null, + playerSteamIds64: dto.playerSteamIds64 ?? [], }; }; diff --git a/backend/swagger.json b/backend/swagger.json index d9668a1..c08b900 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -17,6 +17,7 @@ "properties": { "passthrough": { "type": "string", + "nullable": true, "description": "Passthrough data to identify team in other systems.\nWill be present in every response/webhook." }, "name": { @@ -36,7 +37,7 @@ "description": "Steam ids of players in \"Steam ID 64\" format. Will be forced into this team." } }, - "required": ["name", "advantage"], + "required": ["passthrough", "name", "advantage", "playerSteamIds64"], "type": "object", "additionalProperties": false }, @@ -256,10 +257,20 @@ "$ref": "#/components/schemas/TElectionState" }, "teamX": { - "$ref": "#/components/schemas/TTeamAB" + "allOf": [ + { + "$ref": "#/components/schemas/TTeamAB" + } + ], + "nullable": true }, "teamY": { - "$ref": "#/components/schemas/TTeamAB" + "allOf": [ + { + "$ref": "#/components/schemas/TTeamAB" + } + ], + "nullable": true }, "remainingMaps": { "items": { @@ -279,6 +290,7 @@ }, "currentStepMap": { "type": "string", + "nullable": true, "description": "Current set map of the current selection step." }, "currentAgree": { @@ -312,9 +324,12 @@ }, "required": [ "state", + "teamX", + "teamY", "remainingMaps", "currentStep", "currentSubStep", + "currentStepMap", "currentAgree", "currentRestart" ], @@ -338,7 +353,7 @@ "description": "If plebs (client without an admin token) create a match the hideRconPassword attribute is set to true.\nThis will prevent executing rcon commands from the frontend by the (unauthorized) user." } }, - "required": ["ip", "port", "rconPassword"], + "required": ["ip", "port", "rconPassword", "hideRconPassword"], "type": "object", "additionalProperties": false }, @@ -373,7 +388,12 @@ "$ref": "#/components/schemas/TMatchMapSate" }, "knifeWinner": { - "$ref": "#/components/schemas/TTeamAB", + "allOf": [ + { + "$ref": "#/components/schemas/TTeamAB" + } + ], + "nullable": true, "description": "Winner of the knife round which is able to or already has picked a starting side." }, "readyTeams": { @@ -435,6 +455,7 @@ "knifeForSide", "startAsCtTeam", "state", + "knifeWinner", "readyTeams", "knifeRestart", "score", @@ -465,7 +486,12 @@ "description": "Name." }, "team": { - "$ref": "#/components/schemas/TTeamAB", + "allOf": [ + { + "$ref": "#/components/schemas/TTeamAB" + } + ], + "nullable": true, "description": "Current team as they joined with `.team`.\nIf the player's steam id is in the team's `playerSteamIds64`\nthis cannot be changed and is always set to the team." }, "side": { @@ -479,10 +505,11 @@ }, "online": { "type": "boolean", + "nullable": true, "description": "Player currently on the game server (online)?" } }, - "required": ["steamId64", "name"], + "required": ["steamId64", "name", "team", "side", "online"], "type": "object", "additionalProperties": false }, @@ -502,6 +529,7 @@ }, "passthrough": { "type": "string", + "nullable": true, "description": "e.g. remote identifier, will be present in every response/webhook" }, "mapPool": { @@ -629,6 +657,7 @@ }, "tmtLogAddress": { "type": "string", + "nullable": true, "description": "if set will be used to register the target logaddress for the game server" }, "createdAt": { @@ -639,6 +668,7 @@ "lastSavedAt": { "type": "number", "format": "double", + "nullable": true, "description": "Last time the match was saved to disk (unix time in milliseconds since midnight, January 1, 1970 UTC)" }, "mode": { @@ -649,6 +679,7 @@ "required": [ "id", "state", + "passthrough", "mapPool", "teamA", "teamB", @@ -668,7 +699,9 @@ "tmtSecret", "isStopped", "serverPassword", + "tmtLogAddress", "createdAt", + "lastSavedAt", "mode" ], "type": "object", @@ -682,11 +715,13 @@ }, "passthrough": { "type": "string", + "nullable": true, "description": "Passthrough data to identify team in other systems.\nWill be present in every response/webhook." }, "advantage": { "type": "number", "format": "double", + "nullable": true, "description": "Advantage in map wins, useful for double elimination tournament finals." }, "playerSteamIds64": { @@ -694,6 +729,7 @@ "type": "string" }, "type": "array", + "nullable": true, "description": "Steam ids of players in \"Steam ID 64\" format. Will be forced into this team." } }, @@ -705,6 +741,7 @@ "properties": { "passthrough": { "type": "string", + "nullable": true, "description": "e.g. remote identifier, will be present in every response/webhook" }, "mapPool": { @@ -762,6 +799,7 @@ "type": "string" }, "type": "array", + "nullable": true, "description": "executed after last match map" }, "match": { @@ -769,6 +807,7 @@ "type": "string" }, "type": "array", + "nullable": true, "description": "executed before every match map start" }, "knife": { @@ -776,6 +815,7 @@ "type": "string" }, "type": "array", + "nullable": true, "description": "executed before every knife round" }, "init": { @@ -783,6 +823,7 @@ "type": "string" }, "type": "array", + "nullable": true, "description": "executed exactly once on match init" } }, @@ -790,18 +831,30 @@ }, "canClinch": { "type": "boolean", + "nullable": true, "description": "defaults to true, means that possibly not all maps will be played if the winner is determined before" }, "matchEndAction": { - "$ref": "#/components/schemas/TMatchEndAction", + "allOf": [ + { + "$ref": "#/components/schemas/TMatchEndAction" + } + ], + "nullable": true, "description": "defaults to NONE" }, "tmtLogAddress": { "type": "string", + "nullable": true, "description": "if set will be used to register the target logaddress for the game server" }, "mode": { - "$ref": "#/components/schemas/TMatchMode", + "allOf": [ + { + "$ref": "#/components/schemas/TMatchMode" + } + ], + "nullable": true, "description": "Match mode (single: stops when match is finished, loop: starts again after match is finished)" } }, @@ -820,6 +873,7 @@ }, "passthrough": { "type": "string", + "nullable": true, "description": "e.g. remote identifier, will be present in every response/webhook" }, "mapPool": { @@ -947,6 +1001,7 @@ }, "tmtLogAddress": { "type": "string", + "nullable": true, "description": "if set will be used to register the target logaddress for the game server" }, "createdAt": { @@ -957,6 +1012,7 @@ "lastSavedAt": { "type": "number", "format": "double", + "nullable": true, "description": "Last time the match was saved to disk (unix time in milliseconds since midnight, January 1, 1970 UTC)" }, "mode": { @@ -971,6 +1027,7 @@ "required": [ "id", "state", + "passthrough", "mapPool", "teamA", "teamB", @@ -990,7 +1047,9 @@ "tmtSecret", "isStopped", "serverPassword", + "tmtLogAddress", "createdAt", + "lastSavedAt", "mode", "isLive" ], @@ -1063,7 +1122,12 @@ "type": "boolean" }, "teamString": { - "$ref": "#/components/schemas/TTeamString" + "allOf": [ + { + "$ref": "#/components/schemas/TTeamString" + } + ], + "nullable": true } }, "required": [ @@ -1074,7 +1138,8 @@ "player", "playerTeam", "message", - "isTeamChat" + "isTeamChat", + "teamString" ], "type": "object", "additionalProperties": false @@ -1461,10 +1526,23 @@ "type": "string" }, "pickerTeam": { - "$ref": "#/components/schemas/ITeam" + "allOf": [ + { + "$ref": "#/components/schemas/ITeam" + } + ], + "nullable": true } }, - "required": ["timestamp", "matchId", "matchPassthrough", "type", "mode", "mapName"], + "required": [ + "timestamp", + "matchId", + "matchPassthrough", + "type", + "mode", + "mapName", + "pickerTeam" + ], "type": "object", "additionalProperties": false }, @@ -1495,19 +1573,49 @@ "$ref": "#/components/schemas/TSideMode" }, "pickerTeam": { - "$ref": "#/components/schemas/ITeam" + "allOf": [ + { + "$ref": "#/components/schemas/ITeam" + } + ], + "nullable": true }, "pickerSide": { - "$ref": "#/components/schemas/TTeamSides" + "allOf": [ + { + "$ref": "#/components/schemas/TTeamSides" + } + ], + "nullable": true }, "ctTeam": { - "$ref": "#/components/schemas/ITeam" + "allOf": [ + { + "$ref": "#/components/schemas/ITeam" + } + ], + "nullable": true }, "tTeam": { - "$ref": "#/components/schemas/ITeam" + "allOf": [ + { + "$ref": "#/components/schemas/ITeam" + } + ], + "nullable": true } }, - "required": ["timestamp", "matchId", "matchPassthrough", "type", "mode"], + "required": [ + "timestamp", + "matchId", + "matchPassthrough", + "type", + "mode", + "pickerTeam", + "pickerSide", + "ctTeam", + "tTeam" + ], "type": "object", "additionalProperties": false }, @@ -1735,33 +1843,45 @@ "description": "Match mode (single: stops when match is finished, loop: starts again after match is finished)" }, "state": { - "$ref": "#/components/schemas/TMatchState", + "allOf": [ + { + "$ref": "#/components/schemas/TMatchState" + } + ], + "nullable": true, "description": "Overwrite the match state.\nOnly sets the state. Does not execute any code/logic." }, "logSecret": { "type": "string", + "nullable": true, "description": "updates the server's log address automatically" }, "currentMap": { "type": "number", "format": "double", + "nullable": true, "description": "Change to this match map (0-based index)." }, "_restartElection": { "type": "boolean", + "nullable": true, "description": "Restart the complete match.\nWill restart the election process as well.\nMust be executed when the election steps were changed after the match was created." }, "_execRconCommandsInit": { - "type": "boolean" + "type": "boolean", + "nullable": true }, "_execRconCommandsKnife": { - "type": "boolean" + "type": "boolean", + "nullable": true }, "_execRconCommandsMatch": { - "type": "boolean" + "type": "boolean", + "nullable": true }, "_execRconCommandsEnd": { - "type": "boolean" + "type": "boolean", + "nullable": true } }, "type": "object", @@ -1843,10 +1963,12 @@ }, "_refreshOvertimeAndMaxRoundsSettings": { "type": "boolean", + "nullable": true, "description": "reads and refreshes mp_overtime_enable, mp_overtime_maxrounds and mp_maxrounds from rcon" }, "_switchTeamInternals": { "type": "boolean", + "nullable": true, "description": "switch team internals, i.e. swap team names (and internal score)" } }, @@ -1865,10 +1987,6 @@ "rconPassword": { "type": "string" }, - "hideRconPassword": { - "type": "boolean", - "description": "If plebs (client without an admin token) create a match the hideRconPassword attribute is set to true.\nThis will prevent executing rcon commands from the frontend by the (unauthorized) user." - }, "canBeUsed": { "type": "boolean", "description": "Can the server be used for new matches?" @@ -1895,12 +2013,9 @@ "rconPassword": { "type": "string" }, - "hideRconPassword": { - "type": "boolean", - "description": "If plebs (client without an admin token) create a match the hideRconPassword attribute is set to true.\nThis will prevent executing rcon commands from the frontend by the (unauthorized) user." - }, "canBeUsed": { "type": "boolean", + "nullable": true, "description": "Can the server be used for new matches?" } }, @@ -2004,7 +2119,8 @@ "type": "string" }, "isPublic": { - "type": "boolean" + "type": "boolean", + "nullable": true }, "data": { "$ref": "#/components/schemas/IMatchCreateDto" @@ -2023,7 +2139,8 @@ "type": "string" }, "isPublic": { - "type": "boolean" + "type": "boolean", + "nullable": true }, "data": { "$ref": "#/components/schemas/IMatchCreateDto" diff --git a/common/types/election.ts b/common/types/election.ts index 169b7bb..0b5eaee 100644 --- a/common/types/election.ts +++ b/common/types/election.ts @@ -6,8 +6,8 @@ export type TStep = 'MAP' | 'SIDE'; export interface IElection { state: TElectionState; - teamX?: TTeamAB; - teamY?: TTeamAB; + teamX: TTeamAB | null; + teamY: TTeamAB | null; /** Will be the same as the mapPool from the match, but will shrink when maps get picked, banned or randomly chosen. */ remainingMaps: string[]; /** Index of the current electionSteps of the match. */ @@ -15,7 +15,7 @@ export interface IElection { /** Toggles between MAP and SIDE */ currentSubStep: TStep; /** Current set map of the current selection step. */ - currentStepMap?: string; + currentStepMap: string | null; /** Holds the wanted maps of each team. */ currentAgree: { teamA: string | null; diff --git a/common/types/events.ts b/common/types/events.ts index 50666b2..9761734 100644 --- a/common/types/events.ts +++ b/common/types/events.ts @@ -37,7 +37,7 @@ export interface ChatEvent extends BaseEvent { playerTeam: ITeam | null; message: string; isTeamChat: boolean; - teamString?: TTeamString; + teamString: TTeamString | null; } export interface ElectionEndEvent extends BaseEvent { @@ -114,16 +114,16 @@ export interface ElectionMapStep extends BaseEvent { type: 'ELECTION_MAP_STEP'; mode: TMapMode; mapName: string; - pickerTeam?: ITeam; + pickerTeam: ITeam | null; } export interface ElectionSideStep extends BaseEvent { type: 'ELECTION_SIDE_STEP'; mode: TSideMode; - pickerTeam?: ITeam; - pickerSide?: TTeamSides; - ctTeam?: ITeam; - tTeam?: ITeam; + pickerTeam: ITeam | null; + pickerSide: TTeamSides | null; + ctTeam: ITeam | null; + tTeam: ITeam | null; } export interface MatchCreateEvent extends BaseEvent { diff --git a/common/types/gameServer.ts b/common/types/gameServer.ts index fc12e82..c1eea67 100644 --- a/common/types/gameServer.ts +++ b/common/types/gameServer.ts @@ -8,19 +8,25 @@ export interface IGameServer { * If plebs (client without an admin token) create a match the hideRconPassword attribute is set to true. * This will prevent executing rcon commands from the frontend by the (unauthorized) user. */ - hideRconPassword?: boolean; + hideRconPassword: boolean; } -export interface IManagedGameServer extends IGameServer { +export interface IManagedGameServer { + ip: string; + port: number; + rconPassword: string; /** Can the server be used for new matches? */ canBeUsed: boolean; /** Match id which is currently using this managed game server. */ usedBy: IMatch['id'] | null; } -export interface IManagedGameServerCreateDto extends IGameServer { +export interface IManagedGameServerCreateDto { + ip: string; + port: number; + rconPassword: string; /** Can the server be used for new matches? */ - canBeUsed?: boolean; + canBeUsed?: boolean | null; } export interface IManagedGameServerUpdateDto { diff --git a/common/types/match.ts b/common/types/match.ts index b7dc6b3..6b5721d 100644 --- a/common/types/match.ts +++ b/common/types/match.ts @@ -24,7 +24,7 @@ export interface IMatch { id: string; state: TMatchState; /** e.g. remote identifier, will be present in every response/webhook */ - passthrough?: string; + passthrough: string | null; /** * The maps the players can pick or ban. * Will also be used if a map is chosen randomly. @@ -103,11 +103,11 @@ export interface IMatch { /** Server password, periodically fetched from game server */ serverPassword: string; /** if set will be used to register the target logaddress for the game server */ - tmtLogAddress?: string; + tmtLogAddress: string | null; /** Creation date (unix time in milliseconds since midnight, January 1, 1970 UTC) */ createdAt: number; /** Last time the match was saved to disk (unix time in milliseconds since midnight, January 1, 1970 UTC) */ - lastSavedAt?: number; + lastSavedAt: number | null; /** Match mode (single: stops when match is finished, loop: starts again after match is finished) */ mode: TMatchMode; } @@ -119,7 +119,7 @@ export interface IMatchResponse extends IMatch { export interface IMatchCreateDto { /** e.g. remote identifier, will be present in every response/webhook */ - passthrough?: string; + passthrough?: string | null; /** * The maps the players can pick or ban. * Will also be used if a map is chosen randomly. @@ -152,22 +152,22 @@ export interface IMatchCreateDto { webhookHeaders?: { [key: string]: string } | null; rconCommands?: { /** executed exactly once on match init */ - init?: string[]; + init?: string[] | null; /** executed before every knife round */ - knife?: string[]; + knife?: string[] | null; /** executed before every match map start */ - match?: string[]; + match?: string[] | null; /** executed after last match map */ - end?: string[]; + end?: string[] | null; }; /** defaults to true, means that possibly not all maps will be played if the winner is determined before */ - canClinch?: boolean; + canClinch?: boolean | null; /** defaults to NONE */ - matchEndAction?: TMatchEndAction; + matchEndAction?: TMatchEndAction | null; /** if set will be used to register the target logaddress for the game server */ - tmtLogAddress?: string; + tmtLogAddress?: string | null; /** Match mode (single: stops when match is finished, loop: starts again after match is finished) */ - mode?: TMatchMode; + mode?: TMatchMode | null; } export interface IMatchUpdateDto extends Partial { @@ -175,22 +175,22 @@ export interface IMatchUpdateDto extends Partial { * Overwrite the match state. * Only sets the state. Does not execute any code/logic. */ - state?: TMatchState; + state?: TMatchState | null; /** updates the server's log address automatically */ - logSecret?: string; + logSecret?: string | null; /** * Change to this match map (0-based index). */ - currentMap?: number; + currentMap?: number | null; /** * Restart the complete match. * Will restart the election process as well. * Must be executed when the election steps were changed after the match was created. */ - _restartElection?: boolean; - _execRconCommandsInit?: boolean; - _execRconCommandsKnife?: boolean; - _execRconCommandsMatch?: boolean; - _execRconCommandsEnd?: boolean; + _restartElection?: boolean | null; + _execRconCommandsInit?: boolean | null; + _execRconCommandsKnife?: boolean | null; + _execRconCommandsMatch?: boolean | null; + _execRconCommandsEnd?: boolean | null; } diff --git a/common/types/matchMap.ts b/common/types/matchMap.ts index 989a507..e01f317 100644 --- a/common/types/matchMap.ts +++ b/common/types/matchMap.ts @@ -24,7 +24,7 @@ export interface IMatchMap { startAsCtTeam: TTeamAB; state: TMatchMapSate; /** Winner of the knife round which is able to or already has picked a starting side. */ - knifeWinner?: TTeamAB; + knifeWinner: TTeamAB | null; readyTeams: { teamA: boolean; teamB: boolean; @@ -51,7 +51,7 @@ export interface IMatchMap { */ export interface IMatchMapUpdateDto extends Partial { /** reads and refreshes mp_overtime_enable, mp_overtime_maxrounds and mp_maxrounds from rcon */ - _refreshOvertimeAndMaxRoundsSettings?: boolean; + _refreshOvertimeAndMaxRoundsSettings?: boolean | null; /** switch team internals, i.e. swap team names (and internal score) */ - _switchTeamInternals?: boolean; + _switchTeamInternals?: boolean | null; } diff --git a/common/types/player.ts b/common/types/player.ts index f26cfa5..96508e6 100644 --- a/common/types/player.ts +++ b/common/types/player.ts @@ -14,9 +14,9 @@ export interface IPlayer { * If the player's steam id is in the team's `playerSteamIds64` * this cannot be changed and is always set to the team. */ - team?: TTeamAB; + team: TTeamAB | null; /** Current ingame side. */ - side?: TTeamSides | null; + side: TTeamSides | null; /** Player currently on the game server (online)? */ - online?: boolean; + online: boolean | null; } diff --git a/common/types/preset.ts b/common/types/preset.ts index b4d6240..bd04cfa 100644 --- a/common/types/preset.ts +++ b/common/types/preset.ts @@ -2,7 +2,7 @@ import { IMatchCreateDto } from './match'; export interface IPresetCreateDto { name: string; - isPublic?: boolean; + isPublic?: boolean | null; data: IMatchCreateDto; } diff --git a/common/types/team.ts b/common/types/team.ts index 8641bf1..adba8e2 100644 --- a/common/types/team.ts +++ b/common/types/team.ts @@ -6,13 +6,13 @@ export interface ITeam { * Passthrough data to identify team in other systems. * Will be present in every response/webhook. */ - passthrough?: string; + passthrough: string | null; /** Team name. */ name: string; /** Advantage in map wins, useful for double elimination tournament finals. */ advantage: number; /** Steam ids of players in "Steam ID 64" format. Will be forced into this team.*/ - playerSteamIds64?: string[]; + playerSteamIds64: string[]; } /** @@ -24,11 +24,11 @@ export interface ITeamCreateDto { * Passthrough data to identify team in other systems. * Will be present in every response/webhook. */ - passthrough?: string; + passthrough?: string | null; /** Advantage in map wins, useful for double elimination tournament finals. */ - advantage?: number; + advantage?: number | null; /** Steam ids of players in "Steam ID 64" format. Will be forced into this team.*/ - playerSteamIds64?: string[]; + playerSteamIds64?: string[] | null; } /** Possible ingame sides of a player. */ diff --git a/frontend/src/components/CreateUpdateMatch.tsx b/frontend/src/components/CreateUpdateMatch.tsx index 860638a..ab7dc58 100644 --- a/frontend/src/components/CreateUpdateMatch.tsx +++ b/frontend/src/components/CreateUpdateMatch.tsx @@ -924,7 +924,7 @@ export const CreateUpdateMatch: Component< labelTopRight={t( 'Ends match series after a map if a winner is determined' )} - checked={dto.canClinch} + checked={dto.canClinch ?? undefined} class={getChangedClasses( props.match.canClinch, dto.canClinch, From 35581401d1899984e4de9d672478ac0871ed4294 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Wed, 19 Nov 2025 00:50:56 +0100 Subject: [PATCH 04/15] Reorg: move some type around --- common/types/events.ts | 3 +-- common/types/index.ts | 1 - common/types/player.ts | 3 +-- common/types/stuff.ts | 1 - common/types/team.ts | 2 ++ 5 files changed, 4 insertions(+), 6 deletions(-) delete mode 100644 common/types/stuff.ts diff --git a/common/types/events.ts b/common/types/events.ts index 9761734..e8dcf31 100644 --- a/common/types/events.ts +++ b/common/types/events.ts @@ -1,8 +1,7 @@ import { TMapMode, TSideMode } from './electionStep'; import { IMatchResponse } from './match'; import { IPlayer } from './player'; -import { TTeamSides } from './stuff'; -import { ITeam, TTeamString } from './team'; +import { ITeam, TTeamSides, TTeamString } from './team'; export type EventType = | 'CHAT' diff --git a/common/types/index.ts b/common/types/index.ts index 0b7d56b..5dc5eb4 100644 --- a/common/types/index.ts +++ b/common/types/index.ts @@ -8,6 +8,5 @@ export * from './match'; export * from './matchMap'; export * from './player'; export * from './preset'; -export * from './stuff'; export * from './team'; export * from './webSocket'; diff --git a/common/types/player.ts b/common/types/player.ts index 96508e6..386dc5a 100644 --- a/common/types/player.ts +++ b/common/types/player.ts @@ -1,5 +1,4 @@ -import { TTeamSides } from './stuff'; -import { TTeamAB } from './team'; +import { TTeamAB, TTeamSides } from './team'; /** * Player. diff --git a/common/types/stuff.ts b/common/types/stuff.ts deleted file mode 100644 index 54f117c..0000000 --- a/common/types/stuff.ts +++ /dev/null @@ -1 +0,0 @@ -export type TTeamSides = 'CT' | 'T'; diff --git a/common/types/team.ts b/common/types/team.ts index adba8e2..3c49efe 100644 --- a/common/types/team.ts +++ b/common/types/team.ts @@ -35,3 +35,5 @@ export interface ITeamCreateDto { export type TTeamString = 'Unassigned' | 'CT' | 'TERRORIST' | '' | 'Spectator'; export type TTeamAB = 'TEAM_A' | 'TEAM_B'; + +export type TTeamSides = 'CT' | 'T'; From 5ac77299eb069dc7e209bc8423bcdd3e41d39935 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Mon, 24 Nov 2025 00:39:48 +0100 Subject: [PATCH 05/15] Use SQLite database for matches, maps, players, events and managed game servers --- backend/package-lock.json | 403 ++++++++++++++++++++++++- backend/package.json | 2 + backend/src/auth.ts | 2 +- backend/src/database.ts | 12 + backend/src/events.ts | 77 ++++- backend/src/gameServersController.ts | 6 +- backend/src/index.ts | 4 +- backend/src/managedGameServers.ts | 93 ++++-- backend/src/match.ts | 278 ++++++++++++++++- backend/src/matchMap.ts | 107 +++++++ backend/src/matchService.ts | 84 ++++-- backend/src/matchesController.ts | 18 +- backend/src/migrations/01.ts | 432 +++++++++++++++++++++++++++ backend/src/player.ts | 59 ++++ backend/src/routes.ts | 45 +-- backend/src/storage.ts | 39 --- backend/swagger.json | 34 ++- common/types/match.ts | 8 +- 18 files changed, 1545 insertions(+), 158 deletions(-) create mode 100644 backend/src/database.ts create mode 100644 backend/src/migrations/01.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 8ab5432..84a8964 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@tsoa/runtime": "^6.6.0", + "better-sqlite3": "^12.4.1", "express": "^5.1.0", "short-uuid": "^5.2.0", "steamid": "^2.1.0", @@ -18,6 +19,7 @@ }, "devDependencies": { "@tsoa/cli": "^6.6.0", + "@types/better-sqlite3": "^7.6.13", "@types/debug": "^4.1.12", "@types/express": "^5.0.5", "@types/node": "^24.10.0", @@ -816,6 +818,16 @@ "@types/node": "*" } }, + "node_modules/@types/better-sqlite3": { + "version": "7.6.13", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", + "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -1125,6 +1137,40 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", + "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -1138,6 +1184,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", @@ -1181,6 +1247,30 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1244,6 +1334,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -1443,6 +1539,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -1462,6 +1582,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", @@ -1515,6 +1644,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1570,6 +1708,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", @@ -1612,6 +1759,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1677,6 +1830,12 @@ "node": ">= 0.8" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/fs-extra": { "version": "11.3.2", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", @@ -1763,6 +1922,12 @@ "node": ">= 0.4" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -1909,6 +2074,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore-by-default": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", @@ -1922,6 +2107,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2144,6 +2335,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -2164,7 +2367,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2180,12 +2382,24 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2202,6 +2416,18 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.80.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.80.0.tgz", + "integrity": "sha512-LyPuZJcI9HVwzXK1GPxWNzrr+vr8Hp/3UqlmWxxh8p54U1ZbclOqbSog9lWHaCX+dBaiGi6n/hIX+mKu74GmPA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/nodemon": { "version": "3.1.10", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", @@ -2364,6 +2590,32 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2384,6 +2636,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -2439,6 +2701,35 @@ "url": "https://opencollective.com/express" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -2524,7 +2815,6 @@ "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -2697,6 +2987,51 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -2738,6 +3073,15 @@ "node": ">=12.0.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -2842,6 +3186,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -2855,6 +3208,34 @@ "node": ">=4" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -2948,6 +3329,18 @@ "license": "0BSD", "optional": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", @@ -3032,6 +3425,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index ee46da8..d89f49f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "@tsoa/runtime": "^6.6.0", + "better-sqlite3": "^12.4.1", "express": "^5.1.0", "short-uuid": "^5.2.0", "steamid": "^2.1.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@tsoa/cli": "^6.6.0", + "@types/better-sqlite3": "^7.6.13", "@types/debug": "^4.1.12", "@types/express": "^5.0.5", "@types/node": "^24.10.0", diff --git a/backend/src/auth.ts b/backend/src/auth.ts index 920ad9a..de1b38f 100644 --- a/backend/src/auth.ts +++ b/backend/src/auth.ts @@ -77,7 +77,7 @@ export const isValidMatchToken = async (token?: string, matchId?: string) => { return match.data.tmtSecret === token; } - const matchFromStorage = await MatchService.getFromStorage(matchId); + const matchFromStorage = MatchService.getMatchFromDatabase(matchId); if (matchFromStorage) { return matchFromStorage.tmtSecret === token; } diff --git a/backend/src/database.ts b/backend/src/database.ts new file mode 100644 index 0000000..91d4505 --- /dev/null +++ b/backend/src/database.ts @@ -0,0 +1,12 @@ +import path from 'node:path'; +import Database from 'better-sqlite3'; +import { STORAGE_FOLDER } from './storage'; +import { migration01 } from './migrations/01'; + +export const db = new Database(path.join(STORAGE_FOLDER, 'sqlite3.db')); +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +export const runMigrations = () => { + migration01(); +}; diff --git a/backend/src/events.ts b/backend/src/events.ts index e9dc1f5..3d1e4e9 100644 --- a/backend/src/events.ts +++ b/backend/src/events.ts @@ -22,18 +22,15 @@ import { TSideMode, TTeamString, } from '../../common'; +import { db } from './database'; import * as Match from './match'; import * as MatchService from './matchService'; import { Settings } from './settings'; -import * as Storage from './storage'; import * as WebSocket from './webSocket'; -const STORAGE_EVENTS_PREFIX = 'events_'; -const STORAGE_EVENTS_SUFFIX = '.jsonl'; - const send = (match: Match.Match, data: Event, isSystemEvent?: boolean) => { // Storage - Storage.appendLine(STORAGE_EVENTS_PREFIX + match.data.id + STORAGE_EVENTS_SUFFIX, data); + saveEventToDb(data); // WebSocket WebSocket.publish(data, isSystemEvent); @@ -56,14 +53,6 @@ const send = (match: Match.Match, data: Event, isSystemEvent?: boolean) => { } }; -export const getEventsTail = async (matchId: string, numberOfLines = 1000): Promise => { - return await Storage.readLines( - STORAGE_EVENTS_PREFIX + matchId + STORAGE_EVENTS_SUFFIX, - [], - numberOfLines - ); -}; - const getBaseEvent = ( match: Match.Match, type: T @@ -260,3 +249,65 @@ export const onMatchStop = (match: Match.Match) => { const data: MatchStopEvent = getBaseEvent(match, 'MATCH_STOP'); send(match, data); }; + +const eventToDb = (event: Event): TDbEvent => { + const copy: any = { ...event }; + delete copy.timestamp; + delete copy.matchId; + delete copy.matchPassthrough; + delete copy.type; + const payload = JSON.stringify(copy); + return { + timestamp: event.timestamp, + matchId: event.matchId, + matchPassthrough: event.matchPassthrough, + type: event.type, + payload: payload, + }; +}; + +const eventFromDb = (dbEvent: TDbEvent): Event => { + return { + ...JSON.parse((dbEvent as any).payload), + timestamp: dbEvent.timestamp, + matchId: dbEvent.matchId, + matchPassthrough: dbEvent.matchPassthrough, + type: dbEvent.type, + }; +}; + +type TDbEvent = { + timestamp: string; + matchId: string; + matchPassthrough: string | null; + type: string; + payload: string; +}; + +export const saveEventToDb = (event: Event) => { + db.prepare( + `INSERT INTO event ( + timestamp, + matchId, + matchPassthrough, + type, + payload + ) VALUEs ( + :timestamp, + :matchId, + :matchPassthrough, + :type, + :payload + )` + ).run(eventToDb(event)); +}; + +export const getLatestEventsFromDatabase = (matchId: string, numberOfEvents = 1000): Event[] => { + const rows = db + .prepare< + { matchId: string; numberOfEvents: number }, + TDbEvent + >(`SELECT * FROM event WHERE matchId = :matchId ORDER BY id DESC LIMIT :numberOfEvents`) + .all({ matchId: matchId, numberOfEvents: numberOfEvents }); + return rows.map(eventFromDb).reverse(); +}; diff --git a/backend/src/gameServersController.ts b/backend/src/gameServersController.ts index 967fe41..fe5b4d4 100644 --- a/backend/src/gameServersController.ts +++ b/backend/src/gameServersController.ts @@ -42,7 +42,7 @@ export class GameServersController extends Controller { if (!requestBody.ip) { throw 'invalid ip'; } - await ManagedGameServers.add(managedGameServer); + ManagedGameServers.add(managedGameServer); return managedGameServer; } @@ -55,7 +55,7 @@ export class GameServersController extends Controller { ip: string, port: number ): Promise { - return await ManagedGameServers.update(requestBody); + return ManagedGameServers.update(requestBody); } /** @@ -63,7 +63,7 @@ export class GameServersController extends Controller { */ @Delete('{ip}/{port}') async deleteGameServer(ip: string, port: number): Promise { - await ManagedGameServers.remove({ + ManagedGameServers.remove({ ip: ip, port: port, }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 03b7bee..da60ee8 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -14,6 +14,8 @@ import * as Presets from './presets'; import { RegisterRoutes } from './routes'; import * as Storage from './storage'; import * as WebSocket from './webSocket'; +import * as Database from './database'; +Database.runMigrations(); export const TMT_LOG_ADDRESS: string | null = (() => { if (!process.env['TMT_LOG_ADDRESS']) { @@ -127,7 +129,7 @@ const main = async () => { await Storage.setup(); await Auth.setup(); await WebSocket.setup(httpServer); - await ManagedGameServers.setup(); + ManagedGameServers.setup(); await Presets.setup(); Match.registerCommandHandlers(); MatchMap.registerCommandHandlers(); diff --git a/backend/src/managedGameServers.ts b/backend/src/managedGameServers.ts index 198018b..ee4064f 100644 --- a/backend/src/managedGameServers.ts +++ b/backend/src/managedGameServers.ts @@ -1,21 +1,18 @@ import { IGameServer, IManagedGameServer, IManagedGameServerUpdateDto } from '../../common'; +import { db } from './database'; import * as GameServer from './gameServer'; -import * as Storage from './storage'; -const FILE_NAME = 'managed_game_servers.json'; const managedGameServers = new Map(); -const write = async () => { - await Storage.write(FILE_NAME, Array.from(managedGameServers.values())); -}; - const key = (gameServer: IManagedGameServerUpdateDto) => { return gameServer.ip + ':' + gameServer.port; }; -export const setup = async () => { - const data = await Storage.read(FILE_NAME, [] as IManagedGameServer[]); - data.forEach((managedGameServer) => add(managedGameServer, false)); +export const setup = () => { + const rows = db.prepare<[], TDbManagedGameServer>('SELECT * FROM managedGameServer').all(); + rows.map((row) => managedGameServerFromDb(row)).forEach((managedGameServer) => + add(managedGameServer, false) + ); }; export const get = (ip: string, port: number) => { @@ -26,41 +23,43 @@ export const getAll = () => { return Array.from(managedGameServers.values()); }; -export const add = async (managedGameServer: IManagedGameServer, writeToDisk = true) => { +export const add = (managedGameServer: IManagedGameServer, writeToDisk = true) => { if (managedGameServers.has(key(managedGameServer))) { throw 'this is already a managed game server'; } managedGameServers.set(key(managedGameServer), managedGameServer); if (writeToDisk) { - await write(); + saveManagedGameServerToDb(managedGameServer); } }; -export const update = async (dto: IManagedGameServerUpdateDto) => { +export const update = (dto: IManagedGameServerUpdateDto) => { const managedGameServer = managedGameServers.get(key(dto)); if (!managedGameServer) { throw 'this is not a managed game server'; } const updated = { ...managedGameServer, ...dto }; managedGameServers.set(key(dto), updated); - await write(); + saveManagedGameServerToDb(updated); return updated; }; -export const remove = async (gameServer: IManagedGameServerUpdateDto) => { +export const remove = (gameServer: IManagedGameServerUpdateDto) => { const removed = managedGameServers.delete(key(gameServer)); if (removed) { - await write(); + db.prepare<{ ip: string; port: number }>( + 'DELETE FROM managedGameServer WHERE ip = :ip AND port = :port' + ).run({ ip: gameServer.ip, port: gameServer.port }); } }; -export const getFree = async (matchId: string): Promise => { +export const getFree = (matchId: string): IGameServer | undefined => { const free = getAll().find( (managedGameServer) => managedGameServer.usedBy === null && managedGameServer.canBeUsed ); if (free) { free.usedBy = matchId; - await write(); + saveManagedGameServerToDb(free); return { ip: free.ip, port: free.port, @@ -68,14 +67,14 @@ export const getFree = async (matchId: string): Promise hideRconPassword: true, }; } - return; + return undefined; }; -export const free = async (gameServer: IManagedGameServerUpdateDto, matchId: string) => { +export const free = (gameServer: IManagedGameServerUpdateDto, matchId: string) => { const managedGameServer = managedGameServers.get(key(gameServer)); if (managedGameServer?.usedBy === matchId) { managedGameServer.usedBy = null; - await write(); + saveManagedGameServerToDb(managedGameServer); } }; @@ -103,3 +102,57 @@ export const execManyRcon = async (managedGameServer: IManagedGameServer, comman return responses; }; + +export type TDbManagedGameServer = { + ip: string; + port: number; + rconPassword: string; + canBeUsed: number; + usedBy: string | null; +}; + +const managedGameServerToDb = (managedGameServer: IManagedGameServer): TDbManagedGameServer => { + return { + ip: managedGameServer.ip, + port: managedGameServer.port, + rconPassword: managedGameServer.rconPassword, + canBeUsed: managedGameServer.canBeUsed ? 1 : 0, + usedBy: managedGameServer.usedBy, + }; +}; + +const managedGameServerFromDb = (dbManagedGameServer: TDbManagedGameServer): IManagedGameServer => { + return { + ip: dbManagedGameServer.ip, + port: dbManagedGameServer.port, + rconPassword: dbManagedGameServer.rconPassword, + canBeUsed: !!dbManagedGameServer.canBeUsed, + usedBy: dbManagedGameServer.usedBy, + }; +}; + +const saveManagedGameServerToDb = (managedGameServer: IManagedGameServer) => { + db.prepare( + ` + INSERT INTO managedGameServer ( + ip, + port, + rconPassword, + canBeUsed, + usedBy + ) VALUES ( + :ip, + :port, + :rconPassword, + :canBeUsed, + :usedBy + ) ON CONFLICT (ip, port) DO UPDATE SET + ip = :ip, + port = :port, + rconPassword = :rconPassword, + canBeUsed = :canBeUsed, + usedBy = :usedBy + WHERE ip = :ip AND port = :port + ` + ).run(managedGameServerToDb(managedGameServer)); +}; diff --git a/backend/src/match.ts b/backend/src/match.ts index ebd572e..d12f915 100644 --- a/backend/src/match.ts +++ b/backend/src/match.ts @@ -2,11 +2,16 @@ import { ValidateError } from '@tsoa/runtime'; import { generate as shortUuid } from 'short-uuid'; import { COMMIT_SHA, IMAGE_BUILD_TIMESTAMP, TMT_LOG_ADDRESS, VERSION } from '.'; import { + IElectionStep, + IGameServer, IMatch, IMatchCreateDto, IMatchUpdateDto, IPlayer, ITeam, + TMatchEndAction, + TMatchMode, + TMatchState, TTeamAB, TTeamString, escapeRconSayString, @@ -26,10 +31,10 @@ import * as MatchService from './matchService'; import * as Player from './player'; import { Rcon } from './rcon-client'; import { Settings } from './settings'; -import * as Storage from './storage'; import * as Team from './team'; +import { db } from './database'; -const SAY_PREFIX = GameServer.colors.green + Settings.SAY_PREFIX + GameServer.colors.white; +const SAY_PREFIX = () => GameServer.colors.green + Settings.SAY_PREFIX + GameServer.colors.white; export interface Match { data: IMatch; @@ -52,6 +57,7 @@ export const createFromData = async (data: IMatch, logMessage?: string) => { warnAboutWrongTeam: true, }; match.data = addChangeListener(data, createOnDataChangeHandler(match)); + await MatchService.save(data); match.log = createLogger(match); if (logMessage) { match.log(logMessage); @@ -80,7 +86,12 @@ export const createFromData = async (data: IMatch, logMessage?: string) => { }; export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logSecret: string) => { - const gameServer = dto.gameServer ?? (await ManagedGameServers.getFree(id)); + const gameServer = dto.gameServer + ? { + ...dto.gameServer, + hideRconPassword: false, + } + : ManagedGameServers.getFree(id); if (!gameServer) { throw 'no free game server available'; } @@ -113,7 +124,7 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS createdAt: Date.now(), lastSavedAt: 0, webhookUrl: dto.webhookUrl ?? null, - webhookHeaders: dto.webhookHeaders ?? null, + webhookHeaders: dto.webhookHeaders ?? {}, mode: dto.mode ?? 'SINGLE', }; try { @@ -121,8 +132,8 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS return match; } catch (err) { if (!dto.gameServer) { - await ManagedGameServers.free(gameServer, id); - await ManagedGameServers.update({ ...gameServer, canBeUsed: false }); + ManagedGameServers.free(gameServer, id); + ManagedGameServers.update({ ...gameServer, canBeUsed: false }); } throw err; } @@ -314,7 +325,7 @@ export const execRconCommands = async (match: Match, key: keyof IMatch['rconComm }; export const say = async (match: Match, message: string) => { - message = escapeRconSayString(SAY_PREFIX + message); + message = escapeRconSayString(SAY_PREFIX() + message); await execRcon(match, `say ${message}`); }; @@ -708,7 +719,7 @@ const onPlayerSay = async ( const onConsoleSay = async (match: Match, message: string) => { message = message.trim(); - if (!message.startsWith(SAY_PREFIX)) { + if (!message.startsWith(SAY_PREFIX())) { message = GameServer.removeColors(message); Events.onConsoleSay(match, message); } @@ -975,7 +986,7 @@ export const stop = async (match: Match) => { }); await say(match, `TMT IS OFFLINE`).catch(() => {}); await GameServer.disconnect(match); - await ManagedGameServers.free(match.data.gameServer, match.data.id); + ManagedGameServers.free(match.data.gameServer, match.data.id); Events.onMatchStop(match); }; @@ -1070,8 +1081,12 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { } if (dto.gameServer) { - await ManagedGameServers.free(match.data.gameServer, match.data.id); - match.data.gameServer = dto.gameServer; + ManagedGameServers.free(match.data.gameServer, match.data.id); + const gameServer: IGameServer = { + ...dto.gameServer, + hideRconPassword: match.data.gameServer.hideRconPassword, + }; + match.data.gameServer = gameServer; match.rconConnection?.end().catch((err) => { match.log(`Error end rcon connection ${err}`); }); @@ -1086,7 +1101,7 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { match.data.webhookUrl = dto.webhookUrl; } - if (dto.webhookHeaders !== undefined) { + if (dto.webhookHeaders) { match.data.webhookHeaders = dto.webhookHeaders; } @@ -1183,3 +1198,242 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { MatchService.scheduleSave(match); }; + +export type TDbMatch = { + id: string; + state: string; + passthrough: string | null; + mapPool: string; + teamAPassthrough: string | null; + teamAName: string; + teamAAdvantage: number; + teamAPlayerSteamIds64: string; + teamBPassthrough: string | null; + teamBName: string; + teamBAdvantage: number; + teamBPlayerSteamIds64: string; + electionSteps: string; + gameServerIp: string; + gameServerPort: number; + gameServerRconPassword: string; + gameServerHideRconPassword: number; + logSecret: string; + currentMap: number; + webhookUrl: string | null; + webhookHeaders: string; + rconCommandsInit: string; + rconCommandsKnife: string; + rconCommandsMatch: string; + rconCommandsEnd: string; + canClinch: number; + matchEndAction: string; + tmtSecret: string; + isStopped: number; + tmtLogAddress: string | null; + createdAt: number; + lastSavedAt: number; + mode: string; +}; + +const matchToDb = (match: IMatch): TDbMatch => { + return { + id: match.id, + state: match.state, + passthrough: match.passthrough, + mapPool: JSON.stringify(match.mapPool), + teamAPassthrough: match.teamA.passthrough, + teamAName: match.teamA.name, + teamAAdvantage: match.teamA.advantage, + teamAPlayerSteamIds64: JSON.stringify(match.teamA.playerSteamIds64), + teamBPassthrough: match.teamB.passthrough, + teamBName: match.teamB.name, + teamBAdvantage: match.teamB.advantage, + teamBPlayerSteamIds64: JSON.stringify(match.teamB.playerSteamIds64), + electionSteps: JSON.stringify(match.electionSteps), + gameServerIp: match.gameServer.ip, + gameServerPort: match.gameServer.port, + gameServerRconPassword: match.gameServer.rconPassword, + gameServerHideRconPassword: match.gameServer.hideRconPassword ? 1 : 0, + logSecret: match.logSecret, + currentMap: match.currentMap, + webhookUrl: match.webhookUrl, + webhookHeaders: JSON.stringify(match.webhookHeaders), + rconCommandsInit: JSON.stringify(match.rconCommands.init), + rconCommandsKnife: JSON.stringify(match.rconCommands.knife), + rconCommandsMatch: JSON.stringify(match.rconCommands.match), + rconCommandsEnd: JSON.stringify(match.rconCommands.end), + canClinch: match.canClinch ? 1 : 0, + matchEndAction: match.matchEndAction, + tmtSecret: match.tmtSecret, + isStopped: match.isStopped ? 1 : 0, + tmtLogAddress: match.tmtLogAddress, + createdAt: match.createdAt, + lastSavedAt: match.lastSavedAt ?? Date.now(), + mode: match.mode, + }; +}; + +export const matchFromDb = ( + dbMatch: TDbMatch, + dbMatchMaps: MatchMap.TDbMatchMap[], + dbMatchPlayers: Player.TDbMatchPlayer[] +): IMatch => { + const mapPool = JSON.parse(dbMatch.mapPool) as string[]; + const electionSteps = JSON.parse(dbMatch.electionSteps) as IElectionStep[]; + return { + id: dbMatch.id, + state: dbMatch.state as TMatchState, + passthrough: dbMatch.passthrough, + mapPool: mapPool, + teamA: { + passthrough: dbMatch.teamAPassthrough, + name: dbMatch.teamAName, + advantage: dbMatch.teamAAdvantage, + playerSteamIds64: JSON.parse(dbMatch.teamAPlayerSteamIds64) as string[], + }, + teamB: { + passthrough: dbMatch.teamBPassthrough, + name: dbMatch.teamBName, + advantage: dbMatch.teamBAdvantage, + playerSteamIds64: JSON.parse(dbMatch.teamBPlayerSteamIds64) as string[], + }, + parseIncomingLogs: false, + matchMaps: dbMatchMaps.map(MatchMap.matchMapFromDb), + players: dbMatchPlayers.map(Player.matchPlayerFromDb), + electionSteps: electionSteps, + election: Election.create(mapPool, electionSteps), + gameServer: { + ip: dbMatch.gameServerIp, + port: dbMatch.gameServerPort, + rconPassword: dbMatch.gameServerRconPassword, + hideRconPassword: !!dbMatch.gameServerHideRconPassword, + }, + logSecret: dbMatch.logSecret, + currentMap: dbMatch.currentMap, + webhookUrl: dbMatch.webhookUrl, + webhookHeaders: JSON.parse(dbMatch.webhookHeaders) as { [key: string]: string }, + rconCommands: { + init: JSON.parse(dbMatch.rconCommandsInit) as string[], + knife: JSON.parse(dbMatch.rconCommandsKnife) as string[], + match: JSON.parse(dbMatch.rconCommandsMatch) as string[], + end: JSON.parse(dbMatch.rconCommandsEnd) as string[], + }, + canClinch: !!dbMatch.canClinch, + matchEndAction: dbMatch.matchEndAction as TMatchEndAction, + tmtSecret: dbMatch.tmtSecret, + isStopped: !!dbMatch.isStopped, + tmtLogAddress: dbMatch.tmtLogAddress, + createdAt: dbMatch.createdAt, + mode: dbMatch.mode as TMatchMode, + serverPassword: '', + lastSavedAt: 0, + }; +}; + +export const saveMatchToDb = (match: IMatch) => { + db.prepare( + `INSERT INTO match ( + id, + state, + passthrough, + mapPool, + teamAPassthrough, + teamAName, + teamAAdvantage, + teamAPlayerSteamIds64, + teamBPassthrough, + teamBName, + teamBAdvantage, + teamBPlayerSteamIds64, + electionSteps, + gameServerIp, + gameServerPort, + gameServerRconPassword, + gameServerHideRconPassword, + logSecret, + currentMap, + webhookUrl, + webhookHeaders, + rconCommandsInit, + rconCommandsKnife, + rconCommandsMatch, + rconCommandsEnd, + canClinch, + matchEndAction, + tmtSecret, + isStopped, + tmtLogAddress, + createdAt, + lastSavedAt, + mode + ) VALUES ( + :id, + :state, + :passthrough, + :mapPool, + :teamAPassthrough, + :teamAName, + :teamAAdvantage, + :teamAPlayerSteamIds64, + :teamBPassthrough, + :teamBName, + :teamBAdvantage, + :teamBPlayerSteamIds64, + :electionSteps, + :gameServerIp, + :gameServerPort, + :gameServerRconPassword, + :gameServerHideRconPassword, + :logSecret, + :currentMap, + :webhookUrl, + :webhookHeaders, + :rconCommandsInit, + :rconCommandsKnife, + :rconCommandsMatch, + :rconCommandsEnd, + :canClinch, + :matchEndAction, + :tmtSecret, + :isStopped, + :tmtLogAddress, + :createdAt, + :lastSavedAt, + :mode + ) ON CONFLICT (id) DO UPDATE SET + state = :state, + passthrough = :passthrough, + mapPool = :mapPool, + teamAPassthrough = :teamAPassthrough, + teamAName = :teamAName, + teamAAdvantage = :teamAAdvantage, + teamAPlayerSteamIds64 = :teamAPlayerSteamIds64, + teamBPassthrough = :teamBPassthrough, + teamBName = :teamBName, + teamBAdvantage = :teamBAdvantage, + teamBPlayerSteamIds64 = :teamBPlayerSteamIds64, + electionSteps = :electionSteps, + gameServerIp = :gameServerIp, + gameServerPort = :gameServerPort, + gameServerRconPassword = :gameServerRconPassword, + gameServerHideRconPassword = :gameServerHideRconPassword, + logSecret = :logSecret, + currentMap = :currentMap, + webhookUrl = :webhookUrl, + webhookHeaders = :webhookHeaders, + rconCommandsInit = :rconCommandsInit, + rconCommandsKnife = :rconCommandsKnife, + rconCommandsMatch = :rconCommandsMatch, + rconCommandsEnd = :rconCommandsEnd, + canClinch = :canClinch, + matchEndAction = :matchEndAction, + tmtSecret = :tmtSecret, + isStopped = :isStopped, + tmtLogAddress = :tmtLogAddress, + createdAt = :createdAt, + lastSavedAt = :lastSavedAt, + mode = :mode + WHERE id = :id + ` + ).run(matchToDb(match)); +}; diff --git a/backend/src/matchMap.ts b/backend/src/matchMap.ts index 0b4da40..ba689da 100644 --- a/backend/src/matchMap.ts +++ b/backend/src/matchMap.ts @@ -10,6 +10,7 @@ import { TTeamSides, } from '../../common'; import * as commands from './commands'; +import { db } from './database'; import * as Events from './events'; import { colors, formatMapName } from './gameServer'; import * as Match from './match'; @@ -644,3 +645,109 @@ export const update = async ( MatchService.scheduleSave(match); }; + +export type TDbMatchMap = { + matchId: string; + index: number; + name: string; + knifeForSide: number; + startAsCtTeam: string; + state: string; + knifeWinner: string | null; + readyTeamA: number; + readyTeamB: number; + knifeRestartTeamA: number; + knifeRestartTeamB: number; + scoreTeamA: number; + scoreTeamB: number; +}; + +const matchMapToDb = (matchId: string, matchMap: IMatchMap, index: number): TDbMatchMap => { + return { + matchId: matchId, + index: index, + name: matchMap.name, + knifeForSide: matchMap.knifeForSide ? 1 : 0, + startAsCtTeam: matchMap.startAsCtTeam, + state: matchMap.state, + knifeWinner: matchMap.knifeWinner, + readyTeamA: matchMap.readyTeams.teamA ? 1 : 0, + readyTeamB: matchMap.readyTeams.teamB ? 1 : 0, + knifeRestartTeamA: matchMap.knifeRestart.teamA ? 1 : 0, + knifeRestartTeamB: matchMap.knifeRestart.teamB ? 1 : 0, + scoreTeamA: matchMap.score.teamA, + scoreTeamB: matchMap.score.teamB, + }; +}; + +export const matchMapFromDb = (dbMatchMap: TDbMatchMap): IMatchMap => { + return { + name: dbMatchMap.name, + knifeForSide: !!dbMatchMap.knifeForSide, + startAsCtTeam: dbMatchMap.startAsCtTeam as TTeamAB, + state: dbMatchMap.state as TMatchMapSate, + knifeWinner: dbMatchMap.knifeWinner as TTeamAB | null, + readyTeams: { + teamA: !!dbMatchMap.readyTeamA, + teamB: !!dbMatchMap.readyTeamB, + }, + knifeRestart: { + teamA: !!dbMatchMap.knifeRestartTeamA, + teamB: !!dbMatchMap.knifeRestartTeamB, + }, + score: { + teamA: dbMatchMap.scoreTeamA, + teamB: dbMatchMap.scoreTeamB, + }, + overTimeEnabled: true, + overTimeMaxRounds: 6, + maxRounds: 30, + }; +}; + +export const saveMatchMapToDb = (matchId: string, matchMap: IMatchMap, index: number) => { + db.prepare( + `INSERT INTO matchMap ( + matchId, + "index", + name, + knifeForSide, + startAsCtTeam, + state, + knifeWinner, + readyTeamA, + readyTeamB, + knifeRestartTeamA, + knifeRestartTeamB, + scoreTeamA, + scoreTeamB + ) VALUES ( + :matchId, + :index, + :name, + :knifeForSide, + :startAsCtTeam, + :state, + :knifeWinner, + :readyTeamA, + :readyTeamB, + :knifeRestartTeamA, + :knifeRestartTeamB, + :scoreTeamA, + :scoreTeamB + ) ON CONFLICT (matchId, "index") DO UPDATE SET + name = :name, + knifeForSide = :knifeForSide, + startAsCtTeam = :startAsCtTeam, + state = :state, + knifeWinner = :knifeWinner, + readyTeamA = :readyTeamA, + readyTeamB = :readyTeamB, + knifeRestartTeamA = :knifeRestartTeamA, + knifeRestartTeamB = :knifeRestartTeamB, + scoreTeamA = :scoreTeamA, + scoreTeamB = :scoreTeamB + WHERE matchId = :matchId AND "index" = :index + ` + ).run(matchMapToDb(matchId, matchMap, index)); +}; diff --git a/backend/src/matchService.ts b/backend/src/matchService.ts index 1c45d17..9f16420 100644 --- a/backend/src/matchService.ts +++ b/backend/src/matchService.ts @@ -2,10 +2,9 @@ import { generate as shortUuid } from 'short-uuid'; import { IGameServer, IMatch, IMatchCreateDto, IMatchResponse } from '../../common'; import * as Events from './events'; import * as Match from './match'; -import * as Storage from './storage'; - -const STORAGE_PREFIX = 'match_'; -const STORAGE_SUFFIX = '.json'; +import { db } from './database'; +import { saveMatchMapToDb, TDbMatchMap } from './matchMap'; +import { savePlayerToDb, TDbMatchPlayer } from './player'; const matches: Map = new Map(); @@ -21,7 +20,7 @@ const matchesToSave: Set = new Set(); let timeout: NodeJS.Timeout; export const setup = async () => { - const matchesFromStorage = await getAllFromStorage(); + const matchesFromStorage = getAllMatchesFromDatabase(); // begin with recent matches so when there are multiple matches // with the same game server we recreate the correct (most recent) one @@ -108,34 +107,13 @@ export const get = (id: string) => { return matches.get(id); }; -export const getFromStorage = async (id: string) => { - const matchData: IMatch | undefined = await Storage.read(STORAGE_PREFIX + id + STORAGE_SUFFIX); - return matchData; -}; - export const getAllLive = () => { return Array.from(matches.values()).map((match) => match.data); }; -export const getAllFromStorage = async () => { - const matchesFromStorage = await Storage.list(STORAGE_PREFIX, STORAGE_SUFFIX); - - const matches: IMatch[] = []; - - for (let i = 0; i < matchesFromStorage.length; i++) { - const fileName = matchesFromStorage[i]!; - const matchData: IMatch | undefined = await Storage.read(fileName); - if (matchData && fileName === STORAGE_PREFIX + matchData.id + STORAGE_SUFFIX) { - matches.push(matchData); - } - } - - return matches; -}; - export const getAll = async () => { const live = getAllLive(); - const storage = await getAllFromStorage(); + const storage = getAllMatchesFromDatabase(); const notLive = storage.filter((match) => !live.find((m) => match.id === m.id)); return { live, @@ -156,7 +134,7 @@ export const remove = async (id: string) => { }; export const removeStopped = async (id: string) => { - const matchFromStorage = await getFromStorage(id); + const matchFromStorage = getMatchFromDatabase(id); if (!matchFromStorage) { return false; } @@ -170,7 +148,7 @@ export const revive = async (id: string) => { if (match) { return false; } - const matchFromStorage = await getFromStorage(id); + const matchFromStorage = getMatchFromDatabase(id); if (!matchFromStorage) { return false; } @@ -183,7 +161,11 @@ export const save = async (matchData: IMatch) => { const previousLastSavedAt = matchData.lastSavedAt; matchData.lastSavedAt = Date.now(); try { - await Storage.write(STORAGE_PREFIX + matchData.id + STORAGE_SUFFIX, matchData); + Match.saveMatchToDb(matchData); + matchData.matchMaps.forEach((matchMap, index) => + saveMatchMapToDb(matchData.id, matchMap, index) + ); + matchData.players.forEach((player) => savePlayerToDb(matchData.id, player)); } catch (err) { matchData.lastSavedAt = previousLastSavedAt; throw err; @@ -223,3 +205,45 @@ export const getLiveMatchesByGameServer = (gameServer: IGameServer) => { match.gameServer.ip === gameServer.ip && match.gameServer.port === gameServer.port ); }; + +export const getMatchFromDatabase = (id: string): IMatch | undefined => { + const matchRow = db + .prepare<{ id: string }, Match.TDbMatch>('SELECT * FROM match WHERE id = :id') + .get({ id: id }); + if (!matchRow) { + return undefined; + } + const matchMapRows = db + .prepare< + { matchId: string }, + TDbMatchMap + >('SELECT * FROM matchMap WHERE matchId = :matchId') + .all({ matchId: id }); + const matchPlayerRows = db + .prepare< + { matchId: string }, + TDbMatchPlayer + >('SELECT * FROM matchPlayer WHERE matchId = :matchId') + .all({ matchId: id }); + return Match.matchFromDb(matchRow, matchMapRows, matchPlayerRows); +}; + +export const getAllMatchesFromDatabase = () => { + const matchRows = db.prepare<[], Match.TDbMatch>('SELECT * FROM match').all(); + const matchMapRows = db.prepare<[], TDbMatchMap>('SELECT * FROM matchMap').all(); + const matchPlayerRows = db.prepare<[], TDbMatchPlayer>('SELECT * FROM matchPlayer').all(); + + const matches: IMatch[] = []; + + for (let i = 0; i < matchRows.length; i++) { + const matchRow = matchRows[i] as any; + const match = Match.matchFromDb( + matchRow, + matchMapRows.filter((row: any) => row.matchId === matchRow.id), + matchPlayerRows.filter((row: any) => row.matchId === matchRow.id) + ); + matches.push(match); + } + + return matches; +}; diff --git a/backend/src/matchesController.ts b/backend/src/matchesController.ts index d06fbed..02b2a88 100644 --- a/backend/src/matchesController.ts +++ b/backend/src/matchesController.ts @@ -21,10 +21,10 @@ import { IMatchUpdateDto, } from '../../common'; import { ExpressRequest, IAuthResponse, IAuthResponseOptional } from './auth'; -import * as Events from './events'; import * as Match from './match'; import * as MatchMap from './matchMap'; import * as MatchService from './matchService'; +import { getLatestEventsFromDatabase } from './events'; const checkRconCommands = ( rconCommands: IMatchCreateDto['rconCommands'] | IMatchUpdateDto['rconCommands'] | string[], @@ -84,7 +84,7 @@ export class MatchesController extends Controller { @Query('isLive') isLive?: boolean ): Promise { const live = MatchService.getAllLive(); - const storage = isLive === true ? [] : await MatchService.getAllFromStorage(); + const storage = isLive === true ? [] : MatchService.getAllMatchesFromDatabase(); const notLive = storage.filter((match) => !live.find((m) => match.id === m.id)); return [...live, ...notLive] .map((m) => ({ ...m, isLive: !!live.find((l) => l.id === m.id) })) @@ -115,7 +115,7 @@ export class MatchesController extends Controller { }; } - const matchFromStorage = await MatchService.getFromStorage(id); + const matchFromStorage = MatchService.getMatchFromDatabase(id); if (matchFromStorage) { return { ...MatchService.hideRconPassword(matchFromStorage, req.user.type === 'GLOBAL'), @@ -132,7 +132,7 @@ export class MatchesController extends Controller { */ @Get('{id}/events') async getEvents(id: string, @Request() req: ExpressRequest): Promise { - return await Events.getEventsTail(id); + return getLatestEventsFromDatabase(id); } /** @@ -195,11 +195,13 @@ export class MatchesController extends Controller { await Match.update(match, requestBody); } else if (requestBody.gameServer) { // for offline matches only allow to update game server to get match running again - const offlineMatch = await MatchService.getFromStorage(id); + const offlineMatch = MatchService.getMatchFromDatabase(id); if (offlineMatch) { - const hideRconPassword = offlineMatch.gameServer.hideRconPassword; - offlineMatch.gameServer = requestBody.gameServer; - offlineMatch.gameServer.hideRconPassword = hideRconPassword; + const gameServer = { + ...requestBody.gameServer, + hideRconPassword: offlineMatch.gameServer.hideRconPassword, + }; + offlineMatch.gameServer = gameServer; await MatchService.save(offlineMatch); } } else { diff --git a/backend/src/migrations/01.ts b/backend/src/migrations/01.ts new file mode 100644 index 0000000..2354599 --- /dev/null +++ b/backend/src/migrations/01.ts @@ -0,0 +1,432 @@ +import { db } from '../database'; +import fs from 'node:fs'; +import path from 'node:path'; + +const STORAGE_FOLDER = process.env['TMT_STORAGE_FOLDER'] || 'storage'; + +export const migration01 = () => { + fs.mkdirSync(path.join(STORAGE_FOLDER, 'migrated'), { recursive: true }); + migrateMatches(); + migrateManagedGameServer(); + migrateEvents(); + migrateLogs(); +}; + +const migrateMatches = () => { + db.prepare( + `CREATE TABLE IF NOT EXISTS match ( + id TEXT PRIMARY KEY, + state TEXT NOT NULL, + passthrough TEXT, + mapPool TEXT NOT NULL, + teamAPassthrough TEXT, + teamAName TEXT NOT NULL, + teamAAdvantage INTEGER NOT NULL, + teamAPlayerSteamIds64 TEXT NOT NULL, + teamBPassthrough TEXT, + teamBName TEXT NOT NULL, + teamBAdvantage INTEGER NOT NULL, + teamBPlayerSteamIds64 TEXT NOT NULL, + electionSteps TEXT NOT NULL, + gameServerIp TEXT NOT NULL, + gameServerPort INTEGER NOT NULL, + gameServerRconPassword TEXT NOT NULL, + gameServerHideRconPassword INTEGER NOT NULL, + logSecret TEXT NOT NULL, + currentMap INTEGER NOT NULL, + webhookUrl TEXT, + webhookHeaders TEXT NOT NULL, + rconCommandsInit TEXT NOT NULL, + rconCommandsKnife TEXT NOT NULL, + rconCommandsMatch TEXT NOT NULL, + rconCommandsEnd TEXT NOT NULL, + canClinch INTEGER NOT NULL, + matchEndAction TEXT NOT NULL, + tmtSecret TEXT NOT NULL, + isStopped INTEGER NOT NULL, + tmtLogAddress TEXT, + createdAt INTEGER NOT NULL, + lastSavedAt INTEGER NOT NULL, + mode TEXT NOT NULL + ) STRICT` + ).run(); + const insertMatchStatement = db.prepare(`INSERT INTO match ( + id, + state, + passthrough, + mapPool, + teamAPassthrough, + teamAName, + teamAAdvantage, + teamAPlayerSteamIds64, + teamBPassthrough, + teamBName, + teamBAdvantage, + teamBPlayerSteamIds64, + electionSteps, + gameServerIp, + gameServerPort, + gameServerRconPassword, + gameServerHideRconPassword, + logSecret, + currentMap, + webhookUrl, + webhookHeaders, + rconCommandsInit, + rconCommandsKnife, + rconCommandsMatch, + rconCommandsEnd, + canClinch, + matchEndAction, + tmtSecret, + isStopped, + tmtLogAddress, + createdAt, + lastSavedAt, + mode + ) VALUES ( + :id, + :state, + :passthrough, + :mapPool, + :teamAPassthrough, + :teamAName, + :teamAAdvantage, + :teamAPlayerSteamIds64, + :teamBPassthrough, + :teamBName, + :teamBAdvantage, + :teamBPlayerSteamIds64, + :electionSteps, + :gameServerIp, + :gameServerPort, + :gameServerRconPassword, + :gameServerHideRconPassword, + :logSecret, + :currentMap, + :webhookUrl, + :webhookHeaders, + :rconCommandsInit, + :rconCommandsKnife, + :rconCommandsMatch, + :rconCommandsEnd, + :canClinch, + :matchEndAction, + :tmtSecret, + :isStopped, + :tmtLogAddress, + :createdAt, + :lastSavedAt, + :mode + )`); + + db.prepare( + `CREATE TABLE IF NOT EXISTS matchMap ( + matchId TEXT NOT NULL, + "index" INTEGER NOT NULL, + name TEXT NOT NULL, + knifeForSide INTEGER NOT NULL, + startAsCtTeam TEXT NOT NULL, + state TEXT NOT NULL, + knifeWinner TEXT, + readyTeamA INTEGER NOT NULL, + readyTeamB INTEGER NOT NULL, + knifeRestartTeamA INTEGER NOT NULL, + knifeRestartTeamB INTEGER NOT NULL, + scoreTeamA INTEGER NOT NULL, + scoreTeamB INTEGER NOT NULL, + PRIMARY KEY (matchId, "index") + FOREIGN KEY (matchId) REFERENCES match (id) ON UPDATE CASCADE ON DELETE CASCADE + ) STRICT` + ).run(); + const insertMatchMapStatement = db.prepare(`INSERT INTO matchMap ( + matchId, + "index", + name, + knifeForSide, + startAsCtTeam, + state, + knifeWinner, + readyTeamA, + readyTeamB, + knifeRestartTeamA, + knifeRestartTeamB, + scoreTeamA, + scoreTeamB + ) VALUES ( + :matchId, + :index, + :name, + :knifeForSide, + :startAsCtTeam, + :state, + :knifeWinner, + :readyTeamA, + :readyTeamB, + :knifeRestartTeamA, + :knifeRestartTeamB, + :scoreTeamA, + :scoreTeamB + )`); + + db.prepare( + `CREATE TABLE IF NOT EXISTS matchPlayer ( + matchId TEXT NOT NULL, + steamId64 TEXT NOT NULL, + name TEXT NOT NULL, + team TEXT, + side TEXT, + online INTEGER, + PRIMARY KEY (matchId, steamId64) + FOREIGN KEY (matchId) REFERENCES match (id) ON UPDATE CASCADE ON DELETE CASCADE + ) STRICT` + ).run(); + const insertMatchPlayerStatement = db.prepare(`INSERT INTO matchPlayer ( + matchId, + steamId64, + name, + team, + side, + online + ) VALUES ( + :matchId, + :steamId64, + :name, + :team, + :side, + :online + )`); + + const matchFiles = fs + .readdirSync(path.join(STORAGE_FOLDER)) + .filter((fileName) => fileName.startsWith('match_') && fileName.endsWith('.json')); + matchFiles.forEach((matchFile) => { + try { + const match = JSON.parse( + fs.readFileSync(path.join(STORAGE_FOLDER, matchFile), { encoding: 'utf-8' }) + ); + const params = { + id: match.id, + state: match.state, + passthrough: match.passthrough ?? null, + mapPool: JSON.stringify(match.mapPool), + teamAPassthrough: match.teamA.passthrough ?? null, + teamAName: match.teamA.name, + teamAAdvantage: match.teamA.advantage, + teamAPlayerSteamIds64: JSON.stringify(match.teamA.playerSteamIds64 ?? []), + teamBPassthrough: match.teamB.passthrough ?? null, + teamBName: match.teamB.name, + teamBAdvantage: match.teamB.advantage, + teamBPlayerSteamIds64: JSON.stringify(match.teamB.playerSteamIds64 ?? []), + electionSteps: JSON.stringify(match.electionSteps), + gameServerIp: match.gameServer.ip, + gameServerPort: match.gameServer.port, + gameServerRconPassword: match.gameServer.rconPassword, + gameServerHideRconPassword: match.gameServer.hideRconPassword === true ? 1 : 0, + logSecret: match.logSecret, + currentMap: match.currentMap, + webhookUrl: match.webhookUrl, + webhookHeaders: JSON.stringify(match.webhookHeaders ?? {}), + rconCommandsInit: JSON.stringify(match.rconCommands.init), + rconCommandsKnife: JSON.stringify(match.rconCommands.knife), + rconCommandsMatch: JSON.stringify(match.rconCommands.match), + rconCommandsEnd: JSON.stringify(match.rconCommands.end), + canClinch: match.canClinch ? 1 : 0, + matchEndAction: match.matchEndAction, + tmtSecret: match.tmtSecret, + isStopped: match.isStopped ? 1 : 0, + tmtLogAddress: match.tmtLogAddress ?? null, + createdAt: match.createdAt, + lastSavedAt: match.lastSavedAt, + mode: match.mode, + }; + insertMatchStatement.run(params); + + (match.matchMaps as any[]).forEach((matchMap, index) => { + const params = { + matchId: match.id, + index: index, + name: matchMap.name, + knifeForSide: matchMap.knifeForSide ? 1 : 0, + startAsCtTeam: matchMap.startAsCtTeam, + state: matchMap.state, + knifeWinner: matchMap.knifeWinner ?? null, + readyTeamA: matchMap.readyTeams.teamA ? 1 : 0, + readyTeamB: matchMap.readyTeams.teamB ? 1 : 0, + knifeRestartTeamA: matchMap.knifeRestart.teamA ? 1 : 0, + knifeRestartTeamB: matchMap.knifeRestart.teamB ? 1 : 0, + scoreTeamA: matchMap.score.teamA, + scoreTeamB: matchMap.score.teamB, + }; + insertMatchMapStatement.run(params); + }); + + (match.players as any[]).forEach((player) => { + const params = { + matchId: match.id, + steamId64: player.steamId64, + name: player.name, + team: player.team ?? null, + side: player.side ?? null, + online: player.online === true ? 1 : player.online === false ? 0 : null, + }; + insertMatchPlayerStatement.run(params); + }); + + fs.renameSync( + path.join(STORAGE_FOLDER, matchFile), + path.join(STORAGE_FOLDER, 'migrated', matchFile) + ); + console.log(`migrated match ${match.id}`); + } catch (err) { + console.error(`Could not migrate match ${matchFile}: ${err}`); + console.error(err); + } + }); +}; + +const migrateManagedGameServer = () => { + db.prepare( + `CREATE TABLE IF NOT EXISTS managedGameServer ( + ip TEXT NOT NULL, + port INTEGER NOT NULL, + rconPassword TEXT NOT NULL, + canBeUsed INTEGER NOT NULL, + usedBy TEXT, + PRIMARY KEY (ip, port) + ) STRICT` + ).run(); + + const insert = db.prepare( + `INSERT INTO managedGameServer ( + ip, + port, + rconPassword, + canBeUsed, + usedBy + ) VALUES ( + :ip, + :port, + :rconPassword, + :canBeUsed, + :usedBy + )` + ); + + if (!fs.existsSync(path.join(STORAGE_FOLDER, 'managed_game_servers.json'))) { + return; + } + const managedGameServers = JSON.parse( + fs.readFileSync(path.join(STORAGE_FOLDER, 'managed_game_servers.json'), { + encoding: 'utf-8', + }) + ) as any[]; + managedGameServers.forEach((managedGameServer, i) => { + try { + const params = { + ip: managedGameServer.ip, + port: managedGameServer.port, + rconPassword: managedGameServer.rconPassword, + canBeUsed: managedGameServer.canBeUsed ? 1 : 0, + usedBy: managedGameServer.usedBy, + }; + insert.run(params); + console.log( + `migrated managed gameserver ${managedGameServer.ip}:${managedGameServer.port}` + ); + } catch (err) { + console.error(`Could not migrate managed game server at index ${i}: ${err}`); + console.error(err); + } + }); + fs.renameSync( + path.join(STORAGE_FOLDER, 'managed_game_servers.json'), + path.join(STORAGE_FOLDER, 'migrated', 'managed_game_servers.json') + ); +}; + +const migrateEvents = () => { + db.prepare( + `CREATE TABLE IF NOT EXISTS event ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp TEXT NOT NULL, + matchId TEXT NOT NULL, + matchPassthrough TEXT, + type TEXT NOT NULL, + payload TEXT NOT NULL, + FOREIGN KEY (matchId) REFERENCES match (id) ON UPDATE CASCADE ON DELETE CASCADE + ) STRICT` + ).run(); + const insertStatement = db.prepare( + `INSERT INTO event ( + timestamp, + matchId, + matchPassthrough, + type, + payload + ) VALUEs ( + :timestamp, + :matchId, + :matchPassthrough, + :type, + :payload + )` + ); + + const eventsFiles = fs + .readdirSync(path.join(STORAGE_FOLDER)) + .filter((fileName) => fileName.startsWith('events_') && fileName.endsWith('.jsonl')); + eventsFiles.forEach((eventsFile) => { + const matchId = eventsFile.substring(7, eventsFile.length - 6); + try { + const events = fs + .readFileSync(path.join(STORAGE_FOLDER, eventsFile), { encoding: 'utf-8' }) + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + events.forEach((event) => { + const payload = { ...event }; + delete payload.timestamp; + delete payload.matchId; + delete payload.matchPassthrough; + delete payload.type; + const params = { + timestamp: event.timestamp, + matchId: event.matchId, + matchPassthrough: event.matchPassthrough, + type: event.type, + payload: JSON.stringify(payload), + }; + insertStatement.run(params); + }); + console.log(`Migrated events from match ${matchId}`); + fs.renameSync( + path.join(STORAGE_FOLDER, eventsFile), + path.join(STORAGE_FOLDER, 'migrated', eventsFile) + ); + } catch (err: any) { + if (err.code === 'SQLITE_CONSTRAINT_FOREIGNKEY') { + console.log(`Skip events from match ${matchId} (match does not exist).`); + fs.renameSync( + path.join(STORAGE_FOLDER, eventsFile), + path.join(STORAGE_FOLDER, 'migrated', eventsFile) + ); + } else { + console.error(`Could not migrate events from file ${eventsFile}: ${err}`); + console.error(err); + } + } + }); +}; + +const migrateLogs = () => { + const logsFiles = fs + .readdirSync(path.join(STORAGE_FOLDER)) + .filter((fileName) => fileName.startsWith('logs_') && fileName.endsWith('.jsonl')); + logsFiles.forEach((logsFile) => { + fs.renameSync( + path.join(STORAGE_FOLDER, logsFile), + path.join(STORAGE_FOLDER, 'migrated', logsFile) + ); + }); +}; diff --git a/backend/src/player.ts b/backend/src/player.ts index 244edab..a9daabf 100644 --- a/backend/src/player.ts +++ b/backend/src/player.ts @@ -1,6 +1,7 @@ import SteamID from 'steamid'; import { IPlayer, TTeamAB, TTeamSides, TTeamString } from '../../common'; import * as Match from './match'; +import { db } from './database'; export const create = (match: Match.Match, steamId: string, name: string): IPlayer => { const steamId64 = getSteamID64(steamId); @@ -55,3 +56,61 @@ export const getSideFromTeamString = (teamString: TTeamString): TTeamSides | nul return null; } }; + +export type TDbMatchPlayer = { + matchId: string; + steamId64: string; + name: string; + team: string | null; + side: string | null; + online: number | null; +}; + +export const matchPlayerToDb = (matchId: string, player: IPlayer): TDbMatchPlayer => { + return { + matchId: matchId, + steamId64: player.steamId64, + name: player.name, + team: player.team, + side: player.side, + online: player.online ? 1 : 0, + }; +}; + +export const matchPlayerFromDb = (dbMatchPlayer: TDbMatchPlayer): IPlayer => { + return { + steamId64: dbMatchPlayer.steamId64, + name: dbMatchPlayer.name, + team: dbMatchPlayer.team as TTeamAB | null, + side: dbMatchPlayer.side as TTeamSides | null, + online: dbMatchPlayer.online === null ? null : !!dbMatchPlayer.online, + }; +}; + +export const savePlayerToDb = (matchId: string, player: IPlayer) => { + db.prepare( + `INSERT INTO matchPlayer ( + matchId, + steamId64, + name, + team, + side, + online + ) VALUES ( + :matchId, + :steamId64, + :name, + :team, + :side, + :online + ) ON CONFLICT (matchId, steamId64) DO UPDATE SET + matchId = :matchId, + steamId64 = :steamId64, + name = :name, + team = :team, + side = :side, + online = :online + WHERE matchId = :matchId AND steamId64 = :steamId64 + ` + ).run(matchPlayerToDb(matchId, player)); +}; diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 80cfcfd..8b05073 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -472,15 +472,9 @@ const models: TsoaRoute.Models = { required: true, }, webhookHeaders: { - dataType: 'union', - subSchemas: [ - { - dataType: 'nestedObjectLiteral', - nestedProperties: {}, - additionalProperties: { dataType: 'string' }, - }, - { dataType: 'enum', enums: [null] }, - ], + dataType: 'nestedObjectLiteral', + nestedProperties: {}, + additionalProperties: { dataType: 'string' }, required: true, }, rconCommands: { @@ -562,7 +556,17 @@ const models: TsoaRoute.Models = { }, gameServer: { dataType: 'union', - subSchemas: [{ ref: 'IGameServer' }, { dataType: 'enum', enums: [null] }], + subSchemas: [ + { + dataType: 'nestedObjectLiteral', + nestedProperties: { + rconPassword: { dataType: 'string', required: true }, + port: { dataType: 'double', required: true }, + ip: { dataType: 'string', required: true }, + }, + }, + { dataType: 'enum', enums: [null] }, + ], required: true, }, webhookUrl: { @@ -667,15 +671,9 @@ const models: TsoaRoute.Models = { required: true, }, webhookHeaders: { - dataType: 'union', - subSchemas: [ - { - dataType: 'nestedObjectLiteral', - nestedProperties: {}, - additionalProperties: { dataType: 'string' }, - }, - { dataType: 'enum', enums: [null] }, - ], + dataType: 'nestedObjectLiteral', + nestedProperties: {}, + additionalProperties: { dataType: 'string' }, required: true, }, rconCommands: { @@ -1120,7 +1118,14 @@ const models: TsoaRoute.Models = { subSchemas: [{ ref: 'IElectionStepAdd' }, { ref: 'IElectionStepSkip' }], }, }, - gameServer: { ref: 'IGameServer' }, + gameServer: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + rconPassword: { dataType: 'string', required: true }, + port: { dataType: 'double', required: true }, + ip: { dataType: 'string', required: true }, + }, + }, webhookUrl: { dataType: 'string' }, webhookHeaders: { dataType: 'nestedObjectLiteral', diff --git a/backend/src/storage.ts b/backend/src/storage.ts index 6bebb23..49091b7 100644 --- a/backend/src/storage.ts +++ b/backend/src/storage.ts @@ -31,42 +31,3 @@ export const read: TRead = async (fileName: string, fallback?: T) => { return fallback; } }; - -export const appendLine = async (fileName: string, content: any) => { - try { - await fsp.appendFile(path.join(STORAGE_FOLDER, fileName), JSON.stringify(content) + '\n'); - } catch (err) { - console.warn(`Error storage appendLine ${fileName}: ${err}`); - } -}; - -export const readLines = async ( - fileName: string, - fallback: Array, - numberLastOfLines?: number -) => { - try { - const fullPath = path.join(STORAGE_FOLDER, fileName); - if (!fs.existsSync(fullPath) && fallback) { - throw 'file does not exist'; - } - const content = await fsp.readFile(fullPath, { encoding: 'utf8' }); - return content - .split('\n') - .filter((line) => line.trim().length > 0) - .map((line) => JSON.parse(line)) - .slice(-(numberLastOfLines ?? 0)); - } catch (err) { - console.warn(`Error storage readLines ${fileName}: ${err}. Use fallback.`); - return fallback; - } -}; - -/** - * 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. - */ -export const list = async (prefix: string, suffix: string) => { - const files = await fsp.readdir(STORAGE_FOLDER); - return files.filter((fileName) => fileName.startsWith(prefix) && fileName.endsWith(suffix)); -}; diff --git a/backend/swagger.json b/backend/swagger.json index c08b900..d50aab5 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -592,7 +592,6 @@ "type": "string" }, "type": "object", - "nullable": true, "description": "Additional headers that will be added to each webhook request" }, "rconCommands": { @@ -771,11 +770,20 @@ "type": "array" }, "gameServer": { - "allOf": [ - { - "$ref": "#/components/schemas/IGameServer" + "properties": { + "rconPassword": { + "type": "string" + }, + "port": { + "type": "number", + "format": "double" + }, + "ip": { + "type": "string" } - ], + }, + "required": ["rconPassword", "port", "ip"], + "type": "object", "nullable": true }, "webhookUrl": { @@ -936,7 +944,6 @@ "type": "string" }, "type": "object", - "nullable": true, "description": "Additional headers that will be added to each webhook request" }, "rconCommands": { @@ -1782,7 +1789,20 @@ "type": "array" }, "gameServer": { - "$ref": "#/components/schemas/IGameServer" + "properties": { + "rconPassword": { + "type": "string" + }, + "port": { + "type": "number", + "format": "double" + }, + "ip": { + "type": "string" + } + }, + "required": ["rconPassword", "port", "ip"], + "type": "object" }, "webhookUrl": { "type": "string", diff --git a/common/types/match.ts b/common/types/match.ts index 6b5721d..f555360 100644 --- a/common/types/match.ts +++ b/common/types/match.ts @@ -80,7 +80,7 @@ export interface IMatch { /** Send various events to this url (HTTP POST) */ webhookUrl: string | null; /** Additional headers that will be added to each webhook request */ - webhookHeaders: { [key: string]: string } | null; + webhookHeaders: { [key: string]: string }; rconCommands: { /** executed exactly once on match init */ init: string[]; @@ -145,7 +145,11 @@ export interface IMatchCreateDto { teamA: ITeamCreateDto; teamB: ITeamCreateDto; electionSteps: Array; - gameServer: IGameServer | null; + gameServer: { + ip: string; + port: number; + rconPassword: string; + } | null; /** Send various events to this url (HTTP POST) */ webhookUrl?: string | null; /** Additional headers that will be added to each webhook request */ From a945874481dee66da96f9b86862fe34b40bd1ca5 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Mon, 24 Nov 2025 00:46:42 +0100 Subject: [PATCH 06/15] Change docker image tags: `latest` tags the latest released verison, `dev` tags the latest commit --- .github/workflows/docker.yml | 2 +- .github/workflows/release.yml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index ad2e0c5..67f38ff 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -46,4 +46,4 @@ jobs: TMT_COMMIT_SHA=${{ github.sha }} tags: | jensforstmann/tmt2:${{ github.sha }} - jensforstmann/tmt2:latest + jensforstmann/tmt2:dev diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9ce9031..52582fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,3 +36,4 @@ jobs: jensforstmann/tmt2:${{ steps.tagName.outputs.major }} jensforstmann/tmt2:${{ steps.tagName.outputs.major }}.${{ steps.tagName.outputs.minor }} jensforstmann/tmt2:${{ steps.tagName.outputs.major }}.${{ steps.tagName.outputs.minor }}.${{ steps.tagName.outputs.patch }} + jensforstmann/tmt2:latest From e99fce2f84777b4d0fa5b88fdb000174ab319f09 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Mon, 24 Nov 2025 13:30:40 +0100 Subject: [PATCH 07/15] Fix match map being in wrong (paused) state when ingame vote for tactical timeout is used together with pause command --- backend/src/match.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/backend/src/match.ts b/backend/src/match.ts index d12f915..6450c25 100644 --- a/backend/src/match.ts +++ b/backend/src/match.ts @@ -551,6 +551,24 @@ const onLogLine = async (match: Match, line: string) => { match.warnAboutWrongTeam = true; return; } + + // Match pause is disabled - TimeOutCTs + // Match pause is disabled - TimeOutTs + const matchPauseDisabledPattern = /Match pause is disabled.*/; + const matchPauseDisabledMatch = line.match( + new RegExp(dateTimePattern.source + matchPauseDisabledPattern.source) + ); + if (matchPauseDisabledMatch) { + // Workaround: Match map being in wrong (paused) state when ingame vote for tactical timeout is used together with TMT's `.pause` command. + const currentMatchMap = getCurrentMatchMap(match); + if (currentMatchMap && currentMatchMap.state === 'PAUSED') { + currentMatchMap.readyTeams.teamA = false; + currentMatchMap.readyTeams.teamB = false; + currentMatchMap.state = 'IN_PROGRESS'; + MatchService.scheduleSave(match); + } + return; + } } catch (err) { match.log('Error in onLogLine' + err); } From 4bb36e573499159ca1a87a0bb329504d088fd30c Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Mon, 24 Nov 2025 13:37:14 +0100 Subject: [PATCH 08/15] Fix typo in presets / default match rcon commands --- backend/src/presets.ts | 4 ++-- examples/README.md | 2 +- frontend/src/pages/create.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/presets.ts b/backend/src/presets.ts index 7be55ff..d6b42a8 100644 --- a/backend/src/presets.ts +++ b/backend/src/presets.ts @@ -87,7 +87,7 @@ const DEFAULT_PRESETS: IPreset[] = [ 'mp_give_player_c4 1; mp_startmoney 800; mp_ct_default_secondary "weapon_hkp2000"; mp_t_default_secondary "weapon_glock"', 'mp_overtime_enable 1', 'say > MATCH CONFIG LOADED <', - 'say > HF & LG - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <', + 'say > HF & GL - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <', ], end: ['say > MATCH END RCON LOADED <'], }, @@ -147,7 +147,7 @@ const DEFAULT_PRESETS: IPreset[] = [ 'mp_give_player_c4 1; mp_startmoney 800; mp_ct_default_secondary "weapon_hkp2000"; mp_t_default_secondary "weapon_glock"', 'mp_overtime_enable 1', 'say > MATCH CONFIG LOADED <', - 'say > HF & LG - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <', + 'say > HF & GL - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <', ], end: ['say > MATCH END RCON LOADED <'], }, diff --git a/examples/README.md b/examples/README.md index 206cff5..a59a920 100644 --- a/examples/README.md +++ b/examples/README.md @@ -47,7 +47,7 @@ Template (remove json comments before usage): "mp_give_player_c4 1; mp_startmoney 800; mp_ct_default_secondary \"weapon_hkp2000\"; mp_t_default_secondary \"weapon_glock\"", "mp_overtime_enable 1", "say > MATCH CONFIG LOADED <", - "say > HF & LG - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <" + "say > HF & GL - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <" ], "end": [ // these rcon commands will be executed only once: after the end of the last map, or when the match has been stopped (by api) "say > MATCH END RCON LOADED <" diff --git a/frontend/src/pages/create.tsx b/frontend/src/pages/create.tsx index 6b4bc85..e0dad6e 100644 --- a/frontend/src/pages/create.tsx +++ b/frontend/src/pages/create.tsx @@ -28,7 +28,7 @@ const DEFAULT_RCON_MATCH = [ 'mp_give_player_c4 1; mp_startmoney 800; mp_ct_default_secondary "weapon_hkp2000"; mp_t_default_secondary "weapon_glock"', 'mp_overtime_enable 1', 'say > MATCH CONFIG LOADED <', - 'say > HF & LG - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <', + 'say > HF & GL - %TMT_MAP_NUMBER%. map: %TMT_MAP_NAME% <', ]; const DEFAULT_RCON_END = ['say > MATCH END RCON LOADED <']; From b7a85aaa18f989ac9e391d3d2d922d4f154c1cd6 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Mon, 24 Nov 2025 20:04:10 +0100 Subject: [PATCH 09/15] Add CHANGELOG.md --- CHANGELOG.md | 112 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7195b4e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,112 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +- **BREAKING:** Remove `logs` field from match data API responses. It was never used but an empty array instead. +- **BREAKING:** Remove HTTP `logs` endpoint for a specific match in favor of the `events` endpoint (which also contains logs). +- **BREAKING:** All optional fields in API responses are now always present and might be `null` instead of being not there at all. + +### Fixed + +- Fix match map being in wrong (`PAUSED`) state when ingame vote for tactical timeout (or the `.tac` command) is used together with TMT's `.pause` command. + +## [2.9.1] - 2024-10-08 + +### Fixed + +- Re-add previous docker tag schema with leading "v" for backwards compatibility (e.g. v2). + +## [2.9.0] - 2024-10-08 + +### Added + +- Make presets usable for logged in or all users (configurable per preset). +- Update 2on2 wingman default preset (active duty map pool & election steps). +- Add docker compose file. +- Add `MATCH_STOP` webhook (sent when TMT stops supervising a match). +- Improve (rcon and log) connections between game server and TMT, especially when game server has crashed/changed. +- Add workaround for CS2 getting stuck after loading round backup. + +### Changed + +- Tag docker container with a correct semver version (2.9.0, 2.9, 2 instead of v2.8, v2) + +### Fixed + +- Fix copy to clipboard not always working. +- Fix redirect from edit match page back to match page. + +## [2.8.0] - 2024-09-20 + +### Added + +- Add `.tac` command for tactical timeouts (will send `timeout_ct_start`/`timeout_terrorist_start` commands to the CS2 server). +- Improve team joining process (`.team a`/`.team b`): send various ingame chat message to help the players to pick the right team. +- Add support for workshop maps. +- Dynamic map change delay: wait for `mp_match_restart_delay` seconds before loading the next map (to not cut off casters/specatators on the CSTV server). +- Custom headers can be added to all webhook requests (e.g. for auth). +- Add separate page to send rcon commands to managed game servers (independent of a match). +- Improve detection and handling of dangling matches. + A dangling match is currently not being supervised (not tracked by TMT) and has not been stopped properly. + A match must be either stopped via the UI ("stop") or the API (`DELETE`). + This can happen if the game server goes offline and TMT quits. + Next time TMT starts it tries to resume unfinished matches, if the match cannot be continued (game server is still offline) the match is dangling. + +## [2.7.0] - 2023-11-15 + +### Added + +- Track online state for each player. +- Make displayed columns in match list configurable. + +### Fixed + +- Fix copy to clipboard not working in some cases + +## [2.6.0] - 2023-10-20 + +### Added + +- Add color support for chat messages. +- Track team sides for each player (CT/T) and display them in the frontend. +- Add preset system to save and reuse match creation payload data. + +## [2.5.0] - 2023-10-09 + +### Fixed + +- Fix that the game server does not pause after loading a round backup despite `mp_backup_restore_load_autopause = true`. + +## [2.4.0] - 2023-10-08 + +### Added + +- Add support for Counter-Strike 2. + +## [2.3.0] - 2023-04-21 + +### Added + +- Game servers managed by TMT: If game server property is `null` when match is created a game server managed by TMT will be assigned. +- Loop mode: If enabled (for a match) and after the match has ended or if there are no players left on the server, the match will restart from the beginning (starting with the election process). +- Add pages to frontend to create and update matches. + +## [2.2.0] - 2022-08-27 + +### Added + +- Add method to switch the internal team assignments (in case the teams are already playing, but are in the wrong teams). +- Print auto generated access token to console. +- Live sync match state via WebSocket to the frontend. +- Add dark mode to the frontend. + +### Changed + +- Environment variable `TMT_LOG_ADDRESS` is now optional, if omitted one must be set in match creation payload (`tmtLogAddress`). From 13d059b6d5978e9eae2afdfeaef560fe3c0551d5 Mon Sep 17 00:00:00 2001 From: JensForstmann Date: Thu, 4 Dec 2025 14:51:04 +0100 Subject: [PATCH 10/15] Add `.admin` command --- CHANGELOG.md | 7 + backend/src/commands.ts | 2 + backend/src/match.ts | 25 +++- backend/src/matchesController.ts | 14 +- backend/src/migrations/01.ts | 10 +- backend/src/routes.ts | 15 ++ backend/swagger.json | 30 +++- common/types/match.ts | 7 + frontend/src/App.tsx | 138 ++++++++++++++---- frontend/src/assets/Icons.tsx | 91 +++++++++++- frontend/src/components/MatchList.tsx | 8 +- .../src/components/NeedsAttentionCard.tsx | 30 ++++ frontend/src/pages/match.tsx | 4 + frontend/src/pages/matches.tsx | 7 + frontend/src/utils/theme.ts | 18 ++- 15 files changed, 362 insertions(+), 44 deletions(-) create mode 100644 frontend/src/components/NeedsAttentionCard.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 7195b4e..498b1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `.admin` ingame command which sets the new `needsAttentionSince` property on the match data. +- Add predefined match list filter to frontend to show all live matches which needs attention. +- Add notification bell to frontend to indicate how many matches need attention. + ### Changed - **BREAKING:** Remove `logs` field from match data API responses. It was never used but an empty array instead. - **BREAKING:** Remove HTTP `logs` endpoint for a specific match in favor of the `events` endpoint (which also contains logs). - **BREAKING:** All optional fields in API responses are now always present and might be `null` instead of being not there at all. +- Move some navbar buttons to a new settings menu in the frontend: Login/logout, theme selection and hyperlink to info page. ### Fixed diff --git a/backend/src/commands.ts b/backend/src/commands.ts index 652a317..218ac7c 100644 --- a/backend/src/commands.ts +++ b/backend/src/commands.ts @@ -19,6 +19,7 @@ const Commands = [ 'TACTICAL', 'RESTART', 'VERSION', + 'ADMIN', '*', ] as const; export type TCommand = (typeof Commands)[number]; @@ -46,6 +47,7 @@ commandMapping.set('team', 'TEAM'); commandMapping.set('tac', 'TACTICAL'); commandMapping.set('restart', 'RESTART'); commandMapping.set('version', 'VERSION'); +commandMapping.set('admin', 'ADMIN'); export const getInternalCommandByUserCommand = (userCommand: string) => { return commandMapping.get(userCommand); diff --git a/backend/src/match.ts b/backend/src/match.ts index 6450c25..0a9af6d 100644 --- a/backend/src/match.ts +++ b/backend/src/match.ts @@ -126,6 +126,7 @@ export const createFromCreateDto = async (dto: IMatchCreateDto, id: string, logS webhookUrl: dto.webhookUrl ?? null, webhookHeaders: dto.webhookHeaders ?? {}, mode: dto.mode ?? 'SINGLE', + needsAttentionSince: null, }; try { const match = await createFromData(data, 'Create new match'); @@ -744,11 +745,17 @@ const onConsoleSay = async (match: Match, message: string) => { }; export const registerCommandHandlers = () => { + commands.registerHandler('ADMIN', onAdminCommand); commands.registerHandler('TEAM', onTeamCommand); commands.registerHandler('VERSION', onVersionCommand); commands.registerHandler('*', onEveryCommand); }; +const onAdminCommand: commands.CommandHandler = async (e) => { + e.match.data.needsAttentionSince = Date.now(); + MatchService.scheduleSave(e.match); +}; + const onVersionCommand: commands.CommandHandler = async (e) => { await say( e.match, @@ -1194,6 +1201,10 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { match.data.matchEndAction = dto.matchEndAction; } + if (dto.needsAttentionSince !== undefined) { + match.data.needsAttentionSince = dto.needsAttentionSince; + } + if (dto._restartElection) { await restartElection(match); } @@ -1213,8 +1224,6 @@ export const update = async (match: Match, dto: IMatchUpdateDto) => { if (dto._execRconCommandsEnd) { await execRconCommands(match, 'end'); } - - MatchService.scheduleSave(match); }; export type TDbMatch = { @@ -1251,6 +1260,7 @@ export type TDbMatch = { createdAt: number; lastSavedAt: number; mode: string; + needsAttentionSince: number | null; }; const matchToDb = (match: IMatch): TDbMatch => { @@ -1288,6 +1298,7 @@ const matchToDb = (match: IMatch): TDbMatch => { createdAt: match.createdAt, lastSavedAt: match.lastSavedAt ?? Date.now(), mode: match.mode, + needsAttentionSince: match.needsAttentionSince, }; }; @@ -1345,6 +1356,7 @@ export const matchFromDb = ( mode: dbMatch.mode as TMatchMode, serverPassword: '', lastSavedAt: 0, + needsAttentionSince: dbMatch.needsAttentionSince, }; }; @@ -1383,7 +1395,8 @@ export const saveMatchToDb = (match: IMatch) => { tmtLogAddress, createdAt, lastSavedAt, - mode + mode, + needsAttentionSince ) VALUES ( :id, :state, @@ -1417,7 +1430,8 @@ export const saveMatchToDb = (match: IMatch) => { :tmtLogAddress, :createdAt, :lastSavedAt, - :mode + :mode, + :needsAttentionSince ) ON CONFLICT (id) DO UPDATE SET state = :state, passthrough = :passthrough, @@ -1450,7 +1464,8 @@ export const saveMatchToDb = (match: IMatch) => { tmtLogAddress = :tmtLogAddress, createdAt = :createdAt, lastSavedAt = :lastSavedAt, - mode = :mode + mode = :mode, + needsAttentionSince = :needsAttentionSince WHERE id = :id ` ).run(matchToDb(match)); diff --git a/backend/src/matchesController.ts b/backend/src/matchesController.ts index 02b2a88..3302104 100644 --- a/backend/src/matchesController.ts +++ b/backend/src/matchesController.ts @@ -81,7 +81,8 @@ export class MatchesController extends Controller { @Query('state') state?: string[], @Query('passthrough') passthrough?: string[], @Query('isStopped') isStopped?: boolean, - @Query('isLive') isLive?: boolean + @Query('isLive') isLive?: boolean, + @Query('needsAttention') needsAttention?: boolean ): Promise { const live = MatchService.getAllLive(); const storage = isLive === true ? [] : MatchService.getAllMatchesFromDatabase(); @@ -96,6 +97,11 @@ export class MatchesController extends Controller { ) .filter((m) => isStopped === undefined || m.isStopped === isStopped) .filter((m) => isLive === undefined || m.isLive === isLive) + .filter( + (m) => + needsAttention === undefined || + needsAttention === (m.needsAttentionSince !== null) + ) .map((m) => MatchService.hideRconPassword(m, req.user.type === 'GLOBAL')); } @@ -192,7 +198,11 @@ export class MatchesController extends Controller { if (match.data.gameServer.hideRconPassword) { checkRconCommands(requestBody.rconCommands, req.user.type === 'GLOBAL'); } - await Match.update(match, requestBody); + try { + await Match.update(match, requestBody); + } finally { + MatchService.scheduleSave(match); + } } else if (requestBody.gameServer) { // for offline matches only allow to update game server to get match running again const offlineMatch = MatchService.getMatchFromDatabase(id); diff --git a/backend/src/migrations/01.ts b/backend/src/migrations/01.ts index 2354599..78e63b8 100644 --- a/backend/src/migrations/01.ts +++ b/backend/src/migrations/01.ts @@ -47,7 +47,8 @@ const migrateMatches = () => { tmtLogAddress TEXT, createdAt INTEGER NOT NULL, lastSavedAt INTEGER NOT NULL, - mode TEXT NOT NULL + mode TEXT NOT NULL, + needsAttentionSince INTEGER ) STRICT` ).run(); const insertMatchStatement = db.prepare(`INSERT INTO match ( @@ -83,7 +84,8 @@ const migrateMatches = () => { tmtLogAddress, createdAt, lastSavedAt, - mode + mode, + needsAttentionSince ) VALUES ( :id, :state, @@ -117,7 +119,8 @@ const migrateMatches = () => { :tmtLogAddress, :createdAt, :lastSavedAt, - :mode + :mode, + :needsAttentionSince )`); db.prepare( @@ -239,6 +242,7 @@ const migrateMatches = () => { createdAt: match.createdAt, lastSavedAt: match.lastSavedAt, mode: match.mode, + needsAttentionSince: null, }; insertMatchStatement.run(params); diff --git a/backend/src/routes.ts b/backend/src/routes.ts index 8b05073..7c061b5 100644 --- a/backend/src/routes.ts +++ b/backend/src/routes.ts @@ -509,6 +509,11 @@ const models: TsoaRoute.Models = { required: true, }, mode: { ref: 'TMatchMode', required: true }, + needsAttentionSince: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + required: true, + }, }, additionalProperties: false, }, @@ -708,6 +713,11 @@ const models: TsoaRoute.Models = { required: true, }, mode: { ref: 'TMatchMode', required: true }, + needsAttentionSince: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + required: true, + }, isLive: { dataType: 'boolean', required: true }, }, additionalProperties: false, @@ -1184,6 +1194,10 @@ const models: TsoaRoute.Models = { dataType: 'union', subSchemas: [{ dataType: 'boolean' }, { dataType: 'enum', enums: [null] }], }, + needsAttentionSince: { + dataType: 'union', + subSchemas: [{ dataType: 'double' }, { dataType: 'enum', enums: [null] }], + }, }, additionalProperties: false, }, @@ -1451,6 +1465,7 @@ export function RegisterRoutes(app: Router) { }, isStopped: { in: 'query', name: 'isStopped', dataType: 'boolean' }, isLive: { in: 'query', name: 'isLive', dataType: 'boolean' }, + needsAttention: { in: 'query', name: 'needsAttention', dataType: 'boolean' }, }; app.get( '/api/matches', diff --git a/backend/swagger.json b/backend/swagger.json index d50aab5..0a584fa 100644 --- a/backend/swagger.json +++ b/backend/swagger.json @@ -673,6 +673,12 @@ "mode": { "$ref": "#/components/schemas/TMatchMode", "description": "Match mode (single: stops when match is finished, loop: starts again after match is finished)" + }, + "needsAttentionSince": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Since when (unix time in milliseconds) does the match needs attention (set via ingame `.admin` command)." } }, "required": [ @@ -701,7 +707,8 @@ "tmtLogAddress", "createdAt", "lastSavedAt", - "mode" + "mode", + "needsAttentionSince" ], "type": "object", "additionalProperties": false @@ -1026,6 +1033,12 @@ "$ref": "#/components/schemas/TMatchMode", "description": "Match mode (single: stops when match is finished, loop: starts again after match is finished)" }, + "needsAttentionSince": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Since when (unix time in milliseconds) does the match needs attention (set via ingame `.admin` command)." + }, "isLive": { "type": "boolean", "description": "Match is currently supervised." @@ -1058,6 +1071,7 @@ "createdAt", "lastSavedAt", "mode", + "needsAttentionSince", "isLive" ], "type": "object", @@ -1902,6 +1916,12 @@ "_execRconCommandsEnd": { "type": "boolean", "nullable": true + }, + "needsAttentionSince": { + "type": "number", + "format": "double", + "nullable": true, + "description": "Set to `null` to reset attention timestamp." } }, "type": "object", @@ -2308,6 +2328,14 @@ "schema": { "type": "boolean" } + }, + { + "in": "query", + "name": "needsAttention", + "required": false, + "schema": { + "type": "boolean" + } } ] } diff --git a/common/types/match.ts b/common/types/match.ts index f555360..a12aad3 100644 --- a/common/types/match.ts +++ b/common/types/match.ts @@ -110,6 +110,8 @@ export interface IMatch { lastSavedAt: number | null; /** Match mode (single: stops when match is finished, loop: starts again after match is finished) */ mode: TMatchMode; + /** Since when (unix time in milliseconds) does the match needs attention (set via ingame `.admin` command). */ + needsAttentionSince: number | null; } export interface IMatchResponse extends IMatch { @@ -197,4 +199,9 @@ export interface IMatchUpdateDto extends Partial { _execRconCommandsKnife?: boolean | null; _execRconCommandsMatch?: boolean | null; _execRconCommandsEnd?: boolean | null; + + /** + * Set to `null` to reset attention timestamp. + */ + needsAttentionSince?: number | null; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 17bd250..f79a563 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,21 @@ import { A, AnchorProps, RouteSectionProps } from '@solidjs/router'; -import { Component, Match, Switch, onMount } from 'solid-js'; -import { SvgComputer, SvgDarkMode, SvgLightMode } from './assets/Icons'; +import { Component, Match, Show, Switch, createEffect, createSignal, onMount } from 'solid-js'; +import { + SvgCheck, + SvgInfo, + SvgLogin, + SvgLogout, + SvgNotifications, + SvgSettings, + SvgTheme, +} from './assets/Icons'; import logo from './assets/logo.svg'; -import { isLoggedIn } from './utils/fetcher'; +import { createFetcher, isLoggedIn } from './utils/fetcher'; import { t } from './utils/locale'; -import { currentTheme, cycleDarkMode, updateDarkClasses } from './utils/theme'; +import { currentTheme, setTheme, updateDarkClasses } from './utils/theme'; +import { IMatchResponse } from '../../common'; +import { MatchesNeedingAttention } from './pages/matches'; const NavLink = (props: AnchorProps) => { return ( @@ -16,6 +26,17 @@ const NavLink = (props: AnchorProps) => { }; const NavBar: Component = () => { + const [notificationCount, setNotificationCount] = createSignal(0); + createEffect(() => { + createFetcher()( + 'GET', + `/api/matches${MatchesNeedingAttention.search}` + ).then((matches) => { + if (matches) { + setNotificationCount(matches.length); + } + }); + }); return (