feat(tournament): better frontend and database handling

This commit is contained in:
Maieul BOYER 2026-01-12 16:36:51 +01:00 committed by Maix0
parent ca618d64ca
commit 43e3b9af26
41 changed files with 2484 additions and 278 deletions

View file

@ -6,13 +6,15 @@ import { IUserDb, UserImpl } from './mixin/user';
import { IBlockedDb, BlockedImpl } from './mixin/blocked';
import { ITicTacToeDb, TicTacToeImpl } from './mixin/tictactoe';
import { IPongDb, PongImpl } from './mixin/pong';
import { ITournamentDb, TournamentImpl } from './mixin/tournament';
Object.assign(DbImpl.prototype, UserImpl);
Object.assign(DbImpl.prototype, BlockedImpl);
Object.assign(DbImpl.prototype, TicTacToeImpl);
Object.assign(DbImpl.prototype, PongImpl);
Object.assign(DbImpl.prototype, TournamentImpl);
export interface Database extends DbImpl, IUserDb, IBlockedDb, ITicTacToeDb, IPongDb { }
export interface Database extends DbImpl, IUserDb, IBlockedDb, ITicTacToeDb, IPongDb, ITournamentDb { }
// When using .decorate you have to specify added properties for Typescript
declare module 'fastify' {

View file

@ -1,3 +1,7 @@
----------------
-- AUTH --
----------------
CREATE TABLE IF NOT EXISTS user (
id TEXT PRIMARY KEY NOT NULL,
login TEXT UNIQUE,
@ -10,6 +14,10 @@ CREATE TABLE IF NOT EXISTS user (
allow_guest_message INTEGER NOT NULL DEFAULT 1
);
----------------
-- CHAT --
----------------
CREATE TABLE IF NOT EXISTS blocked (
id INTEGER PRIMARY KEY NOT NULL,
user TEXT NOT NULL,
@ -19,19 +27,27 @@ CREATE TABLE IF NOT EXISTS blocked (
CREATE UNIQUE INDEX IF NOT EXISTS idx_blocked_user_pair ON blocked (user, blocked);
----------------
-- TICTACTOE --
----------------
CREATE TABLE IF NOT EXISTS tictactoe (
id TEXT PRIMARY KEY NOT NULL,
time TEXT NOT NULL default (datetime('now')),
playerX TEXT NOT NULL,
playerO TEXT NOT NULL,
outcome TEXT NOT NULL,
FOREIGN KEY(playerX) REFERENCES user(id),
FOREIGN KEY(playerO) REFERENCES user(id)
id TEXT PRIMARY KEY NOT NULL,
time TEXT NOT NULL default (datetime ('now')),
playerX TEXT NOT NULL,
playerO TEXT NOT NULL,
outcome TEXT NOT NULL,
FOREIGN KEY (playerX) REFERENCES user (id),
FOREIGN KEY (playerO) REFERENCES user (id)
);
----------------
-- PONG --
----------------
CREATE TABLE IF NOT EXISTS pong (
id TEXT PRIMARY KEY NOT NULL,
time TEXT NOT NULL default (datetime('now')),
time TEXT NOT NULL default (datetime ('now')),
playerL TEXT NOT NULL,
playerR TEXT NOT NULL,
scoreL INTEGER NOT NULL,
@ -41,3 +57,32 @@ CREATE TABLE IF NOT EXISTS pong (
FOREIGN KEY (playerL) REFERENCES user (id),
FOREIGN KEY (playerR) REFERENCES user (id)
);
----------------
-- TOURNAMENT --
----------------
CREATE TABLE IF NOT EXISTS tournament (
id TEXT PRIMARY KEY NOT NULL,
time TEXT NOT NULL default (datetime ('now')),
owner TEXT NOT NULL,
FOREIGN KEY (owner) REFERENCES user (id)
);
CREATE TABLE IF NOT EXISTS tour_user (
id INTEGER PRIMARY KEY NOT NULL,
user TEXT NOT NULL,
tournament TEXT NOT NULL,
nickname TEXT NOT NULL,
score INTEGER NOT NULL,
FOREIGN KEY (user) REFERENCES user (id),
FOREIGN KEY (tournament) REFERENCES tournament (id)
);
CREATE TABLE IF NOT EXISTS tour_game (
id INTEGER PRIMARY KEY NOT NULL,
tournament TEXT NOT NULL,
game TEXT NOT NULL,
FOREIGN KEY (game) REFERENCES pong (id),
FOREIGN KEY (tournament) REFERENCES tournament (id)
);

View file

@ -108,7 +108,7 @@ type PongGameTable = {
local: number,
};
function pongGameFromRow(r: Partial<PongGameTable> | undefined): PongGame | undefined {
export function pongGameFromRow(r: Partial<PongGameTable> | undefined): PongGame | undefined {
if (isNullish(r)) return undefined;
if (isNullish(r.id)) return undefined;
if (isNullish(r.playerL)) return undefined;

View file

@ -0,0 +1,133 @@
import UUID, { newUUID } from '@shared/utils/uuid';
import type { Database } from './_base';
import { UserId } from './user';
import { PongGame, pongGameFromRow, PongGameId } from './pong';
import { isNullish } from '@shared/utils';
// never use this directly
// describe every function in the object
export interface ITournamentDb extends Database {
getTournamentById(
this: ITournamentDb,
id: TournamentId,
): TournamentData | null,
createNewTournamentById(
this: ITournamentDb,
owner: UserId,
users: { id: UserId, name: string, score: number }[],
games: PongGameId[],
): void,
getAllTournamentsData(this: ITournamentDb): TournamentTable[],
getLastTournament(this: ITournamentDb): TournamentTable | undefined;
};
export const TournamentImpl: Omit<ITournamentDb, keyof Database> = {
/**
* whole function description
*
* @param id the argument description
*
* @returns what does the function return ?
*/
getTournamentById(
this: ITournamentDb,
id: TournamentId,
): TournamentData | null {
// Fetch tournament
const tournament = this
.prepare('SELECT id, time, owner FROM tournament WHERE id = @id')
.get({ id }) as TournamentTable;
if (!tournament) {
return null;
}
// Fetch games
const games = this.prepare(`
SELECT
pong.*,
userL.name AS nameL,
userR.name AS nameR
FROM
tour_game
INNER JOIN pong
ON pong.id == tour_game.game
INNER JOIN user AS userL
ON pong.playerL = userL.id
INNER JOIN user AS userR
ON pong.playerR = userR.id
WHERE
tour_game.tournament = @id
ORDER BY pong.id`).all({ id })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.map((s: any) => {
const g: (PongGame & { nameL?: string, nameR?: string }) | undefined = pongGameFromRow(s);
if (isNullish(g)) return undefined;
g.nameL = s.nameL;
g.nameR = s.nameR;
if (isNullish(g.nameL) || isNullish(g.nameR)) return undefined;
return g as PongGame & { nameL: string, nameR: string };
}).filter(v => !isNullish(v));
;
// Fetch users
const users = this.prepare('SELECT id, user, tournament, nickname, score FROM tour_user WHERE tournament = @id').all({ id }) as TournamentUser[];
return {
...tournament,
games,
users,
};
},
createNewTournamentById(
this: ITournamentDb,
owner: UserId,
users: { id: UserId, name: string, score: number }[],
games: PongGameId[],
): void {
const tournamentId = newUUID() as TournamentId;
this.prepare('INSERT INTO tournament (id, owner) VALUES (@id, @owner)').run({ id: tournamentId, owner });
for (const u of users) {
this.prepare('INSERT INTO tour_user (user, nickname, score, tournament) VALUES (@id, @name, @score, @tournament)').run({ id: u.id, name: u.name, score: u.score, tournament: tournamentId });
}
for (const g of games) {
this.prepare('INSERT INTO tour_game (tournament, game) VALUES (@tournament, @game)').run({ tournament: tournamentId, game: g });
}
},
getAllTournamentsData(this: ITournamentDb): TournamentTable[] {
return this.prepare('SELECT * FROM tournament ORDER BY rowid').all() as TournamentTable[];
},
getLastTournament(this: ITournamentDb): TournamentTable | undefined {
return this.prepare('SELECT * FROM tournament ORDER BY rowid LIMIT 1').get() as TournamentTable | undefined;
},
};
export type TournamentId = UUID & { readonly __uuid: unique symbol };
export interface TournamentTable {
id: TournamentId;
time: string;
owner: UserId;
}
export interface TournamentUser {
user: UserId;
tournament: TournamentId;
nickname: string;
score: number;
}
export type TournamentGame = PongGame & { nameL: string, nameR: string };
export interface TournamentData extends TournamentTable {
games: TournamentGame[];
users: TournamentUser[];
}

View file

@ -2144,9 +2144,9 @@
]
}
},
"/createPausedGame": {
"/api/pong/createPausedGame": {
"post": {
"operationId": "pongCreatePauseGame",
"operationId": "createPauseGame",
"requestBody": {
"content": {
"application/json": {
@ -2451,28 +2451,20 @@
]
}
},
"/startPausedGame": {
"post": {
"operationId": "pongstartPauseGame",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"gameId"
],
"properties": {
"gameId": {
"type": "string",
"description": "'id' | <gameid>"
}
}
}
}
},
"required": true
},
"/api/pong/tournament/{id}": {
"get": {
"operationId": "TournamentData",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "id",
"required": true,
"description": "the tournament id"
}
],
"responses": {
"200": {
"description": "Default Response",
@ -2493,18 +2485,188 @@
},
"msg": {
"enum": [
"startPausedGame.success"
"tournamentData.success"
]
},
"payload": {
"type": "object",
"properties": {}
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"required": [
"owner",
"users",
"games",
"time"
],
"properties": {
"owner": {
"type": "string",
"description": "ownerId"
},
"users": {
"type": "array",
"items": {
"type": "object",
"required": [
"score",
"id",
"nickname"
],
"properties": {
"score": {
"type": "integer"
},
"id": {
"type": "string"
},
"nickname": {
"type": "string"
}
}
}
},
"games": {
"type": "array",
"items": {
"type": "object",
"required": [
"gameId",
"left",
"right",
"local",
"date",
"outcome"
],
"properties": {
"gameId": {
"type": "string",
"description": "gameId"
},
"left": {
"type": "object",
"required": [
"score",
"id",
"name"
],
"properties": {
"score": {
"type": "integer"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"right": {
"type": "object",
"required": [
"score",
"id",
"name"
],
"properties": {
"score": {
"type": "integer"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"local": {
"type": "boolean"
},
"date": {
"type": "string"
},
"outcome": {
"enum": [
"winL",
"winR",
"other"
]
}
}
}
},
"time": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
},
"404": {
"description": "Default Response",
"content": {
@ -2523,7 +2685,157 @@
},
"msg": {
"enum": [
"startPausedGame.no_such_game"
"tournamentData.failure.notFound"
]
}
}
}
}
}
}
},
"tags": [
"openapi_other"
]
}
},
"/api/pong/tournament/": {
"get": {
"operationId": "TournamentList",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"tournamentList.success"
]
},
"payload": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"owner",
"time"
],
"properties": {
"id": {
"type": "string",
"description": "tournamentId"
},
"owner": {
"type": "string",
"description": "ownerId"
},
"time": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"tournamentList.failure.generic"
]
}
}

View file

@ -8,9 +8,9 @@
"schemas": {}
},
"paths": {
"/createPausedGame": {
"/api/pong/createPausedGame": {
"post": {
"operationId": "pongCreatePauseGame",
"operationId": "createPauseGame",
"requestBody": {
"content": {
"application/json": {
@ -309,28 +309,20 @@
}
}
},
"/startPausedGame": {
"post": {
"operationId": "pongstartPauseGame",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"gameId"
],
"properties": {
"gameId": {
"type": "string",
"description": "'id' | <gameid>"
}
}
}
}
},
"required": true
},
"/api/pong/tournament/{id}": {
"get": {
"operationId": "TournamentData",
"parameters": [
{
"schema": {
"type": "string"
},
"in": "path",
"name": "id",
"required": true,
"description": "the tournament id"
}
],
"responses": {
"200": {
"description": "Default Response",
@ -351,18 +343,188 @@
},
"msg": {
"enum": [
"startPausedGame.success"
"tournamentData.success"
]
},
"payload": {
"type": "object",
"properties": {}
"required": [
"data"
],
"properties": {
"data": {
"type": "object",
"required": [
"owner",
"users",
"games",
"time"
],
"properties": {
"owner": {
"type": "string",
"description": "ownerId"
},
"users": {
"type": "array",
"items": {
"type": "object",
"required": [
"score",
"id",
"nickname"
],
"properties": {
"score": {
"type": "integer"
},
"id": {
"type": "string"
},
"nickname": {
"type": "string"
}
}
}
},
"games": {
"type": "array",
"items": {
"type": "object",
"required": [
"gameId",
"left",
"right",
"local",
"date",
"outcome"
],
"properties": {
"gameId": {
"type": "string",
"description": "gameId"
},
"left": {
"type": "object",
"required": [
"score",
"id",
"name"
],
"properties": {
"score": {
"type": "integer"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"right": {
"type": "object",
"required": [
"score",
"id",
"name"
],
"properties": {
"score": {
"type": "integer"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
}
},
"local": {
"type": "boolean"
},
"date": {
"type": "string"
},
"outcome": {
"enum": [
"winL",
"winR",
"other"
]
}
}
}
},
"time": {
"type": "string"
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
},
"404": {
"description": "Default Response",
"content": {
@ -381,7 +543,154 @@
},
"msg": {
"enum": [
"startPausedGame.no_such_game"
"tournamentData.failure.notFound"
]
}
}
}
}
}
}
}
}
},
"/api/pong/tournament/": {
"get": {
"operationId": "TournamentList",
"responses": {
"200": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg",
"payload"
],
"properties": {
"kind": {
"enum": [
"success"
]
},
"msg": {
"enum": [
"tournamentList.success"
]
},
"payload": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"owner",
"time"
],
"properties": {
"id": {
"type": "string",
"description": "tournamentId"
},
"owner": {
"type": "string",
"description": "ownerId"
},
"time": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
},
{
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"notLoggedIn"
]
},
"msg": {
"enum": [
"auth.noCookie",
"auth.invalidKind",
"auth.noUser",
"auth.invalid"
]
}
}
}
]
}
}
}
},
"404": {
"description": "Default Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"kind",
"msg"
],
"properties": {
"kind": {
"enum": [
"failure"
]
},
"msg": {
"enum": [
"tournamentList.failure.generic"
]
}
}

