Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feature/matchmaking #59

Open
wants to merge 3 commits into
base: feature/matchmaking
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 72 additions & 34 deletions src/classes/admin-handler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { Request, Response } from "express";

import { Collections } from "./database";
import { DBConstants } from "./constants";
import { Logger } from "./logger";
Expand All @@ -10,51 +9,75 @@ import { readFile } from "fs/promises";

export class AdminHandler {
static impersonationInfo = new Map<string, string>();
static async adminDashboard(request: Request, response: Response<string>, 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<string>, 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) => {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason for changing this from the async/await syntax into the callback one?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The primary reason for switching to callback syntax is due to NeDB's native callback-based design, avoiding the extra overhead of wrapping those methods with promises unless necessary. With callbacks, the execution is immediate, and the function typically returns as soon as the operation is complete. (we are getting closer to VHS standarts)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Giving this is a REST API (i.e: most of the overhead is the HTTP+TCP handshake) I doubt a microtask queuing (that's all the overhead a promise gives) is really relevant but it is an improvement, so thanks for doing it and for your patience explaining it

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) => {
LuisMayo marked this conversation as resolved.
Show resolved Hide resolved
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');
}
}

Expand All @@ -63,11 +86,26 @@ export class AdminHandler {
}

private static async getEventsSelect() {
const currentEvent = (await db.collection<ServerInfo>(Collections.SERVER_INFO).findOneAsync({})).currentEvent;
let html = '';
for (const event of Object.values(SeasonalEvents)) {
html += `<option value="${event}" ${currentEvent === event ? 'selected' : ''}>${event}</option>\n`
try {
const serverInfo = await new Promise<ServerInfo>((resolve, reject) => {
db.collection<ServerInfo>(Collections.SERVER_INFO).findOne({}, (err, doc) => {
LuisMayo marked this conversation as resolved.
Show resolved Hide resolved
if (err) {
reject(err);
} else {
resolve(doc || { currentEvent: '' });
}
});
});

const currentEvent = serverInfo.currentEvent || '';
let html = '';
for (const event of Object.values(SeasonalEvents)) {
html += `<option value="${event}" ${currentEvent === event ? 'selected' : ''}>${event}</option>\n`;
}
return html;
} catch (error) {
Logger.error("Failed to get events for select: " + (error instanceof Error ? error.message : error));
return '<option value="">Error loading events</option>';
}
return html;
}
}
11 changes: 11 additions & 0 deletions src/classes/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 53 additions & 7 deletions src/classes/database.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -8,21 +7,26 @@ 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> = T & { _id?: string };

export class Database {
db!: Record<Collections, Datastore>;
token!: string;

constructor() { }

async init() {
Logger.log("Initialiting NeDB database connection");
Logger.log("Initializing NeDB database connection");
let db: Partial<typeof this.db> = {};
try {
const promises: Promise<unknown>[] = [];
Expand All @@ -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();
Expand All @@ -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() {
Expand Down Expand Up @@ -98,8 +103,34 @@ export class Database {
await this.checkVersionAndMigrations(settings.version);
}

private async initMatchmakingQueue() {
const collection = this.collection<MatchmakingQueue>(Collections.MATCHMAKING_QUEUE);
// Initialize the matchmaking queue if necessary
const queue = await collection.findOneAsync({});
if (!queue) {
await collection.insertAsync({ players: [] }); // Empty initial queue
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understand the design here, do we only have a queue?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial testing for me, we can do more here like:

  queueId: string; // Unique ID for the queue (game mode, region, etc.)
  gameMode: string; // The game mode for the queue, e.g., 'ranked', 'casual'
  region: string; // Region, e.g., 'NA', 'EU'
  combineRegions: regions; // Combine NA and EU for more players
  players: string[]; // Players waiting in the queue

This part can be deleted, because I was testing...

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, knowing it's a test, it's good to merge. About your suggestion, we're not gonna use regions yet, also, a single region may have several queues (for instance, several monsters queue). I was hoping to have Matchmaking Sessions in the Array instead of players themselves

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking more about this, I just don't think I want the matchmaking data on the database. It doesn't make sense because if a server restarts we're losing the websocket connections anyway, so all matchmaking info is irrelevant.

This is not blocking for merging the pull request because it can be deleted anyway

}
}

private async initGameSessions() {
const collection = this.collection<GameSession>(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<MatchmakingConfig>(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();
Expand Down Expand Up @@ -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'
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not required for merging, but this could be an ENUM

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're correct, I will make it as enum's

}

interface MatchmakingConfig {
settings: Record<string, any>; // Store configuration settings for matchmaking
}
65 changes: 59 additions & 6 deletions src/classes/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import {
SlotChangesResponse,
UploadPlayerSettingsRequest,
UploadPlayerSettingsResponse,
MatchmakingQueueRequest,
MatchmakingQueueResponse,
CreateGameSessionRequest,
CreateGameSessionResponse,
MatchmakingConfigRequest,
MatchmakingConfigResponse
} from "../types/vhs-the-game-types";
import {
Monsters,
Expand All @@ -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;

Expand Down Expand Up @@ -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);
Expand All @@ -133,6 +138,57 @@ export class Handler {
}
}

// New endpoint for matchmaking queue
static async matchmake(
request: Request<any, MatchmakingQueueResponse | string, MatchmakingQueueRequest>,
response: Response<MatchmakingQueueResponse | string>
) {
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<any, CreateGameSessionResponse | string, CreateGameSessionRequest>,
response: Response<CreateGameSessionResponse | string>
) {
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<any, MatchmakingConfigResponse | string, MatchmakingConfigRequest>,
response: Response<MatchmakingConfigResponse | string>
) {
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,
Expand Down Expand Up @@ -184,7 +240,6 @@ export class Handler {
return response.send({
log: { logSuccessful: true },
data: {
// TODO return the truth instead of this
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to remove this comment?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean I can't remember removing this ..

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok so if you agree I'll add it back

changedSlotNames: Object.keys(request.body.slotChanges).map((item) => {
return { [item]: true };
}),
Expand All @@ -201,9 +256,8 @@ export class Handler {
>,
response: Response<SetWeaponLoadoutsForCharacterResponse | string>
) {
const id = Handler.checkOwnTokenAndGetId(request,);
const id = Handler.checkOwnTokenAndGetId(request);
const saveData = await Handler.getUserSaveGame(id);
///@ts-ignore incomplete typings
const loadout:
| {
[x: string]: {
Expand Down Expand Up @@ -438,7 +492,6 @@ export class Handler {
return {data: {DDT_SeasonalEventBit: {activeSeasonalEventTypes: [event]}}}
}


private static generateToken(id: string) {
return jwt.sign(id, db.token);
}
Expand Down
Loading