diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..4aaa657 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,5 @@ +{ + "printWidth": 120, + "singleQuote": false, + "trailingComma": "none" +} diff --git a/AGENTS.md b/AGENTS.md index 6dcc511..389241f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,11 +1,13 @@ # Repository Guidelines ## Project Structure & Module Organization + - `src/`: TypeScript source. Key areas: `routes/` (HTTP endpoints; auto-mounted), `modules/` (config, DB, matchmaking), `middlewares/` (logging, parsing, errors), `schemas/` (zod), `types/` (shared types). Entry: `src/index.ts`. - `tests/`: Manual scripts for local testing (`tests/manual/*.ts`). - `Dockerfile`: Container entrypoint using Bun. ## Build, Test, and Development Commands + - `bun run dev`: Start in watch mode (`bun --watch src/index.ts`). - `bun run start`: Run the server once (uses Bun). - `bun run build`: Type-check via `tsc` (no emit). @@ -13,27 +15,32 @@ - Manual test scripts: `bun tests/manual/join-queue.ts`, `bun tests/manual/leave-queue.ts`. ## Coding Style & Naming Conventions + - Language: TypeScript (ES modules, target `es2020`). - Indentation: 2 spaces; keep formatting consistent with existing files. - Filenames: routes and middlewares use kebab-case (e.g., `join-queue.ts`); types/interfaces PascalCase within code; variables camelCase. - Validation: zod schemas in `src/schemas`; reuse shared types from `src/types`. ## Testing Guidelines + - Framework: none configured; use manual scripts under `tests/manual/` to exercise endpoints. - Add new manual tests near related features, named after the route (e.g., `tests/manual/.ts`). - Targeted checks: verify `/v1/healthcheck`, queue join/leave flows, and expected JSON shapes. ## Commit & Pull Request Guidelines + - Commit style: prefer Conventional Commits (`feat:`, `fix:`, `chore:`) as used in history. - PRs: include clear description, linked issues, and sample requests/responses (curl or script output). Note any config or migration steps. - Keep changes small and focused; update README/this guide when structure or commands change. ## Security & Configuration Tips + - Copy `.env.example` to `.env`. Key vars: `AuthKey` (API auth), `MongoUrl` (default `mongodb://localhost:27017`), `Port` (default `3000`), `Environment`, `Instances`, `MATCHMAKING_ENABLED`. - Start MongoDB locally and ensure indexes are created at startup. - Use the `AuthKey` in requests where required. ## Architecture Overview + - Server: Hono app with route auto-loading from `src/routes/**`. - Data: MongoDB; collections configured in `modules/config.ts`. - Matchmaking: logic under `modules/matchmaking/**`; enable via `MATCHMAKING_ENABLED=true`. diff --git a/README.md b/README.md index 150706e..0fb366a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ GameMatcher/ ## Documentation See the docs folder for usage guides: + - docs/quickstart.md — local run and development - docs/api.md — endpoint reference and examples - docs/roblox.md — integrate from Roblox (Luau) diff --git a/bun.lockb b/bun.lockb index 8f20a97..733718a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/README.md b/docs/README.md index 206c05f..4d2d6d9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -3,4 +3,5 @@ - [Quickstart](./quickstart.md) - [API Reference](./api.md) - [Configuration](./configuration.md) +- [Matchmaking Architecture](./matchmaking.md) - [Handling Multiple Nodes](./nodes.md) diff --git a/docs/configuration.md b/docs/configuration.md index 3c8d8f3..08cbf55 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,8 +66,8 @@ export const queues: QueueConfigs = [ teamsPerMatch: 2, discoverMatchesInterval: 5, searchRange: [0, 0], - incrementRange: [1, 1], - }, + incrementRange: [1, 1] + } ] as const; ``` diff --git a/docs/matchmaking.md b/docs/matchmaking.md new file mode 100644 index 0000000..1880187 --- /dev/null +++ b/docs/matchmaking.md @@ -0,0 +1,44 @@ +# Matchmaking Architecture + +This project uses a modular architecture to organize matchmaking logic and make it easy to extend, test, and maintain. + +## Algorithms + +Supported queue algorithms: + +- normal: Fixed team size, no ranking. Packs parties greedily to fill teams. +- dynamic: Team size adapts between min/max based on oldest party wait time. +- ranked: Uses party `rankedValue` with a search range that expands over time. + +Algorithms live in `src/modules/matchmaking/algorithms/*` and implement a common interface. + +## Interface and Registry + +- Algorithm interface: `Algorithm = (cfg: T, services: Services) => Promise` +- Services provide DB access and utilities, keeping algorithm code pure and testable: + - `getOldestParties(queueId, limit)` + - `updatePartyRange(partyId, rankedMin, rankedMax)` + - `createMatch(queueConfig, teams, partiesUsed)` + - `now()` and `log()` +- A registry maps `queueType` to the corresponding algorithm. + +## Sharding (Regions) + +Requests can include `shardId` to restrict matching to a region. The server processes queues per-shard. Each queue has `shardTimeoutSeconds` to control expansion: + +- `-1`: disables sharding (global matching only) +- `>= 0`: match within shard until the oldest party waits this many seconds; then an expanded global pass can combine parties from all shards + +Indexes support both per-shard and global scans for efficient matching. + +## Configuration Summary + +See docs/configuration.md for complete details. Key fields: + +- All queues: `queueId`, `queueType`, `usersPerTeam`, `teamsPerMatch`, `discoverMatchesInterval`, `shardTimeoutSeconds` +- Ranked: `searchRange`, `incrementRange`, `incrementRangeMax?` +- Dynamic: `minUsersPerTeam`, `maxUsersPerTeam`, `timeElaspedToUseMinimumUsers` + +## API Summary + +See docs/api.md for request/response formats. To target a region, set `shardId` in `POST /v1/join-queue`. diff --git a/package.json b/package.json index 49a9ed5..cded3a6 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,12 @@ "scripts": { "start": "bun src/index.ts", "build": "tsc", - "dev": "bun --watch src/index.ts" + "dev": "bun --watch src/index.ts", + "format": "prettier --write ." }, "devDependencies": { - "@types/bun": "latest" + "@types/bun": "latest", + "prettier": "^3.6.2" }, "peerDependencies": { "typescript": "^5.6.3" @@ -20,4 +22,4 @@ "zlib": "^1.0.5", "zod": "^3.23.8" } -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 89a0990..7cebe82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,6 @@ -import cluster from 'node:cluster'; -import { availableParallelism } from 'node:os'; -import process from 'node:process'; +import cluster from "node:cluster"; +import { availableParallelism } from "node:os"; +import process from "node:process"; const numCPUs = availableParallelism(); @@ -8,9 +8,9 @@ if (cluster.isPrimary) { console.log(`Primary ${process.pid} is running`); // Spawn n-1 processes if not specified. - let instances = (numCPUs - 1) + let instances = numCPUs - 1; if (process.env.Instances) { - instances = parseInt(process.env.Instances) + instances = parseInt(process.env.Instances); } if (process.versions.bun) { @@ -23,42 +23,42 @@ if (cluster.isPrimary) { cluster.fork(); } - cluster.on('exit', (worker, code, signal) => { + cluster.on("exit", (worker, code, signal) => { console.log(`Worker ${worker.process.pid} died`); - process.exit() + process.exit(); }); } else { console.log(`Worker ${process.pid} started`); } -import { Hono } from 'hono' -import { trimTrailingSlash } from 'hono/trailing-slash' -import { parseGzippedJson } from 'middlewares/parse-gzip-json.js'; -import { logging } from 'middlewares/logging.js'; -import { createIndexes } from 'modules/database-indexes.js'; -import { port } from 'modules/config.js'; -import { errorHandler } from 'middlewares/error-handler.js'; +import { Hono } from "hono"; +import { trimTrailingSlash } from "hono/trailing-slash"; +import { parseGzippedJson } from "middlewares/parse-gzip-json.js"; +import { logging } from "middlewares/logging.js"; +import { createIndexes } from "modules/database-indexes.js"; +import { port } from "modules/config.js"; +import { errorHandler } from "middlewares/error-handler.js"; import fs from "fs"; import path from "path"; -import { client } from 'modules/database.js'; -import { initMatchmaking } from 'modules/matchmaking/matchmaking.js'; +import { client } from "modules/database.js"; +import { initMatchmaking } from "modules/matchmaking/matchmaking.js"; // Initialize Hono app -const app = new Hono() -app.use(trimTrailingSlash()) -app.use(parseGzippedJson) -app.use(logging) +const app = new Hono(); +app.use(trimTrailingSlash()); +app.use(parseGzippedJson); +app.use(logging); // Create Indexes createIndexes(); // Error handling middleware -app.onError(errorHandler) +app.onError(errorHandler); // Start matchmaking if needed -if (process.env.MATCHMAKING_ENABLED === 'true') { - initMatchmaking() +if (process.env.MATCHMAKING_ENABLED === "true") { + initMatchmaking(); } // Function to recursively import routes @@ -77,55 +77,55 @@ function importRoutes(folderPath, baseRoute = "") { } else if (file.endsWith(".js") || file.endsWith(".ts")) { // Import and mount JavaScript files as routes const requiredRoutes = await import(filePath); - const { routes } = requiredRoutes + const { routes } = requiredRoutes; const routeName = file === "index.js" ? "" : file === "index.ts" ? "" : path.parse(file).name; const fullRoute = path.join(baseRoute, routeName); app.route(fullRoute, routes); } - } + }; files.forEach((file) => { - importPromises.push(importFile(file)) + importPromises.push(importFile(file)); }); - return Promise.all(importPromises) + return Promise.all(importPromises); } // Import all routes from the routes folder -let fileDirectory = __dirname +let fileDirectory = __dirname; if (process.versions.bun) { - fileDirectory = import.meta.dir + fileDirectory = import.meta.dir; } const apiFolder = path.join(fileDirectory, "routes"); await importRoutes(apiFolder, ""); -app.get('/', (c) => c.text('Are you lost?', 404)) -app.get('/*', (c) => c.text('Are you lost?', 404)) +app.get("/", (c) => c.text("Are you lost?", 404)); +app.get("/*", (c) => c.text("Are you lost?", 404)); // Start the server -console.log(`Matchmaking Backend (Process ${process.pid}) listening on port ${port}`) +console.log(`Matchmaking Backend (Process ${process.pid}) listening on port ${port}`); // Graceful shutdown -process.on('SIGINT', async () => { - console.log('Shutting down gracefully...') - await client.close() - console.log('MongoDB connection closed') - process.exit(0) -}) +process.on("SIGINT", async () => { + console.log("Shutting down gracefully..."); + await client.close(); + console.log("MongoDB connection closed"); + process.exit(0); +}); let serveOptions: any = { port, - fetch: app.fetch, -} + fetch: app.fetch +}; if (process.versions.bun) { - serveOptions.reusePort = true - Bun.serve(serveOptions) + serveOptions.reusePort = true; + Bun.serve(serveOptions); } else { if (!cluster.isWorker) { import("@hono/node-server").then(({ serve: serveNodeJS }) => { serveNodeJS(serveOptions); }); } -} \ No newline at end of file +} diff --git a/src/middlewares/body-parser.ts b/src/middlewares/body-parser.ts index 5cf860f..e4a2c51 100644 --- a/src/middlewares/body-parser.ts +++ b/src/middlewares/body-parser.ts @@ -6,34 +6,34 @@ export type ContextWithParsedBody = Cont }; type JSONBodyParserOptions = { - schema?: T; -} + schema?: T; +}; export function parseJSONBody({ schema }: JSONBodyParserOptions): MiddlewareHandler { - return async (c: Context, next: Next) => { - var failedToParse = false - const jsonBody = await c.req.json().catch(() => { - failedToParse = true; - }); - - if (failedToParse) { - return c.json({ error: "FailedToParseBody" }, 400); - } - - const ctx = c as ContextWithParsedBody; - - if (schema) { - const parseResult = schema.safeParse(jsonBody); - if (!parseResult.success) { - return c.json({ error: parseResult.error.errors }, 400); - } - - ctx.bodyData = parseResult.data; - } else { - ctx.bodyData = jsonBody; - } - - await next(); - return; - }; -} \ No newline at end of file + return async (c: Context, next: Next) => { + var failedToParse = false; + const jsonBody = await c.req.json().catch(() => { + failedToParse = true; + }); + + if (failedToParse) { + return c.json({ error: "FailedToParseBody" }, 400); + } + + const ctx = c as ContextWithParsedBody; + + if (schema) { + const parseResult = schema.safeParse(jsonBody); + if (!parseResult.success) { + return c.json({ error: parseResult.error.errors }, 400); + } + + ctx.bodyData = parseResult.data; + } else { + ctx.bodyData = jsonBody; + } + + await next(); + return; + }; +} diff --git a/src/middlewares/error-handler.ts b/src/middlewares/error-handler.ts index 0c6c803..09487be 100644 --- a/src/middlewares/error-handler.ts +++ b/src/middlewares/error-handler.ts @@ -1,6 +1,6 @@ import { Context } from "hono"; export const errorHandler = (err: Error, c: Context) => { - console.error(err) - return c.json({ error: 'Internal Server Error' }, 500) - } \ No newline at end of file + console.error(err); + return c.json({ error: "Internal Server Error" }, 500); +}; diff --git a/src/middlewares/logging.ts b/src/middlewares/logging.ts index 121beb5..7fdfcbf 100644 --- a/src/middlewares/logging.ts +++ b/src/middlewares/logging.ts @@ -1,29 +1,29 @@ import { Context, Next } from "hono"; function truncateDecimals(number: number, digits: number): number { - const multiplier = Math.pow(10, digits) - const adjustedNum = number * multiplier - const truncatedNum = Math[adjustedNum < 0 ? 'ceil' : 'floor'](adjustedNum) - return truncatedNum / multiplier + const multiplier = Math.pow(10, digits); + const adjustedNum = number * multiplier; + const truncatedNum = Math[adjustedNum < 0 ? "ceil" : "floor"](adjustedNum); + return truncatedNum / multiplier; } export const logging = async (c: Context, next: Next) => { - const start = Date.now(); + const start = Date.now(); - await next(); - - const end = Date.now(); - const elapsedTime = end - start; + await next(); - const methodLog = `[${c.req.method}]`.padEnd(7); + const end = Date.now(); + const elapsedTime = end - start; - // Extract path and query parameters - const url = new URL(c.req.url); - const pathWithParams = url.pathname + url.search; - const uriLog = `${pathWithParams}`.padEnd(100); + const methodLog = `[${c.req.method}]`.padEnd(7); - const statusLog = `[${c.res.status}]`.padEnd(6); - const elapsedMillisecondsLog = `(${truncateDecimals(elapsedTime, 4)} ms)`.padStart(25); + // Extract path and query parameters + const url = new URL(c.req.url); + const pathWithParams = url.pathname + url.search; + const uriLog = `${pathWithParams}`.padEnd(100); - console.log(`${methodLog}${uriLog}${statusLog}${elapsedMillisecondsLog}`); -}; \ No newline at end of file + const statusLog = `[${c.res.status}]`.padEnd(6); + const elapsedMillisecondsLog = `(${truncateDecimals(elapsedTime, 4)} ms)`.padStart(25); + + console.log(`${methodLog}${uriLog}${statusLog}${elapsedMillisecondsLog}`); +}; diff --git a/src/middlewares/parse-gzip-json.ts b/src/middlewares/parse-gzip-json.ts index 48e7012..ef83a7e 100644 --- a/src/middlewares/parse-gzip-json.ts +++ b/src/middlewares/parse-gzip-json.ts @@ -1,22 +1,19 @@ -import { gunzip } from 'zlib' -import { promisify } from 'util' +import { gunzip } from "zlib"; +import { promisify } from "util"; import { Context, Next } from "hono"; -const gunzipAsync = promisify(gunzip) +const gunzipAsync = promisify(gunzip); export async function parseGzippedJson(c: Context, next: Next) { - if ( - c.req.header('content-type') === 'application/json' && - c.req.header('content-encoding') === 'gzip' - ) { - const buffer = await c.req.arrayBuffer() - try { - const decompressed = await gunzipAsync(new Uint8Array(buffer)) - c.req.json = () => Promise.resolve(JSON.parse(decompressed.toString('utf-8'))) - } catch (error) { - c.status(400) - return c.json({ error: 'Invalid gzipped JSON' }) - } + if (c.req.header("content-type") === "application/json" && c.req.header("content-encoding") === "gzip") { + const buffer = await c.req.arrayBuffer(); + try { + const decompressed = await gunzipAsync(new Uint8Array(buffer)); + c.req.json = () => Promise.resolve(JSON.parse(decompressed.toString("utf-8"))); + } catch (error) { + c.status(400); + return c.json({ error: "Invalid gzipped JSON" }); } - await next() + } + await next(); } diff --git a/src/modules/authorization.ts b/src/modules/authorization.ts index 3125717..284b4c4 100644 --- a/src/modules/authorization.ts +++ b/src/modules/authorization.ts @@ -1,19 +1,19 @@ -import type { Context, Next } from "hono" -import { authKey, environment } from "./config.js" +import type { Context, Next } from "hono"; +import { authKey, environment } from "./config.js"; export function isAuthorized(authHeader: string | undefined): boolean { - if (environment !== 'Testing') { - return authHeader === authKey - } - return true + if (environment !== "Testing") { + return authHeader === authKey; + } + return true; } // middleware to check if the user is authorized before allowing access to a route export async function authMiddleware(c: Context, next: Next) { - if (!isAuthorized(c.req.header('authorization'))) { - return c.json({ error: 'Unauthorized' }, 401) - } + if (!isAuthorized(c.req.header("authorization"))) { + return c.json({ error: "Unauthorized" }, 401); + } - await next(); - return; -} \ No newline at end of file + await next(); + return; +} diff --git a/src/modules/config.ts b/src/modules/config.ts index 277018a..4796045 100644 --- a/src/modules/config.ts +++ b/src/modules/config.ts @@ -1,40 +1,38 @@ -import { QueueConfigs } from "types/queues.js" +import { QueueConfigs } from "types/queues.js"; // Load Environment Variables -export const authKey = process.env.AuthKey -export const mongoUrl = process.env.MongoUrl || 'mongodb://localhost:27017' -export const port = parseInt(process.env.Port || '3000') -export const environment = process.env.Environment || 'Testing' +export const authKey = process.env.AuthKey; +export const mongoUrl = process.env.MongoUrl || "mongodb://localhost:27017"; +export const port = parseInt(process.env.Port || "3000"); +export const environment = process.env.Environment || "Testing"; // Database -export const dbName = 'Matchmaking' +export const dbName = "Matchmaking"; -export const queuesCollectionName = 'queues' -export const serverIdsCollectionName = 'servers' +export const queuesCollectionName = "queues"; +export const serverIdsCollectionName = "servers"; -export const foundMatchesCollectionName = 'foundMatches' -export const foundPartiesCollectionName = 'foundParties' +export const foundMatchesCollectionName = "foundMatches"; +export const foundPartiesCollectionName = "foundParties"; // Queues export const queues: QueueConfigs = [ - { - queueId: "Ranked2v2", - queueType: "ranked", - - usersPerTeam: 2, - teamsPerMatch: 2, - - discoverMatchesInterval: 5, - - searchRange: [0, 0], - incrementRange: [1, 1], - } + { + queueId: "Ranked2v2", + queueType: "ranked", + + usersPerTeam: 2, + teamsPerMatch: 2, + + discoverMatchesInterval: 5, + + searchRange: [0, 0], + incrementRange: [1, 1] + } ] as const; -export const validQueueIds = [ - "Ranked2v2" -] as const +export const validQueueIds = ["Ranked2v2"] as const; // Database Configurations -export const QUEUE_EXPIRE_AFTER = (60 * 60 * 2) // 2 hours in seconds -export const SERVER_EXPIRE_AFTER = (60 * 60 * 2) // 2 hours in seconds -export const MATCHES_EXPIRE_AFTER = (10 * 60) // 10 minutes in seconds \ No newline at end of file +export const QUEUE_EXPIRE_AFTER = 60 * 60 * 2; // 2 hours in seconds +export const SERVER_EXPIRE_AFTER = 60 * 60 * 2; // 2 hours in seconds +export const MATCHES_EXPIRE_AFTER = 10 * 60; // 10 minutes in seconds diff --git a/src/modules/database-indexes.ts b/src/modules/database-indexes.ts index 2349937..b9b3dd5 100644 --- a/src/modules/database-indexes.ts +++ b/src/modules/database-indexes.ts @@ -11,17 +11,25 @@ export function createIndexes() { queuesCollection.createIndex({ queueId: -1 }, { name: "queueId" }).catch(emptyHandler); // Time Added Index - queuesCollection.createIndex({ timeAdded: -1 }, { name: "timeAdded", expireAfterSeconds: QUEUE_EXPIRE_AFTER }).catch(emptyHandler); + queuesCollection + .createIndex({ timeAdded: -1 }, { name: "timeAdded", expireAfterSeconds: QUEUE_EXPIRE_AFTER }) + .catch(emptyHandler); // SERVER IDS COLLECTION // // Created At Index - serverIdsCollection.createIndex({ createdAt: -1 }, { name: "createdAt", expireAfterSeconds: SERVER_EXPIRE_AFTER }).catch(emptyHandler); + serverIdsCollection + .createIndex({ createdAt: -1 }, { name: "createdAt", expireAfterSeconds: SERVER_EXPIRE_AFTER }) + .catch(emptyHandler); // FOUND MATCHES COLLECTION // // Creatd At Index - foundMatchesCollection.createIndex({ createdAt: -1 }, { name: "createdAt", expireAfterSeconds: MATCHES_EXPIRE_AFTER }).catch(emptyHandler); + foundMatchesCollection + .createIndex({ createdAt: -1 }, { name: "createdAt", expireAfterSeconds: MATCHES_EXPIRE_AFTER }) + .catch(emptyHandler); // FOUND PARTIES COLLECTION // // Creatd At Index - foundPartiesCollection.createIndex({ createdAt: -1 }, { name: "createdAt", expireAfterSeconds: MATCHES_EXPIRE_AFTER }).catch(emptyHandler); -} \ No newline at end of file + foundPartiesCollection + .createIndex({ createdAt: -1 }, { name: "createdAt", expireAfterSeconds: MATCHES_EXPIRE_AFTER }) + .catch(emptyHandler); +} diff --git a/src/modules/database.ts b/src/modules/database.ts index b795db2..266cf6e 100644 --- a/src/modules/database.ts +++ b/src/modules/database.ts @@ -1,20 +1,27 @@ -import { Collection, Db, MongoClient } from 'mongodb' -import { dbName, foundMatchesCollectionName, foundPartiesCollectionName, queuesCollectionName, serverIdsCollectionName, mongoUrl } from './config.js' -import { QueueDocument } from 'types/queueDocument.js' -import { ServerDocument } from 'types/serverDocument.js' -import { FoundMatchDocument } from 'types/foundMatchDocument.js' -import { FoundPartyDocument } from 'types/foundPartyDocument.js' +import { Collection, Db, MongoClient } from "mongodb"; +import { + dbName, + foundMatchesCollectionName, + foundPartiesCollectionName, + queuesCollectionName, + serverIdsCollectionName, + mongoUrl +} from "./config.js"; +import { QueueDocument } from "types/queueDocument.js"; +import { ServerDocument } from "types/serverDocument.js"; +import { FoundMatchDocument } from "types/foundMatchDocument.js"; +import { FoundPartyDocument } from "types/foundPartyDocument.js"; // Connect to MongoDB -export const client = new MongoClient(mongoUrl) -await client.connect() +export const client = new MongoClient(mongoUrl); +await client.connect(); -export const database: Db = client.db(dbName) +export const database: Db = client.db(dbName); -export const queuesCollection: Collection = database.collection(queuesCollectionName) -export const serverIdsCollection: Collection = database.collection(serverIdsCollectionName) +export const queuesCollection: Collection = database.collection(queuesCollectionName); +export const serverIdsCollection: Collection = database.collection(serverIdsCollectionName); -export const foundMatchesCollection: Collection = database.collection(foundMatchesCollectionName) -export const foundPartiesCollection: Collection = database.collection(foundPartiesCollectionName) +export const foundMatchesCollection: Collection = database.collection(foundMatchesCollectionName); +export const foundPartiesCollection: Collection = database.collection(foundPartiesCollectionName); -console.log('Connected to MongoDB') \ No newline at end of file +console.log("Connected to MongoDB"); diff --git a/src/modules/empty-handler.ts b/src/modules/empty-handler.ts index eccedfa..2cd91c8 100644 --- a/src/modules/empty-handler.ts +++ b/src/modules/empty-handler.ts @@ -1,3 +1,3 @@ // 'never' so it does not affect typechecking, returns empty array just in case // @ts-ignore -export const emptyHandler: () => never = (() => []) \ No newline at end of file +export const emptyHandler: () => never = () => []; diff --git a/src/modules/matchmaking/algorithms/dynamic.ts b/src/modules/matchmaking/algorithms/dynamic.ts new file mode 100644 index 0000000..befe647 --- /dev/null +++ b/src/modules/matchmaking/algorithms/dynamic.ts @@ -0,0 +1,44 @@ +import type { QueueConfig } from "types/queues.js"; +import type { Algorithm } from "./types.js"; +import { ageSeconds, packTeamsGreedy, sumUsers } from "./utils.js"; + +// Dynamic matchmaking: +// - Chooses an effective team size between min and max. +// - Before the elapsed threshold, uses maxUsersPerTeam to prefer fuller teams. +// - After the elapsed threshold (based on oldest party), uses minUsersPerTeam to relax requirements. +export const findDynamicMatch: Algorithm = async (queueData, services) => { + const { queueId, teamsPerMatch, minUsersPerTeam, maxUsersPerTeam, timeElaspedToUseMinimumUsers } = queueData; + + let allParties = await services.getOldestParties(queueId, 2500); + if (!allParties || allParties.length === 0) return; + + // Determine effective team size based on oldest party wait time + const now = services.now(); + const oldest = allParties[0]; + const elapsedSec = ageSeconds(oldest.timeAdded, now); + const effectiveUsersPerTeam = elapsedSec >= timeElaspedToUseMinimumUsers ? minUsersPerTeam : maxUsersPerTeam; + + // Track used parties so we can form multiple matches per invocation + const usedPartyIds = new Set(); + + let foundMatchInThisIteration = true; + while (foundMatchInThisIteration) { + foundMatchInThisIteration = false; + + const availableParties = allParties.filter((p) => !usedPartyIds.has(p._id)); + if (availableParties.length === 0) break; + + const totalPlayers = sumUsers(availableParties); + const requiredPlayers = effectiveUsersPerTeam * teamsPerMatch; + if (totalPlayers < requiredPlayers) break; + const packed = packTeamsGreedy(availableParties, teamsPerMatch, effectiveUsersPerTeam); + if (packed) { + await services.createMatch(queueData, packed.teams, packed.partiesUsed); + packed.partiesUsed.forEach((id) => usedPartyIds.add(id)); + allParties = allParties.filter((p) => !usedPartyIds.has(p._id)); + foundMatchInThisIteration = true; + } else { + break; + } + } +}; diff --git a/src/modules/matchmaking/algorithms/index.ts b/src/modules/matchmaking/algorithms/index.ts new file mode 100644 index 0000000..c2c29c1 --- /dev/null +++ b/src/modules/matchmaking/algorithms/index.ts @@ -0,0 +1,11 @@ +import type { QueueConfig } from "types/queues.js"; +import type { Algorithm } from "./types.js"; +import { findNormalMatch } from "./normal.js"; +import { findDynamicMatch } from "./dynamic.js"; +import { findRankedMatch } from "./ranked.js"; + +export const algorithmRegistry: Record> = { + normal: findNormalMatch as Algorithm, + dynamic: findDynamicMatch as Algorithm, + ranked: findRankedMatch as Algorithm +}; diff --git a/src/modules/matchmaking/algorithms/normal.ts b/src/modules/matchmaking/algorithms/normal.ts new file mode 100644 index 0000000..485fd84 --- /dev/null +++ b/src/modules/matchmaking/algorithms/normal.ts @@ -0,0 +1,40 @@ +import type { QueueConfig } from "types/queues.js"; +import type { Algorithm } from "./types.js"; +import { packTeamsGreedy, sumUsers } from "./utils.js"; + +// Normal matchmaking: ignore ranked ranges and just form matches +// by packing parties up to usersPerTeam across teamsPerMatch teams. +export const findNormalMatch: Algorithm = async (queueData, services) => { + const { queueId, usersPerTeam, teamsPerMatch } = queueData; + + let allParties = await services.getOldestParties(queueId, 2500); + if (!allParties || allParties.length === 0) return; + + // Track used parties across iterations so we can keep forming matches + const usedPartyIds = new Set(); + + let foundMatchInThisIteration = true; + while (foundMatchInThisIteration) { + foundMatchInThisIteration = false; + + // Filter out already-used parties + const availableParties = allParties.filter((p) => !usedPartyIds.has(p._id)); + if (availableParties.length === 0) break; + + // Check if we have enough players overall to attempt a match + const totalPlayers = sumUsers(availableParties); + const requiredPlayers = usersPerTeam * teamsPerMatch; + if (totalPlayers < requiredPlayers) break; + const packed = packTeamsGreedy(availableParties, teamsPerMatch, usersPerTeam); + if (packed) { + await services.createMatch(queueData, packed.teams, packed.partiesUsed); + packed.partiesUsed.forEach((id) => usedPartyIds.add(id)); + // Remove used parties from local list and try to form another match + allParties = allParties.filter((p) => !usedPartyIds.has(p._id)); + foundMatchInThisIteration = true; + } else { + // Could not form a complete match with current composition + break; + } + } +}; diff --git a/src/modules/matchmaking/algorithms/ranked.ts b/src/modules/matchmaking/algorithms/ranked.ts index 0ded258..663fbfa 100644 --- a/src/modules/matchmaking/algorithms/ranked.ts +++ b/src/modules/matchmaking/algorithms/ranked.ts @@ -1,169 +1,125 @@ -import { queuesCollection } from "modules/database.js"; -import { emptyHandler } from "modules/empty-handler.js"; -import { createMatch } from "../matchmaking.js"; import type { QueueConfig } from "types/queues.js"; import type { WithId } from "mongodb"; import type { QueueDocument } from "types/queueDocument.js"; +import type { Algorithm, Services } from "./types.js"; +import { packTeamsGreedy, sumUsers } from "./utils.js"; const DOUBLE_RANGE_REQUIRED = true; // Set this to false to disable double range requirement: both party's range has to be within each other -async function expandSearchRange(queueData: QueueConfig & { queueType: "ranked" }, originalParty: WithId) { - const { - incrementRange, - incrementRangeMax - } = queueData; +async function expandSearchRange( + queueData: QueueConfig & { queueType: "ranked" }, + originalParty: WithId, + services: Services +) { + const { incrementRange, incrementRangeMax } = queueData; - const rankedValue = originalParty.rankedValue; - let rankedMin = originalParty.rankedMin ?? rankedValue; - let rankedMax = originalParty.rankedMax ?? rankedValue; + const rankedValue = originalParty.rankedValue; + let rankedMin = originalParty.rankedMin ?? rankedValue; + let rankedMax = originalParty.rankedMax ?? rankedValue; - const originalRankedMin = rankedMin; - const originalRankedMax = rankedMax; + const originalRankedMin = rankedMin; + const originalRankedMax = rankedMax; - // Not enough players, expand ranked range - rankedMin -= incrementRange[0]; - rankedMax += incrementRange[1]; + // Not enough players, expand ranked range + rankedMin -= incrementRange[0]; + rankedMax += incrementRange[1]; - // Check for differences between min and max - const maxDifferences = (rankedMax - rankedValue); - const minDifferences = (rankedValue - rankedMin); + // Check for differences between min and max + const maxDifferences = rankedMax - rankedValue; + const minDifferences = rankedValue - rankedMin; - // Cap the range - if (incrementRangeMax) { - if (minDifferences > incrementRangeMax[0]) { - rankedMin = rankedValue - incrementRangeMax[0]; - } - if (maxDifferences > incrementRangeMax[1]) { - rankedMax = rankedValue + incrementRangeMax[1]; - } + // Cap the range + if (incrementRangeMax) { + if (minDifferences > incrementRangeMax[0]) { + rankedMin = rankedValue - incrementRangeMax[0]; } - - if (originalRankedMin === rankedMin && originalRankedMax === rankedMax) { - // nothing changed - return false; + if (maxDifferences > incrementRangeMax[1]) { + rankedMax = rankedValue + incrementRangeMax[1]; } + } + + if (originalRankedMin === rankedMin && originalRankedMax === rankedMax) { + // nothing changed + return false; + } + + // Persist in database via service + if (services.updatePartyRange) { + await services.updatePartyRange(originalParty._id, rankedMin, rankedMax); + } + + // Update the originalParty object in memory + originalParty.rankedMin = rankedMin; + originalParty.rankedMax = rankedMax; + return true; +} - // Update in database - queuesCollection.updateOne( - { _id: originalParty._id }, - { $set: { rankedMin, rankedMax } } - ).catch(emptyHandler); +export const findRankedMatch: Algorithm = async (queueData, services) => { + const { queueId, usersPerTeam, teamsPerMatch, searchRange } = queueData; - // Update the originalParty object in memory - originalParty.rankedMin = rankedMin; - originalParty.rankedMax = rankedMax; - return true; -} + let allParties = await services.getOldestParties(queueId, 2500); + + if (!allParties || allParties.length === 0) return; + + // Filter out parties with invalid rankedValue + allParties = allParties.filter((party) => party.rankedValue !== null && party.rankedValue !== undefined); + + // Initialize a set to keep track of used parties + const usedPartyIds = new Set(); + + // Sort parties by timeAdded to prioritize older parties + allParties.sort((a, b) => a.timeAdded.getTime() - b.timeAdded.getTime()); + + let foundMatchInThisIteration = true; + + while (foundMatchInThisIteration) { + foundMatchInThisIteration = false; + + // For each party that hasn't been used yet + for (const originalParty of allParties) { + if (usedPartyIds.has(originalParty._id)) continue; + + const rankedValue = originalParty.rankedValue; + let rankedMin = originalParty.rankedMin ?? rankedValue - searchRange[0]; + let rankedMax = originalParty.rankedMax ?? rankedValue + searchRange[1]; + + // Find parties within the ranked value range + const partiesInRange = allParties.filter((party) => { + if (usedPartyIds.has(party._id)) return false; + const partyRankedValue = party.rankedValue; + if (partyRankedValue < rankedMin || partyRankedValue > rankedMax) return false; + + if (DOUBLE_RANGE_REQUIRED) { + // Need to check if originalParty's rankedValue is within the other party's range + let partyRankedMin = party.rankedMin ?? partyRankedValue - searchRange[0]; + let partyRankedMax = party.rankedMax ?? partyRankedValue + searchRange[1]; + if (originalParty.rankedValue < partyRankedMin || originalParty.rankedValue > partyRankedMax) return false; + } -export async function findRankedMatch(queueData: QueueConfig & { queueType: "ranked" }) { - const { - queueId, - usersPerTeam, - teamsPerMatch, - searchRange, - incrementRange - } = queueData; - - let allParties = await queuesCollection - .find({ queueId }) - .sort({ timeAdded: 1 }) - .limit(2500) - .toArray() - .catch(emptyHandler); - - if (!allParties || allParties.length === 0) return; - - // Filter out parties with invalid rankedValue - allParties = allParties.filter(party => party.rankedValue !== null && party.rankedValue !== undefined); - - // Initialize a set to keep track of used parties - const usedPartyIds = new Set(); - - // Sort parties by timeAdded to prioritize older parties - allParties.sort((a, b) => a.timeAdded.getTime() - b.timeAdded.getTime()); - - let foundMatchInThisIteration = true; - - while (foundMatchInThisIteration) { - foundMatchInThisIteration = false; - - // For each party that hasn't been used yet - for (const originalParty of allParties) { - if (usedPartyIds.has(originalParty._id)) continue; - - const rankedValue = originalParty.rankedValue; - let rankedMin = originalParty.rankedMin ?? rankedValue - searchRange[0]; - let rankedMax = originalParty.rankedMax ?? rankedValue + searchRange[1]; - - // Find parties within the ranked value range - const partiesInRange = allParties.filter(party => { - if (usedPartyIds.has(party._id)) return false; - const partyRankedValue = party.rankedValue; - if (partyRankedValue < rankedMin || partyRankedValue > rankedMax) return false; - - if (DOUBLE_RANGE_REQUIRED) { - // Need to check if originalParty's rankedValue is within the other party's range - let partyRankedMin = party.rankedMin ?? partyRankedValue - searchRange[0]; - let partyRankedMax = party.rankedMax ?? partyRankedValue + searchRange[1]; - if (originalParty.rankedValue < partyRankedMin || originalParty.rankedValue > partyRankedMax) return false; - } - - return true; - }); - - // Calculate total number of players - const totalPlayers = partiesInRange.reduce((sum, party) => sum + party.userIds.length, 0); - - // If enough players to form a match - if (totalPlayers >= usersPerTeam * teamsPerMatch) { - // Sort parties by size (largest first) - partiesInRange.sort((a, b) => b.userIds.length - a.userIds.length); - - const teams: number[][] = Array.from({ length: teamsPerMatch }, () => []); - const partiesUsed: string[] = []; - - // Assign parties to teams - for (const party of partiesInRange) { - if (usedPartyIds.has(party._id)) continue; - - // Try to add party to any team where it fits - let partyAdded = false; - for (const team of teams) { - if (team.length + party.userIds.length <= usersPerTeam) { - team.push(...party.userIds); - usedPartyIds.add(party._id); - partiesUsed.push(party._id); - partyAdded = true; - break; // Break the team loop once the party is added - } - } - - if (!partyAdded) { - continue; // Couldn't add this party to any team - } - - // Check if all teams are filled - const allTeamsFilled = teams.every(team => team.length === usersPerTeam); - - if (allTeamsFilled) { - await createMatch(queueData, teams, partiesUsed); - foundMatchInThisIteration = true; - break; // Break out of the parties loop to start over - } - } - - if (foundMatchInThisIteration) { - // Remove used parties from allParties - allParties = allParties.filter(party => !usedPartyIds.has(party._id)); - break; // Break out of the for-loop to start over - } else { - // Not able to form teams with these parties, expand ranked range - await expandSearchRange(queueData, originalParty); - } - } else { - // Not enough players, expand ranked range - await expandSearchRange(queueData, originalParty); - } + return true; + }); + + // Calculate total number of players + const totalPlayers = sumUsers(partiesInRange); + + // If enough players to form a match + if (totalPlayers >= usersPerTeam * teamsPerMatch) { + const packed = packTeamsGreedy(partiesInRange, teamsPerMatch, usersPerTeam); + if (packed) { + await services.createMatch(queueData, packed.teams, packed.partiesUsed); + packed.partiesUsed.forEach((id) => usedPartyIds.add(id)); + // Remove used parties from allParties + allParties = allParties.filter((party) => !usedPartyIds.has(party._id)); + foundMatchInThisIteration = true; + break; // Break out to start over + } else { + // Not able to form teams with these parties, expand ranked range + await expandSearchRange(queueData, originalParty, services); } + } else { + // Not enough players, expand ranked range + await expandSearchRange(queueData, originalParty, services); + } } -} \ No newline at end of file + } +}; diff --git a/src/modules/matchmaking/algorithms/types.ts b/src/modules/matchmaking/algorithms/types.ts new file mode 100644 index 0000000..7e9e274 --- /dev/null +++ b/src/modules/matchmaking/algorithms/types.ts @@ -0,0 +1,13 @@ +import type { WithId } from "mongodb"; +import type { QueueConfig } from "types/queues.js"; +import type { QueueDocument } from "types/queueDocument.js"; + +export type Algorithm = (cfg: T, services: Services) => Promise; + +export type Services = { + getOldestParties: (queueId: string, limit?: number) => Promise[]>; + updatePartyRange?: (partyId: string, rankedMin: number, rankedMax: number) => Promise; + createMatch: (queueData: QueueConfig, teams: number[][], partiesUsed: string[]) => Promise; + now: () => Date; + log?: (message: string, meta?: Record) => void; +}; diff --git a/src/modules/matchmaking/algorithms/utils.ts b/src/modules/matchmaking/algorithms/utils.ts new file mode 100644 index 0000000..62df625 --- /dev/null +++ b/src/modules/matchmaking/algorithms/utils.ts @@ -0,0 +1,44 @@ +import type { WithId } from "mongodb"; +import type { QueueDocument } from "types/queueDocument.js"; + +export function createTeamsArray(n: number): number[][] { + return Array.from({ length: n }, () => [] as number[]); +} + +export function sumUsers(parties: WithId[]): number { + return parties.reduce((sum, p) => sum + p.userIds.length, 0); +} + +export function ageSeconds(date: Date, now: Date): number { + return Math.floor((now.getTime() - date.getTime()) / 1000); +} + +// Greedy packing by largest parties first. +// Returns null if exact fill not possible with given parties. +export function packTeamsGreedy( + parties: WithId[], + teamsPerMatch: number, + usersPerTeam: number +): { teams: number[][]; partiesUsed: string[] } | null { + const sorted = [...parties].sort((a, b) => b.userIds.length - a.userIds.length); + const teams = createTeamsArray(teamsPerMatch); + const partiesUsed: string[] = []; + + for (const party of sorted) { + let placed = false; + for (const team of teams) { + if (team.length + party.userIds.length <= usersPerTeam) { + team.push(...party.userIds); + partiesUsed.push(party._id); + placed = true; + break; + } + } + const allFilled = teams.every((t) => t.length === usersPerTeam); + if (allFilled) return { teams, partiesUsed }; + if (!placed) continue; + } + + // Not able to exactly fill all teams + return null; +} diff --git a/src/modules/matchmaking/matchmaking.ts b/src/modules/matchmaking/matchmaking.ts index 8d4b03a..3c304ea 100644 --- a/src/modules/matchmaking/matchmaking.ts +++ b/src/modules/matchmaking/matchmaking.ts @@ -4,124 +4,138 @@ import type { QueueConfig } from "types/queues.js"; import { queues } from "../config.js"; import { foundMatchesCollection, foundPartiesCollection, queuesCollection, serverIdsCollection } from "../database.js"; import { emptyHandler } from "../empty-handler.js"; -import { findRankedMatch } from "./algorithms/ranked.js"; +import { algorithmRegistry } from "./algorithms/index.js"; +import type { Services } from "./algorithms/types.js"; export async function getPartyMatch(partyId: string): Promise | null> { - const matchId: ObjectId | null = await foundPartiesCollection.findOne({ _id: partyId }).then(foundParty => { - if (foundParty && foundParty.matchId) { - return foundParty.matchId; + const matchId: ObjectId | null = await foundPartiesCollection + .findOne({ _id: partyId }) + .then((foundParty) => { + if (foundParty && foundParty.matchId) { + return foundParty.matchId; + } + return null; + }) + .catch(() => null); + + if (matchId) { + return await foundMatchesCollection + .findOne({ + _id: matchId + }) + .then((match) => { + if (match) { + return match; } return null; - }).catch(() => null); - - if (matchId) { - return await foundMatchesCollection.findOne({ - _id: matchId - }).then(match => { - if (match) { - return match - } - return null - }).catch(() => null) - } - return null + }) + .catch(() => null); + } + return null; } export async function createMatch(queueData: QueueConfig, teams: number[][], partiesUsed: string[]) { - const { queueId } = queueData; + const { queueId } = queueData; - const currentDate = new Date(); + const currentDate = new Date(); - // find and delete a server access code - const serverResult = await serverIdsCollection.findOneAndDelete( - {}, - { sort: { createdAt: 1 } } - ) - - const serverAccessToken = serverResult?._id - if (!serverAccessToken) { - return { - success: false, - status: "NoServerAccessCode" - } - } + // find and delete a server access code + const serverResult = await serverIdsCollection.findOneAndDelete({}, { sort: { createdAt: 1 } }); - // create the match - const insertedMatchId: ObjectId | null = await foundMatchesCollection.insertOne({ - teams, - serverAccessToken, - queueId, + const serverAccessToken = serverResult?._id; + if (!serverAccessToken) { + return { + success: false, + status: "NoServerAccessCode" + }; + } + + // create the match + const insertedMatchId: ObjectId | null = await foundMatchesCollection + .insertOne({ + teams, + serverAccessToken, + queueId, + createdAt: currentDate + }) + .then((result) => { + return result.insertedId; + }) + .catch(() => null); + + if (!insertedMatchId) { + return { + success: false, + status: "FailedToCreateMatch" + }; + } + + // remove the parties from the queue + queuesCollection.deleteMany({ _id: { $in: partiesUsed } }).catch(emptyHandler); + + // Insert found parties into the foundPartiesCollection + foundPartiesCollection + .insertMany( + partiesUsed.map((partyId) => ({ + _id: partyId, + matchId: insertedMatchId, createdAt: currentDate - }).then(result => { - return result.insertedId; - }).catch(() => null); - - if (!insertedMatchId) { - return { - success: false, - status: "FailedToCreateMatch" - } - } - - // remove the parties from the queue - queuesCollection.deleteMany({ _id: { $in: partiesUsed } }).catch(emptyHandler); - - // Insert found parties into the foundPartiesCollection - foundPartiesCollection.insertMany( - partiesUsed.map(partyId => ({ - _id: partyId, - matchId: insertedMatchId, - createdAt: currentDate - })) - ).catch(emptyHandler); + })) + ) + .catch(emptyHandler); - console.log("found match", queueId, insertedMatchId.toString()) + console.log("found match", queueId, insertedMatchId.toString()); - return { - success: true, - status: "CreatedMatch" - } + return { + success: true, + status: "CreatedMatch" + }; } export async function discoverMatches(queueId: string) { - const queueData = queues.find(q => q.queueId === queueId); - if (!queueData) { - return false; - } - - const { - queueType - } = queueData; - - switch (queueType) { - case "normal": - // TODO - break - case "dynamic": - // TODO - break - case "ranked": - await findRankedMatch(queueData); - break - } + const queueData = queues.find((q) => q.queueId === queueId); + if (!queueData) { + return false; + } + + const services: Services = { + getOldestParties: async (qid: string, limit = 2500) => { + const docs = await queuesCollection + .find({ queueId: qid }) + .sort({ timeAdded: 1 }) + .limit(limit) + .toArray() + .catch(emptyHandler as any); + return docs || []; + }, + updatePartyRange: async (partyId: string, rankedMin: number, rankedMax: number) => { + await queuesCollection.updateOne({ _id: partyId }, { $set: { rankedMin, rankedMax } }).catch(emptyHandler); + }, + createMatch: (data, teams, parties) => createMatch(data, teams, parties), + now: () => new Date(), + log: (message, meta) => console.log(`[matchmaking] ${message}`, meta ?? {}) + }; + + const algo = algorithmRegistry[queueData.queueType]; + await algo(queueData as any, services); } let matchmakingInitialized = false; export function initMatchmaking() { - if (matchmakingInitialized) { - return; - } - matchmakingInitialized = true; - - for (const queue of queues) { - const { queueId, discoverMatchesInterval } = queue; - - // Start an async function to handle the loop for each queue - (async () => { - while (true) { - await discoverMatches(queueId); - await new Promise(resolve => setTimeout(resolve, discoverMatchesInterval * 1000)); - } - })(); - } + if (matchmakingInitialized) { + return; + } + matchmakingInitialized = true; + + for (const queue of queues) { + const { queueId, discoverMatchesInterval } = queue; + + // Start an async function to handle the loop for each queue + (async () => { + while (true) { + await discoverMatches(queueId); + await new Promise((resolve) => setTimeout(resolve, discoverMatchesInterval * 1000)); + } + })(); + } } diff --git a/src/routes/v1/healthcheck.ts b/src/routes/v1/healthcheck.ts index b75d4b3..593e074 100644 --- a/src/routes/v1/healthcheck.ts +++ b/src/routes/v1/healthcheck.ts @@ -3,7 +3,7 @@ import { Hono } from "hono"; const routes = new Hono(); routes.get("/", (c) => { - return c.json({ success: true }); + return c.json({ success: true }); }); -export { routes }; \ No newline at end of file +export { routes }; diff --git a/src/routes/v1/join-queue.ts b/src/routes/v1/join-queue.ts index 2c9b322..60616e5 100644 --- a/src/routes/v1/join-queue.ts +++ b/src/routes/v1/join-queue.ts @@ -9,79 +9,83 @@ import { getPartyMatch } from "modules/matchmaking/matchmaking.js"; const routes = new Hono(); -routes.post("/", authMiddleware, parseJSONBody({ schema: JoinQueueSchema }), async (c: ContextWithParsedBody) => { - const { - partyId, - userIds, - queueId, - rankedValue, - serverAccessToken - } = c.bodyData; +routes.post( + "/", + authMiddleware, + parseJSONBody({ schema: JoinQueueSchema }), + async (c: ContextWithParsedBody) => { + const { partyId, userIds, queueId, rankedValue, serverAccessToken } = c.bodyData; - const queueConfig = queues.find(config => config.queueId === queueId) - if (!queueConfig) { - return c.json({ error: "Queue not found" }, 404); - } + const queueConfig = queues.find((config) => config.queueId === queueId); + if (!queueConfig) { + return c.json({ error: "Queue not found" }, 404); + } - if (userIds.length > queueConfig.usersPerTeam) { - return c.json({ error: "Too many users for this queue" }, 400); - } + if (userIds.length > queueConfig.usersPerTeam) { + return c.json({ error: "Too many users for this queue" }, 400); + } - if (queueConfig.queueType == "ranked") { - if (rankedValue === undefined || rankedValue == null) { - return c.json({ error: "Ranked value is required" }, 400); - } - } + if (queueConfig.queueType == "ranked") { + if (rankedValue === undefined || rankedValue == null) { + return c.json({ error: "Ranked value is required" }, 400); + } + } - const currentDate = new Date(); + const currentDate = new Date(); - if (serverAccessToken) { - serverIdsCollection.insertOne({ - _id: serverAccessToken, - createdAt: currentDate - }).catch(emptyHandler); - } + if (serverAccessToken) { + serverIdsCollection + .insertOne({ + _id: serverAccessToken, + createdAt: currentDate + }) + .catch(emptyHandler); + } - const foundMatch = await getPartyMatch(partyId) - if (foundMatch) { - return c.json({ - success: true, - status: "FoundMatch", - matchData: foundMatch - }) - } + const foundMatch = await getPartyMatch(partyId); + if (foundMatch) { + return c.json({ + success: true, + status: "FoundMatch", + matchData: foundMatch + }); + } - const result = await queuesCollection.findOneAndUpdate( - { - _id: partyId - }, - { - $setOnInsert: { - timeAdded: currentDate, - }, - $set: { - queueId, - userIds, - rankedValue: rankedValue, - } - }, - { - upsert: true, - returnDocument: "after" - } - ).then(newDoc => { - return { - success: true, - status: "InQueue", - queueData: newDoc - } - }).catch(() => { - return { - success: false - } - }) + const result = await queuesCollection + .findOneAndUpdate( + { + _id: partyId + }, + { + $setOnInsert: { + timeAdded: currentDate + }, + $set: { + queueId, + userIds, + rankedValue: rankedValue + } + }, + { + upsert: true, + returnDocument: "after" + } + ) + .then((newDoc) => { + return { + success: true, + status: "InQueue", + queueData: newDoc + }; + }) + .catch(() => { + return { + success: false + }; + }); - return c.json(result); -}); + return c.json(result); + } +); -export { routes }; \ No newline at end of file +export { routes }; diff --git a/src/routes/v1/leave-queue.ts b/src/routes/v1/leave-queue.ts index 13702a1..a69bb34 100644 --- a/src/routes/v1/leave-queue.ts +++ b/src/routes/v1/leave-queue.ts @@ -7,33 +7,39 @@ import { LeaveQueueSchema } from "schemas/leaveQueue.js"; const routes = new Hono(); -routes.post("/", authMiddleware, parseJSONBody({ schema: LeaveQueueSchema }), async (c: ContextWithParsedBody) => { - const { - partyId - } = c.bodyData; +routes.post( + "/", + authMiddleware, + parseJSONBody({ schema: LeaveQueueSchema }), + async (c: ContextWithParsedBody) => { + const { partyId } = c.bodyData; - const foundMatch = await getPartyMatch(partyId) - if (foundMatch) { - return c.json({ - success: true, - status: "FoundMatch", - matchData: foundMatch - }) - } + const foundMatch = await getPartyMatch(partyId); + if (foundMatch) { + return c.json({ + success: true, + status: "FoundMatch", + matchData: foundMatch + }); + } - return await queuesCollection.deleteOne({ - _id: partyId - }).then(() => { - // success even if not found in queue so we can return a success status - return c.json({ - success: true, - status: "RemovedFromQueue" - }) - }).catch(() => { - return c.json({ - success: false - }) - }) -}); + return await queuesCollection + .deleteOne({ + _id: partyId + }) + .then(() => { + // success even if not found in queue so we can return a success status + return c.json({ + success: true, + status: "RemovedFromQueue" + }); + }) + .catch(() => { + return c.json({ + success: false + }); + }); + } +); -export { routes }; \ No newline at end of file +export { routes }; diff --git a/src/schemas/joinQueue.ts b/src/schemas/joinQueue.ts index f2bc555..f889a4d 100644 --- a/src/schemas/joinQueue.ts +++ b/src/schemas/joinQueue.ts @@ -2,9 +2,9 @@ import { z } from "zod"; import { ValidQueueIdSchema } from "./validQueues.js"; export const JoinQueueSchema = z.object({ - partyId: z.string().min(1), - userIds: z.array(z.number()).min(1), - queueId: ValidQueueIdSchema, - rankedValue: z.number().nullable().optional(), - serverAccessToken: z.string().nullable().optional(), -}) \ No newline at end of file + partyId: z.string().min(1), + userIds: z.array(z.number()).min(1), + queueId: ValidQueueIdSchema, + rankedValue: z.number().nullable().optional(), + serverAccessToken: z.string().nullable().optional() +}); diff --git a/src/schemas/leaveQueue.ts b/src/schemas/leaveQueue.ts index d511579..facbb4a 100644 --- a/src/schemas/leaveQueue.ts +++ b/src/schemas/leaveQueue.ts @@ -1,5 +1,5 @@ import { z } from "zod"; export const LeaveQueueSchema = z.object({ - partyId: z.string().min(1) -}) \ No newline at end of file + partyId: z.string().min(1) +}); diff --git a/src/schemas/validQueues.ts b/src/schemas/validQueues.ts index b90161f..f384120 100644 --- a/src/schemas/validQueues.ts +++ b/src/schemas/validQueues.ts @@ -2,4 +2,4 @@ import { validQueueIds } from "modules/config.js"; import { z } from "zod"; export const ValidQueueIdSchema = z.enum(validQueueIds); -export type ValidQueueId = z.infer; \ No newline at end of file +export type ValidQueueId = z.infer; diff --git a/src/types/foundMatchDocument.ts b/src/types/foundMatchDocument.ts index 5225dad..ce6dde1 100644 --- a/src/types/foundMatchDocument.ts +++ b/src/types/foundMatchDocument.ts @@ -1,13 +1,13 @@ export interface FoundMatchDocument { - /* + /* e.g: [ [1, 2], // Red Team [3, 4] // Blue Team ] */ - teams: number[][], - serverAccessToken: string, - queueId: string, - createdAt: Date, -} \ No newline at end of file + teams: number[][]; + serverAccessToken: string; + queueId: string; + createdAt: Date; +} diff --git a/src/types/foundPartyDocument.ts b/src/types/foundPartyDocument.ts index 7e57600..36eb4ee 100644 --- a/src/types/foundPartyDocument.ts +++ b/src/types/foundPartyDocument.ts @@ -1,7 +1,7 @@ import { ObjectId } from "mongodb"; export interface FoundPartyDocument { - _id: string, - matchId: ObjectId, - createdAt: Date -} \ No newline at end of file + _id: string; + matchId: ObjectId; + createdAt: Date; +} diff --git a/src/types/queueDocument.ts b/src/types/queueDocument.ts index d3b6b94..4aa6a12 100644 --- a/src/types/queueDocument.ts +++ b/src/types/queueDocument.ts @@ -1,11 +1,11 @@ export interface QueueDocument { - _id: string, // Party ID - userIds: number[], - queueId: string, - timeAdded: Date, + _id: string; // Party ID + userIds: number[]; + queueId: string; + timeAdded: Date; - // ranked specific - rankedValue?: number, - rankedMax?: number, - rankedMin?: number, -} \ No newline at end of file + // ranked specific + rankedValue?: number; + rankedMax?: number; + rankedMin?: number; +} diff --git a/src/types/queues.ts b/src/types/queues.ts index acf264a..bc6c472 100644 --- a/src/types/queues.ts +++ b/src/types/queues.ts @@ -1,30 +1,30 @@ type BasicQueueConfig = { - queueId: string, + queueId: string; - usersPerTeam: number, - teamsPerMatch: number, + usersPerTeam: number; + teamsPerMatch: number; - discoverMatchesInterval: number, -} + discoverMatchesInterval: number; +}; type NormalQueueConfig = BasicQueueConfig & { - queueType: "normal", -} + queueType: "normal"; +}; type DynamicQueueConfig = BasicQueueConfig & { - queueType: "dynamic", + queueType: "dynamic"; - minUsersPerTeam: number, - maxUsersPerTeam: number, + minUsersPerTeam: number; + maxUsersPerTeam: number; - timeElaspedToUseMinimumUsers: number, -} + timeElaspedToUseMinimumUsers: number; +}; type RankedQueueConfig = BasicQueueConfig & { - queueType: "ranked", + queueType: "ranked"; - searchRange: [number, number], - incrementRange: [number, number], - incrementRangeMax?: [number, number] -} + searchRange: [number, number]; + incrementRange: [number, number]; + incrementRangeMax?: [number, number]; +}; -export type QueueConfig = NormalQueueConfig | DynamicQueueConfig | RankedQueueConfig -export type QueueConfigs = QueueConfig[] \ No newline at end of file +export type QueueConfig = NormalQueueConfig | DynamicQueueConfig | RankedQueueConfig; +export type QueueConfigs = QueueConfig[]; diff --git a/src/types/serverDocument.ts b/src/types/serverDocument.ts index ae6eee0..99280e2 100644 --- a/src/types/serverDocument.ts +++ b/src/types/serverDocument.ts @@ -1,4 +1,4 @@ export interface ServerDocument { - _id: string, - createdAt: Date, -} \ No newline at end of file + _id: string; + createdAt: Date; +} diff --git a/tests/configuration.ts b/tests/configuration.ts index d884076..e41a34f 100644 --- a/tests/configuration.ts +++ b/tests/configuration.ts @@ -1 +1 @@ -export const BASE_URL = 'http://localhost:3000'; \ No newline at end of file +export const BASE_URL = "http://localhost:3000"; diff --git a/tests/manual/join-queue.ts b/tests/manual/join-queue.ts index ed38d0f..3e14a99 100644 --- a/tests/manual/join-queue.ts +++ b/tests/manual/join-queue.ts @@ -1,27 +1,27 @@ -import { BASE_URL } from "../configuration" -import { randomUUID } from "node:crypto" +import { BASE_URL } from "../configuration"; +import { randomUUID } from "node:crypto"; -const queueId = prompt("Queue ID:") +const queueId = prompt("Queue ID:"); -const partyId = prompt("Party ID (Autogenerate if empty):") || randomUUID() -const userIds = prompt("User IDs (comma-separated):")?.split(",").map(Number) || [] +const partyId = prompt("Party ID (Autogenerate if empty):") || randomUUID(); +const userIds = prompt("User IDs (comma-separated):")?.split(",").map(Number) || []; -const rankedValue = Number(prompt("Ranked Value:") || "0") +const rankedValue = Number(prompt("Ranked Value:") || "0"); const body = { - partyId, - userIds, - queueId, - rankedValue, -} + partyId, + userIds, + queueId, + rankedValue +}; fetch(`${BASE_URL}/v1/join-queue`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) }) - .then(response => response.json()) - .then(data => console.log(data)) - .catch(error => console.error("Error:", error)) \ No newline at end of file + .then((response) => response.json()) + .then((data) => console.log(data)) + .catch((error) => console.error("Error:", error)); diff --git a/tests/manual/leave-queue.ts b/tests/manual/leave-queue.ts index 507d7db..c69229c 100644 --- a/tests/manual/leave-queue.ts +++ b/tests/manual/leave-queue.ts @@ -1,19 +1,19 @@ -import { BASE_URL } from "../configuration" -import { randomUUID } from "node:crypto" +import { BASE_URL } from "../configuration"; +import { randomUUID } from "node:crypto"; -const partyId = prompt("Party ID:") +const partyId = prompt("Party ID:"); const body = { - partyId -} + partyId +}; fetch(`${BASE_URL}/v1/leave-queue`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(body) + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify(body) }) - .then(response => response.json()) - .then(data => console.log(data)) - .catch(error => console.error("Error:", error)) \ No newline at end of file + .then((response) => response.json()) + .then((data) => console.log(data)) + .catch((error) => console.error("Error:", error)); diff --git a/tsconfig.json b/tsconfig.json index 5682b9a..1d2863d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,29 +1,20 @@ { - "compilerOptions": { - "target": "es2020", - "module": "NodeNext", - "lib": [ - "es2020" - ], - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "sourceMap": true, - "outDir": "./dist", - "rootDir": "./src", - "baseUrl": "./src", - "paths": { - "*": [ - "*" - ] - }, - "noEmit": true - }, - "include": [ - "src/**/*" - ], - "exclude": [ - "node_modules", - "dist" - ] -} \ No newline at end of file + "compilerOptions": { + "target": "es2020", + "module": "NodeNext", + "lib": ["es2020"], + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "./src", + "paths": { + "*": ["*"] + }, + "noEmit": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}