View file

@ -0,0 +1,101 @@
import { TournamentId } from '@shared/database/mixin/tournament';
import { isNullish, MakeStaticResponse, typeResponse } from '@shared/utils';
import { FastifyPluginAsync } from 'fastify';
import { Static, Type } from 'typebox';
const TournamentDataParams = Type.Object({
id: Type.String({ description: 'the tournament id' }),
});
type TournamentDataParams = Static<typeof TournamentDataParams>;
const TournamentDataResponse = {
'200': typeResponse('success', 'tournamentData.success', {
data: Type.Object({
owner: Type.String({ description: 'ownerId' }),
users: Type.Array(
Type.Object({
score: Type.Integer(),
id: Type.String(),
nickname: Type.String(),
}),
),
games: Type.Array(
Type.Object({
gameId: Type.String({ description: 'gameId' }),
left: Type.Object({
score: Type.Integer(),
id: Type.String(),
name: Type.String(),
}),
right: Type.Object({
score: Type.Integer(),
id: Type.String(),
name: Type.String(),
}),
local: Type.Boolean(),
date: Type.String(),
outcome: Type.Enum(['winL', 'winR', 'other']),
}),
),
time: Type.String(),
}),
}),
'404': typeResponse('failure', 'tournamentData.failure.notFound'),
};
type TournamentDataResponse = MakeStaticResponse<typeof TournamentDataResponse>;
const route: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.get<{ Params: TournamentDataParams }>(
'/api/pong/tournament/:id',
{
schema: {
params: TournamentDataParams,
response: TournamentDataResponse,
operationId: 'TournamentData',
},
config: { requireAuth: true },
},
async function(req, res) {
const tourId = req.params.id;
const data = this.db.getTournamentById(tourId as TournamentId);
if (isNullish(data)) {
return res.makeResponse(
404,
'failure',
'tournamentData.failure.notFound',
);
}
const typed_res: TournamentDataResponse['200']['payload']['data'] =
{
owner: data.owner,
time: data.time,
users: data.users.map((v) => ({
nickname: v.nickname,
score: v.score,
id: v.user,
})),
games: data.games.map((v) => ({
gameId: v.id,
left: {
score: v.left.score,
id: v.left.id,
name: `${v.nameL}-left`,
},
right: {
score: v.right.score,
id: v.right.id,
name: `${v.nameR}-right`,
},
local: v.local,
date: v.time.toString(),
outcome: v.outcome,
})),
};
console.log(JSON.stringify(typed_res));
return res.makeResponse(200, 'success', 'tournamentData.success', {
data: typed_res,
});
},
);
};
export default route;

