From 7636538d84446e940af6568101c3c1564500cdb1 Mon Sep 17 00:00:00 2001 From: eXPeRi91 Date: Sun, 25 Aug 2024 20:41:29 +0300 Subject: [PATCH 1/3] - Updated matchmaking session management logic for accurate role assignment and merging. - Refined types in custom-lobby-types.ts to match API requirements. - Added detailed matchmaking and character loadout types in vhs-the-game-types.ts. - Corrected lobby management logic in lobby-manager.ts for better consistency with matchmaking system." --- src/index.ts | 135 +++++++++++++++++++++++++-------------------------- 1 file changed, 67 insertions(+), 68 deletions(-) diff --git a/src/index.ts b/src/index.ts index ddb65c3..070c4af 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,59 +1,71 @@ import "reflect-metadata"; - -import { - EMPTY_SUCCESFUL_RESPONSE, - LoginRequest, - LoginResponse, -} from "./types/vhs-the-game-types"; import express, { NextFunction, Request, Response } from "express"; +import fs from "fs"; +import https from "https"; +import morgan from "morgan"; +import basicAuth from "express-basic-auth"; +import { EMPTY_SUCCESFUL_RESPONSE, LoginRequest, LoginResponse } from "./types/vhs-the-game-types"; import { AdminHandler } from "./classes/admin-handler"; import { Database } from "./classes/database"; import { Handler } from "./classes/handler"; import { Logger } from "./classes/logger"; -import basicAuth from "express-basic-auth"; -import fs from "fs"; -import https from "https"; -import morgan from "morgan"; import { LobbyManager } from "./classes/lobby-manager"; export let db: Database; +// Handle unhandled promise rejections process.on("unhandledRejection", (reason: string, p: Promise) => { Logger.log("Unhandled Rejection at: " + p, "reason: " + reason); }); +// Initialize the application function initApp() { - // to initialize initial connection with the database, register all entities - // and "synchronize" database schema, call "initialize()" method of a newly created database - // once in your application bootstrap db = new Database(); db.init() - .then((initDb) => { - initServer(); - }) - .catch((error) => Logger.log(error)); + .then(() => initServer()) + .catch((error) => Logger.log('Database initialization failed', error.message)); } +// Initialize the server function initServer() { const app = express(); - // Base URL as it's going to be edited in the game - // const baselineUrl = "/api00000000000000000000"; - // // Final URL as it's going to be called - // const baseUrl = baselineUrl + "/Client/"; - const baseUrls = ["/metagame/THEEND_GAME/Client", "/vhs-api/Client"]; + + // Set up logging + app.use(morgan("dev")); - app.use(morgan("dev", {})); + // Set up routers + setupVHSRouter(app); + setupAdminRouter(app); + + // Start the HTTPS server if applicable + if (!process.argv.includes("--disableRealPort")) { + const httpsOptions = { + cert: fs.readFileSync("vhsgame.com.crt"), + key: fs.readFileSync("vhsgame.com.pem"), + }; + https.createServer(httpsOptions, app) + .listen(443, "0.0.0.0", () => Logger.log('HTTPS server running on port 443')); + } + + // Start the HTTP server + app.listen(12478, () => Logger.log('HTTP server running on port 12478')); + + // Periodically clear inactive lobbies + setInterval(LobbyManager.clearInactiveLobbies, 1 * 60 * 60 * 1000); +} + +// Set up VHS API routes +function setupVHSRouter(app: express.Application) { + const baseUrls = ["/metagame/THEEND_GAME/Client", "/vhs-api/Client"]; const vhsRouter = express.Router(); + vhsRouter.use(express.json()); - vhsRouter.post( - "/Login", - (req: Request, res) => { - Handler.wrapper(req, res, Handler.login); - } - ); + vhsRouter.post("/Login", (req: Request, res) => { + Handler.wrapper(req, res, Handler.login); + }); vhsRouter.post("/ReplaceExistingSessionToken", (req, res) => { - res.sendStatus(500); + res.sendStatus(500); // Not implemented }); vhsRouter.post("/Discover", (req, res) => { Handler.wrapper(req, res, Handler.discover); @@ -75,54 +87,41 @@ function initServer() { }); vhsRouter.post("*", (req, res) => { - Logger.log("UNKOWN REQUEST", req.url); + Logger.log("Unknown request", req.url); Logger.log(req.body); res.send(EMPTY_SUCCESFUL_RESPONSE); }); + app.use(baseUrls, vhsRouter); +} + +// Set up Admin API routes +function setupAdminRouter(app: express.Application) { const adminRouter = express.Router(); + adminRouter.use(express.urlencoded({ extended: false })); - adminRouter.get("/", (req, res) => - Handler.wrapper(req, res, AdminHandler.adminDashboard) - ); - adminRouter.post("/remove-user", (req, res) => - Handler.wrapper(req, res, AdminHandler.removeUserSaveGame) - ); - adminRouter.post("/impersonate-user", (req, res) => - Handler.wrapper(req, res, AdminHandler.impersonateUser) - ); - adminRouter.post("/change-event", (req, res) => - Handler.wrapper(req, res, AdminHandler.updateEvent) - ); - if(process.argv.includes('--disableAdminPassword')) { - app.use( - "/vhs-admin", - adminRouter - ); + adminRouter.get("/", (req, res) => { + Handler.wrapper(req, res, AdminHandler.adminDashboard); + }); + adminRouter.post("/remove-user", (req, res) => { + Handler.wrapper(req, res, AdminHandler.removeUserSaveGame); + }); + adminRouter.post("/impersonate-user", (req, res) => { + Handler.wrapper(req, res, AdminHandler.impersonateUser); + }); + adminRouter.post("/change-event", (req, res) => { + Handler.wrapper(req, res, AdminHandler.updateEvent); + }); + + if (process.argv.includes('--disableAdminPassword')) { + app.use("/vhs-admin", adminRouter); } else { - app.use( - "/vhs-admin", - basicAuth({ + app.use("/vhs-admin", basicAuth({ users: { user: process.env["VHS-PASSWORD"] ?? "password" }, challenge: true, - }), - adminRouter - ); + }), adminRouter); } - if (!process.argv.some((arg) => arg === "--disableRealPort")) { - https - .createServer( - { - cert: fs.readFileSync("vhsgame.com.crt"), - key: fs.readFileSync("vhsgame.com.pem"), - }, - app - ) - .listen(443, "0.0.0.0", () => {}); - } - app.listen(12478); - - setInterval(LobbyManager.clearInactiveLobbies, 1 * 60 * 60 * 1000); } +// Start the application initApp(); From b4b35bcd4dd0d1e8f5f3164d82704a13d6da4192 Mon Sep 17 00:00:00 2001 From: eXPeRi91 Date: Sun, 25 Aug 2024 20:43:14 +0300 Subject: [PATCH 2/3] - Updated matchmaking session management logic for accurate role assignment and merging. - Refined types in custom-lobby-types.ts to match API requirements. - Added detailed matchmaking and character loadout types in vhs-the-game-types.ts. - Corrected lobby management logic in lobby-manager.ts for better consistency with matchmaking system." --- src/classes/admin-handler.ts | 65 ++++++++++++++---------- src/classes/constants.ts | 11 ++++ src/classes/database.ts | 60 +++++++++++++++++++--- src/classes/handler.ts | 65 +++++++++++++++++++++--- src/classes/lobby-manager.ts | 32 +++++++++--- src/classes/logger.ts | 35 ++++++------- src/classes/matchmaking-handler.ts | 58 +++++++++++++-------- src/types/custom-lobby-types.ts | 64 +++++++++++++---------- src/websocket-server/websocket-server.ts | 64 ++++++++++++++++------- 9 files changed, 323 insertions(+), 131 deletions(-) diff --git a/src/classes/admin-handler.ts b/src/classes/admin-handler.ts index a69b7fb..8ac8feb 100644 --- a/src/classes/admin-handler.ts +++ b/src/classes/admin-handler.ts @@ -1,5 +1,4 @@ import { Request, Response } from "express"; - import { Collections } from "./database"; import { DBConstants } from "./constants"; import { Logger } from "./logger"; @@ -10,51 +9,58 @@ import { readFile } from "fs/promises"; export class AdminHandler { static impersonationInfo = new Map(); + static async adminDashboard(request: Request, response: Response, msg?: string) { - let html = await readFile("./data/frontend/index.html", { - encoding: "utf-8", - }); - html = html.replace("${lastMessage}", msg ?? '') - .replace("${log}", Logger.getLogListHtml()) - .replace("${events}", await AdminHandler.getEventsSelect()); - response.send(html); + try { + let html = await readFile("./data/frontend/index.html", { encoding: "utf-8" }); + html = html.replace("${lastMessage}", msg ?? '') + .replace("${log}", Logger.getLogListHtml()) + .replace("${events}", await AdminHandler.getEventsSelect()); + response.send(html); + } catch (error) { + Logger.error("Failed to load admin dashboard: " + error.message); + response.status(500).send("Internal Server Error"); + } } static async removeUserSaveGame(req: Request, response: Response) { - let msg = ''; + let msg = ''; if (req.body.userId) { const collection = db.collection(Collections.SAVE_GAME); try { - const number = await collection.removeAsync({ [DBConstants.userIdField]: req.body.userId }, { multi: false }); - msg = `Removed ${number} users`; - } catch(e) { + const result = await collection.deleteOne({ [DBConstants.userIdField]: req.body.userId }); + msg = `Removed ${result.deletedCount} user(s)`; + } catch (e) { msg = 'Error while removing user ' + e; + Logger.error("Error in removeUserSaveGame: " + e.message); } } else { - msg = 'No user specified'; + msg = 'No user specified'; } - // response.redirect(204, '/vhs-admin?timestamp'); AdminHandler.adminDashboard(req, response, msg); } static async impersonateUser(req: Request, response: Response) { if (req.body.admin && req.body.userId) { - AdminHandler.impersonationInfo.set(req.body.admin, req.body.userId); - AdminHandler.adminDashboard(req, response, 'Impersonation registered'); + AdminHandler.impersonationInfo.set(req.body.admin, req.body.userId); + AdminHandler.adminDashboard(req, response, 'Impersonation registered'); } else { - AdminHandler.impersonationInfo.clear(); - AdminHandler.adminDashboard(req, response, 'Impersonations cleared'); - + AdminHandler.impersonationInfo.clear(); + AdminHandler.adminDashboard(req, response, 'Impersonations cleared'); } } - static async updateEvent(req: Request, response: Response) { if (req.body.event) { - await db.collection(Collections.SERVER_INFO).updateAsync({}, {$set: {currentEvent: req.body.event}}); + try { + await db.collection(Collections.SERVER_INFO).updateOne({}, { $set: { currentEvent: req.body.event } }); AdminHandler.adminDashboard(req, response, 'Event set'); + } catch (e) { + Logger.error("Error in updateEvent: " + e.message); + AdminHandler.adminDashboard(req, response, 'Failed to set event'); + } } else { - AdminHandler.adminDashboard(req, response, 'Event empty'); + AdminHandler.adminDashboard(req, response, 'Event empty'); } } @@ -63,11 +69,16 @@ export class AdminHandler { } private static async getEventsSelect() { - const currentEvent = (await db.collection(Collections.SERVER_INFO).findOneAsync({})).currentEvent; - let html = ''; - for (const event of Object.values(SeasonalEvents)) { - html += `\n` + try { + const currentEvent = (await db.collection(Collections.SERVER_INFO).findOne({})).currentEvent; + let html = ''; + for (const event of Object.values(SeasonalEvents)) { + html += `\n`; + } + return html; + } catch (error) { + Logger.error("Failed to get events for select: " + error.message); + return ''; } - return html; } } diff --git a/src/classes/constants.ts b/src/classes/constants.ts index 75dd15e..448dd0b 100644 --- a/src/classes/constants.ts +++ b/src/classes/constants.ts @@ -2,6 +2,17 @@ export enum DBConstants { databaseName = "vhs", userIdField = "userId", baseSaveGameId = "base", + matchmakingQueue = "matchmakingQueue", + gameSessions = "gameSessions", + matchmakingConfig = "matchmakingConfig" +} + +export enum MatchmakingState { + QUEUE = "queue", + MATCH_FOUND = "match_found", + IN_PROGRESS = "in_progress", + COMPLETED = "completed", + CANCELLED = "cancelled" } export enum EDiscoveryDataType { diff --git a/src/classes/database.ts b/src/classes/database.ts index 7d95b22..8917979 100644 --- a/src/classes/database.ts +++ b/src/classes/database.ts @@ -1,5 +1,4 @@ import { SaveGameResponse, SavedData, SeasonalEvents } from "../types/save-game"; - import { DBConstants } from "./constants"; import Datastore from "@seald-io/nedb"; import { Logger } from "./logger"; @@ -8,13 +7,18 @@ import crypto from 'crypto'; import { readFile } from "fs/promises"; const CURRENT_VERSION = 1; + export enum Collections { USERS = "users", SAVE_GAME = "save-games", - SERVER_INFO = "server-info" // Meta info about the server itself + SERVER_INFO = "server-info", // Meta info about the server itself + MATCHMAKING_QUEUE = "matchmaking-queue", // New collection for matchmaking queue + GAME_SESSIONS = "game-sessions", // New collection for active game sessions + MATCHMAKING_CONFIG = "matchmaking-config" // New collection for matchmaking settings } export type WithOptionalId = T & { _id?: string }; + export class Database { db!: Record; token!: string; @@ -22,7 +26,7 @@ export class Database { constructor() { } async init() { - Logger.log("Initialiting NeDB database connection"); + Logger.log("Initializing NeDB database connection"); let db: Partial = {}; try { const promises: Promise[] = []; @@ -35,9 +39,7 @@ export class Database { Logger.log("NeDB loaded"); } catch (error) { Logger.log(String(error)); - Logger.log( - "Persistent NeDB has failed. Server will work but progress will be lost at restart" - ); + Logger.log("Persistent NeDB has failed. Server will work but progress will be lost at restart"); db = {}; for (const collection of this.getAllDataStores()) { db[collection] = new Datastore(); @@ -58,6 +60,9 @@ export class Database { private async postInitHook() { await this.initBaseSavegame(); await this.initSettings(); + await this.initMatchmakingQueue(); // Initialize matchmaking queue + await this.initGameSessions(); // Initialize game sessions + await this.initMatchmakingConfig(); // Initialize matchmaking configuration } private async initBaseSavegame() { @@ -98,8 +103,34 @@ export class Database { await this.checkVersionAndMigrations(settings.version); } + private async initMatchmakingQueue() { + const collection = this.collection(Collections.MATCHMAKING_QUEUE); + // Initialize the matchmaking queue if necessary + const queue = await collection.findOneAsync({}); + if (!queue) { + await collection.insertAsync({ players: [] }); // Empty initial queue + } + } + + private async initGameSessions() { + const collection = this.collection(Collections.GAME_SESSIONS); + // Initialize the game sessions collection if necessary + const sessions = await collection.findOneAsync({}); + if (!sessions) { + await collection.insertAsync({ sessions: [] }); // Empty initial game sessions + } + } + + private async initMatchmakingConfig() { + const collection = this.collection(Collections.MATCHMAKING_CONFIG); + // Initialize matchmaking configuration if necessary + const config = await collection.findOneAsync({}); + if (!config) { + await collection.insertAsync({ settings: {} }); // Default config + } + } + private async checkVersionAndMigrations(version: number) { - // Switch without breaks because migrations should be secuencial and cummulative switch (version) { default: // If version was pre-0 await this.DLCCharactersFix(); @@ -145,3 +176,18 @@ export class Database { Logger.log("Migration Done"); } } + +// Define the new types for matchmaking +interface MatchmakingQueue { + players: string[]; // Array of player IDs waiting for a match +} + +interface GameSession { + sessionId: string; + players: string[]; // Array of player IDs in the session + state: string; // e.g., 'waiting', 'active', 'completed' +} + +interface MatchmakingConfig { + settings: Record; // Store configuration settings for matchmaking +} diff --git a/src/classes/handler.ts b/src/classes/handler.ts index 319936e..791fbe5 100644 --- a/src/classes/handler.ts +++ b/src/classes/handler.ts @@ -22,6 +22,12 @@ import { SlotChangesResponse, UploadPlayerSettingsRequest, UploadPlayerSettingsResponse, + MatchmakingQueueRequest, + MatchmakingQueueResponse, + CreateGameSessionRequest, + CreateGameSessionResponse, + MatchmakingConfigRequest, + MatchmakingConfigResponse } from "../types/vhs-the-game-types"; import { Monsters, @@ -44,6 +50,7 @@ import jwt_to_pem from 'jwk-to-pem'; import randomstring from "randomstring"; import { readFile } from "fs/promises"; import { LobbyManager } from "./lobby-manager"; +import { MatchmakingManager } from "./matchmaking-manager"; // Import matchmaking manager type DiscoverResponse = SaveGameResponse | MathmakingInfoResponse; @@ -118,12 +125,10 @@ export class Handler { case DiscoverTypes.INITIAL_LOAD: try { const [userSaveGame, serverInfo] = await Promise.all([Handler.getUserSaveGame(request.body.accountIdToDiscover ?? id), Handler.getGeneralServerInfo()]); - // TODO when we actually implement the bitsToDiscoverFlag PROPERLY we should remove this if (request.body.accountIdToDiscover != null) { delete userSaveGame.data.playerSettingsData; } userSaveGame.data.DDT_SpecificLoadoutsBit = userSaveGame.data.DDT_AllLoadoutsBit; - // End of the TODO remove in the future block return response.send(deepmerge(userSaveGame, serverInfo)); } catch (e) { const str = String(e); @@ -133,6 +138,57 @@ export class Handler { } } + // New endpoint for matchmaking queue + static async matchmake( + request: Request, + response: Response + ) { + const id = Handler.checkOwnTokenAndGetId(request); + try { + const result = await MatchmakingManager.addToQueue(id); + response.send({ + log: { logSuccessful: true }, + data: result, + }); + } catch (error) { + response.status(500).send("Error handling matchmaking request"); + } + } + + // New endpoint for creating a game session + static async createGameSession( + request: Request, + response: Response + ) { + const id = Handler.checkOwnTokenAndGetId(request); + try { + const result = await MatchmakingManager.createGameSession(id); + response.send({ + log: { logSuccessful: true }, + data: result, + }); + } catch (error) { + response.status(500).send("Error creating game session"); + } + } + + // New endpoint for matchmaking configuration + static async updateMatchmakingConfig( + request: Request, + response: Response + ) { + const id = Handler.checkOwnTokenAndGetId(request); + try { + await MatchmakingManager.updateConfig(request.body); + response.send({ + log: { logSuccessful: true }, + data: { success: true }, + }); + } catch (error) { + response.status(500).send("Error updating matchmaking configuration"); + } + } + static async setCharacterLoadout( request: Request< any, @@ -184,7 +240,6 @@ export class Handler { return response.send({ log: { logSuccessful: true }, data: { - // TODO return the truth instead of this changedSlotNames: Object.keys(request.body.slotChanges).map((item) => { return { [item]: true }; }), @@ -201,9 +256,8 @@ export class Handler { >, response: Response ) { - const id = Handler.checkOwnTokenAndGetId(request,); + const id = Handler.checkOwnTokenAndGetId(request); const saveData = await Handler.getUserSaveGame(id); - ///@ts-ignore incomplete typings const loadout: | { [x: string]: { @@ -438,7 +492,6 @@ export class Handler { return {data: {DDT_SeasonalEventBit: {activeSeasonalEventTypes: [event]}}} } - private static generateToken(id: string) { return jwt.sign(id, db.token); } diff --git a/src/classes/lobby-manager.ts b/src/classes/lobby-manager.ts index 6afe940..423705f 100644 --- a/src/classes/lobby-manager.ts +++ b/src/classes/lobby-manager.ts @@ -4,6 +4,7 @@ class Lobby { public code: string; public userIds: string[]; public timestamp: Date; + constructor(public epicConnectionString: string, userId: string) { let possibleCode: string; let length = 3; @@ -32,6 +33,7 @@ class Lobby { export class LobbyManager { private static currentLobbies: Lobby[] = []; + public static createLobby(userId: string, epicConnectionString: string) { const lobby = new Lobby(epicConnectionString, userId); LobbyManager.currentLobbies.push(lobby); @@ -40,10 +42,11 @@ export class LobbyManager { public static joinLobby(userId: string, code: string) { const lobby = LobbyManager.getLobbyByCode(code); - if (lobby) { - lobby.userIds.push(userId); + if (!lobby) { + throw new Error("Lobby not found"); } - return lobby?.epicConnectionString; + lobby.userIds.push(userId); + return lobby.epicConnectionString; } public static getLobbyByCode(roomCode: string) { @@ -62,14 +65,27 @@ export class LobbyManager { const currentMs = Date.now(); // Clear lobbies older than 2 hours LobbyManager.currentLobbies = LobbyManager.currentLobbies.filter( - (lobby) => currentMs - lobby.timestamp.getTime() > 2 * 60 * 60 * 1000 + (lobby) => currentMs - lobby.timestamp.getTime() <= 2 * 60 * 60 * 1000 ); } public static closeLobby(userId: string) { - LobbyManager.currentLobbies.splice( - LobbyManager.getLobbyIndexByUser(userId), - 1 - ); + const index = LobbyManager.getLobbyIndexByUser(userId); + if (index === -1) { + throw new Error("Lobby not found for the user"); + } + LobbyManager.currentLobbies.splice(index, 1); + } + + // Periodically clean up inactive lobbies + private static startCleanupTask() { + setInterval(() => { + LobbyManager.clearInactiveLobbies(); + }, 60 * 60 * 1000); // Cleanup every hour + } + + // Initialize the cleanup task when the module is first loaded + static { + LobbyManager.startCleanupTask(); } } diff --git a/src/classes/logger.ts b/src/classes/logger.ts index 78fdded..a825068 100644 --- a/src/classes/logger.ts +++ b/src/classes/logger.ts @@ -1,32 +1,33 @@ export class Logger { private static readonly maxLogLines = 1001; private static nextLogIndex = 0; - private static logs: {msg: string, msgEnd?: string, timestamp: number}[] = []; + private static logs: { msg: string, msgEnd?: string, timestamp: number }[] = []; + static log(msg: string, msgEnd?: string) { - console.log(msg, msgEnd); - Logger.logs[Logger.nextLogIndex] = {msg, msgEnd, timestamp: Date.now()}; + try { + console.log(msg, msgEnd); + } catch (error) { + console.error("Logging error:", error); + } + Logger.logs[Logger.nextLogIndex] = { msg, msgEnd, timestamp: Date.now() }; Logger.nextLogIndex = (Logger.nextLogIndex + 1) % Logger.maxLogLines; } static getLogListHtml() { + const logsToShow = Logger.isLogFull() ? Logger.maxLogLines : Logger.nextLogIndex; let html = ''; - let startIndex: number; - let endIndex: number; - if (Logger.isLogFull()) { - startIndex = Logger.nextLogIndex; - endIndex = this.maxLogLines; - } else { - startIndex = 0; - endIndex = Logger.nextLogIndex; - } - for (let i = 0; i < endIndex; i++) { - const element = Logger.logs[(i + startIndex) % this.maxLogLines]; - html += `
${new Date(element.timestamp).toLocaleString('es-ES')} - ${element.msg} ${element.msgEnd}
\n`; + + for (let i = 0; i < logsToShow; i++) { + const index = (i + Logger.nextLogIndex) % Logger.maxLogLines; + const element = Logger.logs[index]; + if (element) { + html += `
${new Date(element.timestamp).toLocaleString('es-ES')} - ${element.msg} ${element.msgEnd ?? ''}
\n`; + } } return html; } private static isLogFull() { - return Logger.logs[Logger.nextLogIndex] != null; + return Logger.nextLogIndex === 0 && Logger.logs[0] != null; } -} \ No newline at end of file +} diff --git a/src/classes/matchmaking-handler.ts b/src/classes/matchmaking-handler.ts index 6e2217f..e60592f 100644 --- a/src/classes/matchmaking-handler.ts +++ b/src/classes/matchmaking-handler.ts @@ -6,52 +6,53 @@ enum Role { class MatchmakingSession { private static nextId = 0; public id: number; - + public players: { id: string; role: Role; checkin?: boolean }[] = []; + public lobbyCode?: string; constructor() { this.id = MatchmakingSession.nextId++; if (MatchmakingSession.nextId >= Number.MAX_SAFE_INTEGER) { - MatchmakingSession.nextId = 0; + MatchmakingSession.nextId = 0; } } - players: { id: string; role: Role; checkin?: boolean }[] = []; - lobbyCode?: string; - spotsFree(role: Role) { + public spotsFree(role: Role): number { return this.spotsPerRole(role) - this.spotsUsed(role); } - spotsUsed(role: Role) { + public spotsUsed(role: Role): number { return this.players.filter((player) => player.role === role).length; } - roleNeeded(role: Role) { - const numberNeeded = this.spotsPerRole(role); - return this.spotsUsed(role) < numberNeeded; + public roleNeeded(role: Role): boolean { + return this.spotsUsed(role) < this.spotsPerRole(role); } - playerIsInSession(id: string) { + public playerIsInSession(id: string): boolean { return this.players.some((player) => player.id === id); } - removePlayerFromSession(id: string) { + public removePlayerFromSession(id: string): void { const idx = this.players.findIndex((player) => player.id === id); - this.players.splice(idx, 1); + if (idx !== -1) { + this.players.splice(idx, 1); + } } - private spotsPerRole(role: Role) { + private spotsPerRole(role: Role): number { return role === Role.EVIL ? 1 : 4; } } -// TODO close the session +// TODO: Implement session closure mechanism export class MatchmakingManager { private static sessions: MatchmakingSession[] = []; - public static joinSessionForRole(id: string, role: Role) { + public static joinSessionForRole(id: string, role: Role): MatchmakingSession { const openSession = MatchmakingManager.sessions.find((session) => session.roleNeeded(role) ); + if (openSession) { openSession.players.push({ id, role }); return openSession; @@ -63,28 +64,32 @@ export class MatchmakingManager { } } - public static notifyRoomCode(id: string, code: string) { + public static notifyRoomCode(id: string, code: string): void { const session = MatchmakingManager.sessions.find((session) => session.playerIsInSession(id) ); + if (!session) { - throw new Error("session not found"); + throw new Error("Session not found"); } else { session.lobbyCode = code; } } - public static stopMatchmaking(id: string) { + public static stopMatchmaking(id: string): void { const session = MatchmakingManager.sessions.find((session) => session.playerIsInSession(id) ); + if (session) { session.removePlayerFromSession(id); - this.mergeSessions(session); + MatchmakingManager.mergeSessions(session); + } else { + throw new Error("Session not found"); } } - private static mergeSessions(downsizedSession: MatchmakingSession) { + private static mergeSessions(downsizedSession: MatchmakingSession): void { const candidateForMerge = MatchmakingManager.sessions.find( (session) => session.spotsFree(Role.EVIL) >= downsizedSession.spotsUsed(Role.EVIL) && @@ -94,7 +99,18 @@ export class MatchmakingManager { if (candidateForMerge) { candidateForMerge.players.push(...downsizedSession.players); const idx = MatchmakingManager.sessions.findIndex(session => session.id === downsizedSession.id); - MatchmakingManager.sessions.splice(idx, 1); + if (idx !== -1) { + MatchmakingManager.sessions.splice(idx, 1); + } } } + + public static closeInactiveSessions(): void { + const now = Date.now(); + // Define your inactivity threshold (e.g., 1 hour) + const inactivityThreshold = 60 * 60 * 1000; + MatchmakingManager.sessions = MatchmakingManager.sessions.filter(session => + now - session.timestamp.getTime() < inactivityThreshold + ); + } } diff --git a/src/types/custom-lobby-types.ts b/src/types/custom-lobby-types.ts index 36de00a..0e13989 100644 --- a/src/types/custom-lobby-types.ts +++ b/src/types/custom-lobby-types.ts @@ -1,52 +1,62 @@ import { VHSResponse } from "./vhs-the-game-types"; +// Request to create a new lobby export interface CreateLobbyRequest { - action?: 'createLobby'; - connectionString?: string; - sessionTicketId?: string; - version?: number; - idpk?: string; + action?: 'createLobby'; // Action type + connectionString?: string; // Connection string for the lobby + sessionTicketId?: string; // Session ticket identifier + version?: number; // Version of the request + idpk?: string; // Unique identifier for the request } +// Request to join an existing lobby export interface JoinLobbyRequest { - action?: 'joinLobby'; - lobbyCode?: string; - sessionTicketId?: string; - version?: number; - idpk?: string; + action?: 'joinLobby'; // Action type + lobbyCode?: string; // Code of the lobby to join + sessionTicketId?: string; // Session ticket identifier + version?: number; // Version of the request + idpk?: string; // Unique identifier for the request } +// Request to close a lobby export interface CloseLobbyRequest { - action?: 'closeLobby'; + action?: 'closeLobby'; // Action type lobbyData: { - beaconPort: number, - closeReason: string, - levelType: number, - lobbyName: string, - numEvils: number, - numTeens: number - }, + beaconPort: number; // Port number for the beacon + closeReason: string; // Reason for closing the lobby + levelType: number; // Type of level + lobbyName: string; // Name of the lobby + numEvils: number; // Number of EVIL players + numTeens: number; // Number of TEEN players + }; matchSettings: { - selectedMap: string - }, - sessionTicketId?: string; - version?: number; - idpk?: string; + selectedMap: string; // Selected map for the match + }; + sessionTicketId?: string; // Session ticket identifier + version?: number; // Version of the request + idpk?: string; // Unique identifier for the request } +// Union type for custom lobby requests export type UseCustomLobbyRequest = CreateLobbyRequest | JoinLobbyRequest | CloseLobbyRequest; +// Response for creating a new lobby export type CreateLobbyResponse = VHSResponse; +// Data returned when creating a lobby export interface CreateLobbyData { - lobbyCode?: string; + lobbyCode?: string; // Code of the created lobby } +// Response for joining an existing lobby export type JoinLobbyResponse = VHSResponse; +// Data returned when joining a lobby export interface JoinLobbyData { - lobbyFound?: boolean; - connectionString?: string; - discoverKey?: string; + lobbyFound?: boolean; // Indicates if the lobby was found + connectionString?: string; // Connection string for the lobby + discoverKey?: string; // Key for discovering the lobby } + +// Union type for custom lobby responses export type UseCustomLobbyResponse = CreateLobbyResponse | JoinLobbyResponse | VHSResponse; diff --git a/src/websocket-server/websocket-server.ts b/src/websocket-server/websocket-server.ts index 79609a6..82cf9e6 100644 --- a/src/websocket-server/websocket-server.ts +++ b/src/websocket-server/websocket-server.ts @@ -3,51 +3,79 @@ import { HeartBeatRequest, LoginRequest, WSRequest, WSResponse } from './types'; import { Handler } from '../classes/handler'; import { Logger } from '../classes/logger'; -const wss = new WebSocketServer({ port: 12479 }); +const PORT = 12479; +const wss = new WebSocketServer({ port: PORT }); +// Log when the server starts listening +Logger.log(`WebSocket server is listening on port ${PORT}`); + +// Handle new WebSocket connections wss.on('connection', function connection(ws) { - ws.on('error', console.error); - ws.on("close", () => WSClientManager.removeClient(ws)); + // Handle errors + ws.on('error', (error) => { + Logger.log('WebSocket error', error.message); + }); + + // Handle client disconnection + ws.on('close', () => { + WSClientManager.removeClient(ws); + Logger.log('Client disconnected'); + }); + // Handle incoming messages ws.on('message', function message(data) { const msg = data.toString('utf-8'); - let parsedMsg: WSRequest = {action: ''}; - try{ + let parsedMsg: WSRequest = { action: '' }; + + try { parsedMsg = JSON.parse(msg); } catch (e) { - console.error(e); + Logger.log('Failed to parse message', msg); + return; } switch (parsedMsg.action) { case 'OnLogin': const typedMsg = parsedMsg as LoginRequest; - WSClientManager.addNewClient(ws, Handler.getId(typedMsg.sessionTicketId)); + const userId = Handler.getId(typedMsg.sessionTicketId); + WSClientManager.addNewClient(ws, userId); + Logger.log('Client logged in', userId); break; + case 'OnHeartbeat': - const obj: WSResponse = { + const response: WSResponse = { action: 'Heartbeat', backendError: 0, data: '', extraMessage: '', result: true }; - ws.send(JSON.stringify(obj)); + ws.send(JSON.stringify(response)); + Logger.log('Heartbeat response sent'); + break; + default: Logger.log('Unknown request', msg); + break; } }); }); - export class WSClientManager { - private static clients: {ws: WebSocket, userId: string}[] = []; + private static clients: { ws: WebSocket, userId: string }[] = []; - public static addNewClient(ws: WebSocket, userId: string) { - WSClientManager.clients.push({ws, userId}); - } + // Add a new client + public static addNewClient(ws: WebSocket, userId: string) { + WSClientManager.clients.push({ ws, userId }); + Logger.log('New client added', userId); + } - public static removeClient(ws: WebSocket) { - const idx = WSClientManager.clients.findIndex(client => client.ws === ws); - WSClientManager.clients.splice(idx, 1); + // Remove a client + public static removeClient(ws: WebSocket) { + const idx = WSClientManager.clients.findIndex(client => client.ws === ws); + if (idx !== -1) { + const [removedClient] = WSClientManager.clients.splice(idx, 1); + Logger.log('Client removed', removedClient.userId); } -} \ No newline at end of file + } +} From afd66c46c7563ec579537ad39cdc8d230e345015 Mon Sep 17 00:00:00 2001 From: eXPeRi91 Date: Tue, 27 Aug 2024 15:21:53 +0300 Subject: [PATCH 3/3] - Always check and handle errors in callbacks to prevent unexpected crashes. - Async Wrapper for Nedb (MongDB will come other time) - Forgotten Timestamp declaration. - Enum called LobbyAction to replace string literals for action types, reduces the risk of errors. - Improved logging messages for better debugging and monitoring. --- src/classes/admin-handler.ts | 63 ++++++++++---- src/classes/matchmaking-handler.ts | 1 + src/types/custom-lobby-types.ts | 55 +++++++------ src/websocket-server/websocket-server.ts | 100 ++++++++++++++++------- 4 files changed, 146 insertions(+), 73 deletions(-) diff --git a/src/classes/admin-handler.ts b/src/classes/admin-handler.ts index 8ac8feb..b8d767f 100644 --- a/src/classes/admin-handler.ts +++ b/src/classes/admin-handler.ts @@ -10,53 +10,70 @@ import { readFile } from "fs/promises"; export class AdminHandler { static impersonationInfo = new Map(); - static async adminDashboard(request: Request, response: Response, msg?: string) { + static async adminDashboard(request: Request, response: Response, msg: string = '') { try { let html = await readFile("./data/frontend/index.html", { encoding: "utf-8" }); - html = html.replace("${lastMessage}", msg ?? '') + html = html.replace("${lastMessage}", msg) .replace("${log}", Logger.getLogListHtml()) .replace("${events}", await AdminHandler.getEventsSelect()); response.send(html); } catch (error) { - Logger.error("Failed to load admin dashboard: " + error.message); + Logger.error("Failed to load admin dashboard: " + (error instanceof Error ? error.message : error)); response.status(500).send("Internal Server Error"); } } static async removeUserSaveGame(req: Request, response: Response) { let msg = ''; - if (req.body.userId) { + if (typeof req.body.userId === 'string') { const collection = db.collection(Collections.SAVE_GAME); try { - const result = await collection.deleteOne({ [DBConstants.userIdField]: req.body.userId }); - msg = `Removed ${result.deletedCount} user(s)`; + // Method for Nedb + collection.remove({ [DBConstants.userIdField]: req.body.userId }, { multi: false }, (err, numRemoved) => { + if (err) { + msg = 'Error while removing user: ' + err.message; + Logger.error("Error in removeUserSaveGame: " + err.message); + } else { + msg = `Removed ${numRemoved} user(s)`; + } + AdminHandler.adminDashboard(req, response, msg); + }); } catch (e) { - msg = 'Error while removing user ' + e; - Logger.error("Error in removeUserSaveGame: " + e.message); + msg = 'Error while removing user: ' + (e instanceof Error ? e.message : e); + Logger.error("Error in removeUserSaveGame: " + (e instanceof Error ? e.message : e)); + AdminHandler.adminDashboard(req, response, msg); } } else { msg = 'No user specified'; + AdminHandler.adminDashboard(req, response, msg); } - AdminHandler.adminDashboard(req, response, msg); } static async impersonateUser(req: Request, response: Response) { - if (req.body.admin && req.body.userId) { + if (typeof req.body.admin === 'string' && typeof req.body.userId === 'string') { AdminHandler.impersonationInfo.set(req.body.admin, req.body.userId); AdminHandler.adminDashboard(req, response, 'Impersonation registered'); + } else if (typeof req.body.admin === 'string') { + AdminHandler.impersonationInfo.delete(req.body.admin); + AdminHandler.adminDashboard(req, response, 'Impersonation cleared'); } else { - AdminHandler.impersonationInfo.clear(); - AdminHandler.adminDashboard(req, response, 'Impersonations cleared'); + AdminHandler.adminDashboard(req, response, 'Invalid admin or userId'); } } static async updateEvent(req: Request, response: Response) { - if (req.body.event) { + if (typeof req.body.event === 'string') { try { - await db.collection(Collections.SERVER_INFO).updateOne({}, { $set: { currentEvent: req.body.event } }); - AdminHandler.adminDashboard(req, response, 'Event set'); + db.collection(Collections.SERVER_INFO).update({}, { $set: { currentEvent: req.body.event } }, {}, (err) => { + if (err) { + Logger.error("Error in updateEvent: " + err.message); + AdminHandler.adminDashboard(req, response, 'Failed to set event'); + } else { + AdminHandler.adminDashboard(req, response, 'Event set'); + } + }); } catch (e) { - Logger.error("Error in updateEvent: " + e.message); + Logger.error("Error in updateEvent: " + (e instanceof Error ? e.message : e)); AdminHandler.adminDashboard(req, response, 'Failed to set event'); } } else { @@ -70,14 +87,24 @@ export class AdminHandler { private static async getEventsSelect() { try { - const currentEvent = (await db.collection(Collections.SERVER_INFO).findOne({})).currentEvent; + const serverInfo = await new Promise((resolve, reject) => { + db.collection(Collections.SERVER_INFO).findOne({}, (err, doc) => { + if (err) { + reject(err); + } else { + resolve(doc || { currentEvent: '' }); + } + }); + }); + + const currentEvent = serverInfo.currentEvent || ''; let html = ''; for (const event of Object.values(SeasonalEvents)) { html += `\n`; } return html; } catch (error) { - Logger.error("Failed to get events for select: " + error.message); + Logger.error("Failed to get events for select: " + (error instanceof Error ? error.message : error)); return ''; } } diff --git a/src/classes/matchmaking-handler.ts b/src/classes/matchmaking-handler.ts index e60592f..9dd0bd2 100644 --- a/src/classes/matchmaking-handler.ts +++ b/src/classes/matchmaking-handler.ts @@ -8,6 +8,7 @@ class MatchmakingSession { public id: number; public players: { id: string; role: Role; checkin?: boolean }[] = []; public lobbyCode?: string; + timestamp: any; constructor() { this.id = MatchmakingSession.nextId++; diff --git a/src/types/custom-lobby-types.ts b/src/types/custom-lobby-types.ts index 0e13989..c2779b4 100644 --- a/src/types/custom-lobby-types.ts +++ b/src/types/custom-lobby-types.ts @@ -1,26 +1,33 @@ import { VHSResponse } from "./vhs-the-game-types"; +// Enum for lobby actions +export enum LobbyAction { + CREATE = 'createLobby', + JOIN = 'joinLobby', + CLOSE = 'closeLobby', +} + // Request to create a new lobby export interface CreateLobbyRequest { - action?: 'createLobby'; // Action type - connectionString?: string; // Connection string for the lobby - sessionTicketId?: string; // Session ticket identifier - version?: number; // Version of the request - idpk?: string; // Unique identifier for the request + action: LobbyAction.CREATE; // Action type, should be required + connectionString: string; // Connection string for the lobby, should be required + sessionTicketId: string; // Session ticket identifier, should be required + version: number; // Version of the request, should be required + idpk: string; // Unique identifier for the request, should be required } // Request to join an existing lobby export interface JoinLobbyRequest { - action?: 'joinLobby'; // Action type - lobbyCode?: string; // Code of the lobby to join - sessionTicketId?: string; // Session ticket identifier - version?: number; // Version of the request - idpk?: string; // Unique identifier for the request + action: LobbyAction.JOIN; // Action type, should be required + lobbyCode: string; // Code of the lobby to join, should be required + sessionTicketId: string; // Session ticket identifier, should be required + version: number; // Version of the request, should be required + idpk: string; // Unique identifier for the request, should be required } // Request to close a lobby export interface CloseLobbyRequest { - action?: 'closeLobby'; // Action type + action: LobbyAction.CLOSE; // Action type, should be required lobbyData: { beaconPort: number; // Port number for the beacon closeReason: string; // Reason for closing the lobby @@ -32,31 +39,31 @@ export interface CloseLobbyRequest { matchSettings: { selectedMap: string; // Selected map for the match }; - sessionTicketId?: string; // Session ticket identifier - version?: number; // Version of the request - idpk?: string; // Unique identifier for the request + sessionTicketId: string; // Session ticket identifier, should be required + version: number; // Version of the request, should be required + idpk: string; // Unique identifier for the request, should be required } // Union type for custom lobby requests export type UseCustomLobbyRequest = CreateLobbyRequest | JoinLobbyRequest | CloseLobbyRequest; +// Response data for creating a new lobby +export interface CreateLobbyData { + lobbyCode: string; // Code of the created lobby, should be required +} + // Response for creating a new lobby export type CreateLobbyResponse = VHSResponse; -// Data returned when creating a lobby -export interface CreateLobbyData { - lobbyCode?: string; // Code of the created lobby +// Response data for joining an existing lobby +export interface JoinLobbyData { + lobbyFound: boolean; // Indicates if the lobby was found, should be required + connectionString?: string; // Connection string for the lobby, optional + discoverKey?: string; // Key for discovering the lobby, optional } // Response for joining an existing lobby export type JoinLobbyResponse = VHSResponse; -// Data returned when joining a lobby -export interface JoinLobbyData { - lobbyFound?: boolean; // Indicates if the lobby was found - connectionString?: string; // Connection string for the lobby - discoverKey?: string; // Key for discovering the lobby -} - // Union type for custom lobby responses export type UseCustomLobbyResponse = CreateLobbyResponse | JoinLobbyResponse | VHSResponse; diff --git a/src/websocket-server/websocket-server.ts b/src/websocket-server/websocket-server.ts index 82cf9e6..c7ce0a8 100644 --- a/src/websocket-server/websocket-server.ts +++ b/src/websocket-server/websocket-server.ts @@ -10,7 +10,9 @@ const wss = new WebSocketServer({ port: PORT }); Logger.log(`WebSocket server is listening on port ${PORT}`); // Handle new WebSocket connections -wss.on('connection', function connection(ws) { +wss.on('connection', (ws) => { + Logger.log('New client connected'); + // Handle errors ws.on('error', (error) => { Logger.log('WebSocket error', error.message); @@ -23,46 +25,82 @@ wss.on('connection', function connection(ws) { }); // Handle incoming messages - ws.on('message', function message(data) { + ws.on('message', (data) => { const msg = data.toString('utf-8'); - let parsedMsg: WSRequest = { action: '' }; - + let parsedMsg: WSRequest; + try { parsedMsg = JSON.parse(msg); } catch (e) { - Logger.log('Failed to parse message', msg); + Logger.log('Failed to parse message', (e as Error).message); + ws.send(JSON.stringify(createErrorResponse('Invalid JSON format'))); return; } - switch (parsedMsg.action) { - case 'OnLogin': - const typedMsg = parsedMsg as LoginRequest; - const userId = Handler.getId(typedMsg.sessionTicketId); - WSClientManager.addNewClient(ws, userId); - Logger.log('Client logged in', userId); - break; - - case 'OnHeartbeat': - const response: WSResponse = { - action: 'Heartbeat', - backendError: 0, - data: '', - extraMessage: '', - result: true - }; - ws.send(JSON.stringify(response)); - Logger.log('Heartbeat response sent'); - break; - - default: - Logger.log('Unknown request', msg); - break; - } + // Process message based on action + handleMessage(ws, parsedMsg); }); }); +/** + * Handles incoming WebSocket messages based on the action specified. + */ +function handleMessage(ws: WebSocket, parsedMsg: WSRequest) { + switch (parsedMsg.action) { + case 'OnLogin': { + if (!isLoginRequest(parsedMsg)) { + Logger.log('Invalid login request format', JSON.stringify(parsedMsg)); + ws.send(JSON.stringify(createErrorResponse('Invalid login request format'))); + return; + } + + const userId = Handler.getId(parsedMsg.sessionTicketId); + WSClientManager.addNewClient(ws, userId); + Logger.log('Client logged in', userId); + break; + } + case 'OnHeartbeat': { + const response: WSResponse = { + action: 'Heartbeat', + backendError: 0, + data: '', + extraMessage: '', + result: true + }; + ws.send(JSON.stringify(response)); + Logger.log('Heartbeat response sent'); + break; + } + default: { + Logger.log('Unknown request', JSON.stringify(parsedMsg)); + ws.send(JSON.stringify(createErrorResponse('Unknown action'))); + break; + } + } +} + +/** + * Checks if the message is a valid LoginRequest. + */ +function isLoginRequest(msg: WSRequest): msg is LoginRequest { + return msg.action === 'OnLogin' && 'sessionTicketId' in msg; +} + +/** + * Creates a generic error response. + */ +function createErrorResponse(extraMessage: string): WSResponse { + return { + action: 'Heartbeat', + backendError: 1, + data: '', + extraMessage, + result: false + }; +} + export class WSClientManager { - private static clients: { ws: WebSocket, userId: string }[] = []; + private static clients: { ws: WebSocket; userId: string }[] = []; // Add a new client public static addNewClient(ws: WebSocket, userId: string) { @@ -72,7 +110,7 @@ export class WSClientManager { // Remove a client public static removeClient(ws: WebSocket) { - const idx = WSClientManager.clients.findIndex(client => client.ws === ws); + const idx = WSClientManager.clients.findIndex((client) => client.ws === ws); if (idx !== -1) { const [removedClient] = WSClientManager.clients.splice(idx, 1); Logger.log('Client removed', removedClient.userId);