diff --git a/src/classes/admin-handler.ts b/src/classes/admin-handler.ts index a69b7fb..b8d767f 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,75 @@ 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); + + 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) + .replace("${log}", Logger.getLogListHtml()) + .replace("${events}", await AdminHandler.getEventsSelect()); + response.send(html); + } catch (error) { + 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) { + let msg = ''; + if (typeof req.body.userId === 'string') { 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) { - msg = 'Error while removing user ' + e; + // 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 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'; + msg = 'No user specified'; + AdminHandler.adminDashboard(req, response, msg); } - // 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'); + 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) { - await db.collection(Collections.SERVER_INFO).updateAsync({}, {$set: {currentEvent: req.body.event}}); - AdminHandler.adminDashboard(req, response, 'Event set'); + if (typeof req.body.event === 'string') { + try { + 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 instanceof Error ? e.message : e)); + AdminHandler.adminDashboard(req, response, 'Failed to set event'); + } } else { - AdminHandler.adminDashboard(req, response, 'Event empty'); + AdminHandler.adminDashboard(req, response, 'Event empty'); } } @@ -63,11 +86,26 @@ 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 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 instanceof Error ? error.message : error)); + 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..9dd0bd2 100644 --- a/src/classes/matchmaking-handler.ts +++ b/src/classes/matchmaking-handler.ts @@ -6,52 +6,54 @@ enum Role { class MatchmakingSession { private static nextId = 0; public id: number; - + public players: { id: string; role: Role; checkin?: boolean }[] = []; + public lobbyCode?: string; + timestamp: any; 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 +65,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 +100,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/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(); diff --git a/src/types/custom-lobby-types.ts b/src/types/custom-lobby-types.ts index 36de00a..c2779b4 100644 --- a/src/types/custom-lobby-types.ts +++ b/src/types/custom-lobby-types.ts @@ -1,52 +1,69 @@ 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'; - connectionString?: string; - sessionTicketId?: string; - version?: number; - idpk?: string; + 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'; - lobbyCode?: string; - sessionTicketId?: string; - version?: number; - idpk?: string; + 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: LobbyAction.CLOSE; // Action type, should be required 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, 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; -export type CreateLobbyResponse = VHSResponse; - +// Response data for creating a new lobby export interface CreateLobbyData { - lobbyCode?: string; + lobbyCode: string; // Code of the created lobby, should be required } -export type JoinLobbyResponse = VHSResponse; +// Response for creating a new lobby +export type CreateLobbyResponse = VHSResponse; +// Response data for joining an existing lobby export interface JoinLobbyData { - lobbyFound?: boolean; - connectionString?: string; - discoverKey?: string; + 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; + +// 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..c7ce0a8 100644 --- a/src/websocket-server/websocket-server.ts +++ b/src/websocket-server/websocket-server.ts @@ -3,51 +3,117 @@ 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 }); -wss.on('connection', function connection(ws) { - ws.on('error', console.error); - ws.on("close", () => WSClientManager.removeClient(ws)); +// Log when the server starts listening +Logger.log(`WebSocket server is listening on port ${PORT}`); - ws.on('message', function message(data) { +// Handle new WebSocket connections +wss.on('connection', (ws) => { + Logger.log('New client connected'); + + // 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', (data) => { const msg = data.toString('utf-8'); - let parsedMsg: WSRequest = {action: ''}; - try{ + let parsedMsg: WSRequest; + + try { parsedMsg = JSON.parse(msg); } catch (e) { - console.error(e); + 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; - WSClientManager.addNewClient(ws, Handler.getId(typedMsg.sessionTicketId)); - break; - case 'OnHeartbeat': - const obj: WSResponse = { - action: 'Heartbeat', - backendError: 0, - data: '', - extraMessage: '', - result: true - }; - ws.send(JSON.stringify(obj)); - default: - Logger.log('Unknown request', msg); - } + // 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 }[] = []; - 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 + } +}