View file

@ -0,0 +1,71 @@
import { MakeStaticResponse, typeResponse } from '@shared/utils';
import { FastifyPluginAsync } from 'fastify';
import { Type } from 'typebox';
const TournamentListResponse = {
'200': typeResponse('success', 'tournamentList.success', {
data: Type.Array(
Type.Object({
id: Type.String({ description: 'tournamentId' }),
owner: Type.String({ description: 'ownerId' }),
time: Type.String(),
}),
),
}),
'404': typeResponse('failure', 'tournamentList.failure.generic'),
};
/*
const TournamentListResponse = {
'200': typeResponse('success', 'tournamentHistory.success', {
data: Type.Array(
Type.Object({
owner: Type.String({ description: 'ownerId' }),
users: Type.Array(Type.Object({
score: Type.Integer(),
id: Type.String(),
name: Type.String(),
})),
game: Type.Object({
gameId: Type.String({ description: 'gameId' }),
left: Type.Object({
score: Type.Integer(),
id: Type.String(),
name: Type.String(),
}),
right: Type.Object({
score: Type.Integer(),
id: Type.String(),
name: Type.String(),
}),
local: Type.Boolean(),
date: Type.String(),
outcome: Type.Enum(['winL', 'winR', 'other']),
}),
date: Type.String(),
}),
),
}),
'404': typeResponse('failure', 'tournamentHistory.failure.generic'),
};
*/
type TournamentListResponse = MakeStaticResponse<typeof TournamentListResponse>;
const route: FastifyPluginAsync = async (fastify): Promise<void> => {
fastify.get(
'/api/pong/tournament/',
{
schema: {
response: TournamentListResponse,
operationId: 'TournamentList',
},
config: { requireAuth: true },
},
async function(req, res) {
void req;
const typed_data: TournamentListResponse['200']['payload']['data'] = this.db.getAllTournamentsData();
return res.makeResponse(200, 'success', 'tournamentHistory.success', { data: typed_data });
},
);
};
export default route;

View file

@ -208,6 +208,7 @@ class StateI {
private cleanupTournament() {
if (this.tournament === null) return;
if (this.tournament.state === 'ended') { this.fastify.db.createNewTournamentById(this.tournament.owner, this.tournament.users.values().toArray(), this.tournament.games); }
this.tournament = null;
this.fastify.log.info('Tournament has been ended');
}
@ -305,6 +306,8 @@ class StateI {
const user = this.users.get(sock.authUser.id);
if (!user) return;
if (this.tournament && this.tournament.users.has(sock.authUser.id)) return;
const gameId = newUUID() as unknown as GameId;
const g = Pong.makeLocal(user.id);
const iState: GameUpdate = StateI.getGameUpdateData(gameId, g);
@ -538,6 +541,8 @@ class StateI {
if (this.users.get(socket.authUser.id)?.currentGame !== null) return;
if (this.tournament && this.tournament.users.has(socket.authUser.id)) return;
this.queue.add(socket.authUser.id);
socket.emit('queueEvent', 'registered');
}

View file

@ -17,6 +17,7 @@ export class Tournament {
public matchup: [UserId, UserId][] = [];
public state: TournamentState = 'prestart';
public startTimeout: NodeJS.Timeout | undefined;
public games: PongGameId[] = [];
constructor(public owner: UserId) { }
@ -69,6 +70,7 @@ export class Tournament {
game.onEnd = () => this.gameEnd();
}
this.currentGame = gameId;
this.games.push(gameId);
}
else {
this.state = 'ended';
@ -78,15 +80,9 @@ export class Tournament {
}
public gameEnd() {
console.log(this);
State.fastify.log.info('tournament game ended');
if (!isNullish(this.currentGame)) {
State.fastify.log.info('HERE2');
State.fastify.log.info(State.games);
State.fastify.log.info(this.currentGame);
const game = State.games.get(this.currentGame);
if (game) {
State.fastify.log.info('HERE3');
const winner = game.checkWinner();
const winnerId = winner === 'left' ? game.userLeft : game.userRight;