From 5dc711de47539aa560afe1ac16b0626b03af5b0d Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Fri, 7 Nov 2025 13:28:12 -0600 Subject: [PATCH 1/7] feat: Include new queue service class. This class will handle all queueing and checking if users are in queues via redis. --- config/development.json.template | 3 +- config/production.json.template | 3 +- config/test.json.template | 3 +- src/services/queue.ts | 189 ++++++++++++++++++++++++++++ src/types/queues/QueueDescriptor.ts | 9 ++ src/types/queues/QueueItem.ts | 5 + src/utility/utils.ts | 72 +++++++++++ yarn.lock | 6 +- 8 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 src/services/queue.ts create mode 100644 src/types/queues/QueueDescriptor.ts create mode 100644 src/types/queues/QueueItem.ts diff --git a/config/development.json.template b/config/development.json.template index 01d3bbb2..d46971df 100644 --- a/config/development.json.template +++ b/config/development.json.template @@ -11,7 +11,8 @@ "uploadDemos": false, "localLoginEnabled": true, "redisUrl": "redis://:super_secure@localhost:6379", - "redisTTL": 86400 + "redisTTL": 86400, + "queueTTL": 3600 }, "development": { "driver": "mysql", diff --git a/config/production.json.template b/config/production.json.template index 4cc0a41f..3c2263e9 100644 --- a/config/production.json.template +++ b/config/production.json.template @@ -11,7 +11,8 @@ "uploadDemos": $UPLOADDEMOS, "localLoginEnabled": $LOCALLOGINS, "redisUrl": "$REDISURL", - "redisTTL": $REDISTTL + "redisTTL": $REDISTTL, + "queueTTL": $QUEUETTL }, "production": { "driver": "mysql", diff --git a/config/test.json.template b/config/test.json.template index 402b24b9..246c26f3 100644 --- a/config/test.json.template +++ b/config/test.json.template @@ -11,7 +11,8 @@ "uploadDemos": false, "localLoginEnabled": true, "redisUrl": "redis://:super_secure@localhost:6379", - "redisTTL": 86400 + "redisTTL": 86400, + "queueTTL": 3600 }, "test": { "driver": "mysql", diff --git a/src/services/queue.ts b/src/services/queue.ts new file mode 100644 index 00000000..63580275 --- /dev/null +++ b/src/services/queue.ts @@ -0,0 +1,189 @@ +import config from "config"; +import Utils from "../utility/utils.js"; +import { QueueDescriptor } from "../types/queues/QueueDescriptor.js" +import { QueueItem } from "../types/queues/QueueItem.js"; +import { createClient } from "redis"; + +const redis = createClient({ url: config.get("server.redisUrl"), }); +const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL"); + +export class QueueService { + async createQueue(ttlSeconds = DEFAULT_TTL_SECONDS, ownerId?: string, maxPlayers: number = 10): Promise { + let slug: string; + let key: string; + let attempts: number = 0; + + do { + slug = Utils.generateSlug(); + key = `queue:${slug}`; + const exists = await redis.exists(key); + if (!exists) break; + attempts++; + } while (attempts < 5); + + if (attempts === 5) { + throw new Error('Failed to generate a unique queue slug after 5 attempts.'); + } + + const createdAt = Date.now(); + const expiresAt = createdAt + ttlSeconds * 1000; + + const descriptor: QueueDescriptor = { + name: slug, + slug, + createdAt, + expiresAt, + ownerId, + maxSize: maxPlayers, + isPrivate: false + }; + + await redis.sAdd('queues', slug); + await redis.expire(key, ttlSeconds); + await redis.set(`queue-meta:${slug}`, JSON.stringify(descriptor), { EX: ttlSeconds }); + + return descriptor; + } + + async deleteQueue( + slug: string, + requesterSteamId: string, + role: 'user' | 'admin' | 'super_admin' + ): Promise { + const key = `queue:${slug}`; + const metaKey = `queue-meta:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + // Permission check + const isOwner = meta.ownerId === requesterSteamId; + const isAdmin = role === 'admin' || role === 'super_admin'; + + if (!isOwner && !isAdmin) { + throw new Error('You do not have permission to delete this queue.'); + } + + // Delete queue data + await redis.del(key); // Remove queue list + await redis.del(metaKey); // Remove metadata + await redis.sRem('queues', slug); // Remove from global queue list + } + + async addUserToQueue( + slug: string, + steamId: string, + requesterSteamId: string, + role: 'user' | 'admin' | 'super_admin' + ): Promise { + const key = `queue:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + // Permission check + if ( + role === 'user' && + steamId !== requesterSteamId && + meta.ownerId !== requesterSteamId + ) { + throw new Error('You do not have permission to add other users to this queue.'); + } + + const currentUsers = await redis.lRange(key, 0, -1); + const alreadyInQueue = currentUsers.some((item: string) => { + const parsed = JSON.parse(item); + return parsed.steamId === steamId; + }); + if (alreadyInQueue) { + throw new Error(`Steam ID ${steamId} is already in the queue.`); + } + + if (meta.maxSize && currentUsers.length >= meta.maxSize) { + throw new Error(`Queue ${slug} is full.`); + } + + const hltvRating = await Utils.getRatingFromSteamId(steamId); + + const item: QueueItem = { + steamId, + timestamp: Date.now(), + hltvRating: hltvRating ?? undefined + }; + + await redis.rPush(key, JSON.stringify(item)); + } + + async removeUserFromQueue( + slug: string, + steamId: string, + requesterSteamId: string, + role: 'user' | 'admin' | 'super_admin' + ): Promise { + const key = `queue:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + // Permission check + if ( + role === 'user' && + steamId !== requesterSteamId && + meta.ownerId !== requesterSteamId + ) { + throw new Error('You do not have permission to remove other users from this queue.'); + } + + const currentUsers = await redis.lRange(key, 0, -1); + for (const item of currentUsers) { + const parsed = JSON.parse(item); + if (parsed.steamId === steamId) { + await redis.lRem(key, 1, item); + return true; + } + } + + return false; + } + + async listUsersInQueue(slug: string): Promise { + const key = `queue:${slug}`; + const exists = await redis.exists(key); + if (!exists) throw new Error(`Queue ${slug} does not exist or has expired.`); + + const rawItems = await redis.lRange(key, 0, -1); + return rawItems.map((item: string) => JSON.parse(item)); + } + + async listQueues(requesterSteamId: string, role: 'user' | 'admin' | 'super_admin'): Promise { + const slugs = await redis.sMembers('queues'); + const descriptors: QueueDescriptor[] = []; + + for (const slug of slugs) { + const metaRaw = await redis.get(`queue-meta:${slug}`); + if (!metaRaw) continue; + + const meta: QueueDescriptor = JSON.parse(metaRaw); + + if (role === 'admin' || role === 'super_admin' || meta.ownerId === requesterSteamId) { + descriptors.push(meta); + } + } + + return descriptors; + } + +} + +async function getQueueMetaOrThrow(slug: string): Promise { + const key = `queue:${slug}`; + const metaKey = `queue-meta:${slug}`; + + const exists = await redis.exists(key); + if (!exists) { + throw new Error(`Queue ${slug} does not exist or has expired.`); + } + + const metaRaw = await redis.get(metaKey); + if (!metaRaw) { + throw new Error(`Queue metadata missing for ${slug}.`); + } + + return JSON.parse(metaRaw); +} + +export default QueueService; \ No newline at end of file diff --git a/src/types/queues/QueueDescriptor.ts b/src/types/queues/QueueDescriptor.ts new file mode 100644 index 00000000..1d1bfc94 --- /dev/null +++ b/src/types/queues/QueueDescriptor.ts @@ -0,0 +1,9 @@ +export interface QueueDescriptor { + name: string; // Human-readable name + slug: string; // Unique identifier + createdAt: number; // Timestamp (ms) when queue was created + expiresAt: number; // Timestamp (ms) when queue will expire + ownerId?: string; // Optional user ID of the queue creator + maxSize?: number; // Optional max number of users allowed + isPrivate?: boolean; // Optional flag for visibility +} \ No newline at end of file diff --git a/src/types/queues/QueueItem.ts b/src/types/queues/QueueItem.ts new file mode 100644 index 00000000..cf01e5b2 --- /dev/null +++ b/src/types/queues/QueueItem.ts @@ -0,0 +1,5 @@ +export interface QueueItem { + steamId: string; + timestamp: number; + hltvRating?: number; +} \ No newline at end of file diff --git a/src/utility/utils.ts b/src/utility/utils.ts index 563a45e5..ca358681 100644 --- a/src/utility/utils.ts +++ b/src/utility/utils.ts @@ -81,6 +81,43 @@ class Utils { } } +/** + * Fetches HLTV rating for a user by Steam ID. + * @param steamId - The user's Steam ID + * @returns HLTV rating or null if not found + */ +static async getRatingFromSteamId(steamId: string): Promise { + let playerStatSql = + `SELECT steam_id, name, sum(kills) as kills, + sum(deaths) as deaths, sum(assists) as assists, sum(k1) as k1, + sum(k2) as k2, sum(k3) as k3, + sum(k4) as k4, sum(k5) as k5, sum(v1) as v1, + sum(v2) as v2, sum(v3) as v3, sum(v4) as v4, + sum(v5) as v5, sum(roundsplayed) as trp, sum(flashbang_assists) as fba, + sum(damage) as dmg, sum(headshot_kills) as hsk, count(id) as totalMaps, + sum(knife_kills) as knifekills, sum(friendlies_flashed) as fflash, + sum(enemies_flashed) as eflash, sum(util_damage) as utildmg + FROM player_stats + WHERE steam_id = ? + AND match_id IN ( + SELECT id + FROM \`match\` + WHERE cancelled = 0)`; + const user: RowDataPacket[] = await db.query(playerStatSql, [steamId]);; + + if (!user.length) return null; + + return this.getRating(parseFloat(user[0].kills), + parseFloat(user[0].trp), + parseFloat(user[0].deaths), + parseFloat(user[0].k1), + parseFloat(user[0].k2), + parseFloat(user[0].k3), + parseFloat(user[0].k4), + parseFloat(user[0].k5)); +} + + /** Inner function - Supports encryption and decryption for the database keys to get server RCON passwords. * @name decrypt * @function @@ -591,6 +628,40 @@ class Utils { } } + /** + * Generates a Counter-Strike-style slug using themed adjectives and nouns, + * including weapon skins and knife types. + * Example: "clutch-karambit" or "dusty-dragonlore" + */ + public static generateSlug(): string { + const adjectives = [ + 'dusty', 'silent', 'brutal', 'clutch', 'smoky', 'tactical', 'deadly', 'stealthy', + 'eco', 'forceful', 'aggressive', 'defensive', 'sneaky', 'explosive', 'fraggy', 'nasty', + 'quick', 'slow', 'noisy', 'clean', 'dirty', 'sharp', 'blind', 'lucky', + 'fiery', 'cold', 'ghostly', 'venomous', 'royal' + ]; + + const nouns = [ + // Weapons & gameplay + 'ak47', 'deagle', 'bombsite', 'flashbang', 'knife', 'smoke', 'molotov', 'awp', + 'nade', 'scout', 'pistol', 'rifle', 'mid', 'long', 'short', 'connector', + 'ramp', 'hegrenade', 'tunnel', 'palace', 'apps', 'boost', 'peek', 'spray', + + // Skins + 'dragonlore', 'fireserpent', 'hyperbeast', 'fade', 'casehardened', 'redline', + 'vulcan', 'asiimov', 'howl', 'bloodsport', 'phantomdisruptor', 'neonrider', + + // Knives + 'karambit', 'bayonet', 'butterfly', 'gutknife', 'falchion', 'shadowdaggers', + 'huntsman', 'talon', 'ursus', 'paracord', 'nomad' + ]; + + const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; + const noun = nouns[Math.floor(Math.random() * nouns.length)]; + + return `${adj}-${noun}`; + } + public static addChallongeTeamAuthsToArray: (teamId: number, custom_field_response: { key: string; value: string; }) => Promise = async (teamId: number, custom_field_response: { key: string, value: string }) => { let teamAuthArray: Array> = []; let key: keyof typeof custom_field_response; @@ -608,6 +679,7 @@ class Utils { await db.query(sqlString, [teamAuthArray]); } } + } diff --git a/yarn.lock b/yarn.lock index 9975133c..adbefe36 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4945,9 +4945,9 @@ v8-to-istanbul@^9.0.1: convert-source-map "^2.0.0" validator@^13.7.0: - version "13.15.15" - resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.15.tgz#246594be5671dc09daa35caec5689fcd18c6e7e4" - integrity sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A== + version "13.15.20" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.20.tgz#054e9238109538a1bf46ae3e1290845a64fa2186" + integrity sha512-KxPOq3V2LmfQPP4eqf3Mq/zrT0Dqp2Vmx2Bn285LwVahLc+CsxOM0crBHczm8ijlcjZ0Q5Xd6LW3z3odTPnlrw== vary@^1, vary@~1.1.2: version "1.1.2" From f4e95ac0e9865c9fc04f71fa7719bbb625585295 Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:50:58 -0600 Subject: [PATCH 2/7] Include (untested) routes to the queue service to add and remove queues. TODO: Add in event streams to notify of new queues and list queues. TODO: Upon max queue creation, begin team selections, this will require changing the data of the queue. TODO: Upon team creation, create a match with no server, or a publically available one. Allow users to update as needed. --- app.ts | 2 + src/routes/queue.ts | 418 ++++++++++++++++++++++++++++++++++++++++++ src/services/queue.ts | 51 +++--- 3 files changed, 444 insertions(+), 27 deletions(-) create mode 100644 src/routes/queue.ts diff --git a/app.ts b/app.ts index 7b61dbe4..1c398e3b 100644 --- a/app.ts +++ b/app.ts @@ -24,6 +24,7 @@ import matchesRouter from "./src/routes/matches/matches.js"; import matchServerRouter from "./src/routes/matches/matchserver.js"; import playerstatsRouter from "./src/routes/playerstats/playerstats.js"; import playerstatsextraRouter from "./src/routes/playerstats/extrastats.js"; +import queuerouter from "./src/routes/queue.js"; import seasonsRouter from "./src/routes/seasons.js"; import serversRouter from "./src/routes/servers.js"; import teamsRouter from "./src/routes/teams.js"; @@ -143,6 +144,7 @@ app.use("/matches", matchesRouter, matchServerRouter); app.use("/mapstats", mapstatsRouter); app.use("/playerstats", playerstatsRouter); app.use("/playerstatsextra", playerstatsextraRouter); +app.use("/queue", queuerouter); app.use("/seasons", seasonsRouter); app.use("/match", legacyAPICalls); app.use("/leaderboard", leaderboardRouter); diff --git a/src/routes/queue.ts b/src/routes/queue.ts new file mode 100644 index 00000000..999cba0b --- /dev/null +++ b/src/routes/queue.ts @@ -0,0 +1,418 @@ +/** + * @swagger + * resourcePath: /queue + * description: Express API router for queue management in G5API. + */ + +import { Router } from 'express'; +import Utils from "../utility/utils.js"; +import { QueueService } from "../services/queue.js"; + +const router = Router(); + + +/** + * @swagger + * + * components: + * schemas: + * QueueDescriptor: + * type: object + * properties: + * name: + * type: string + * description: Human-readable name of the queue + * example: "Support Queue" + * slug: + * type: string + * description: Unique identifier for the queue + * example: "support-queue-abc123" + * createdAt: + * type: integer + * format: int64 + * description: Timestamp (ms) when the queue was created + * example: 1699478400000 + * expiresAt: + * type: integer + * format: int64 + * description: Timestamp (ms) when the queue will expire + * example: 1699482000000 + * ownerId: + * type: string + * nullable: true + * description: Optional user ID of the queue creator + * example: "user-456" + * maxSize: + * type: integer + * nullable: true + * description: Optional max number of users allowed + * example: 50 + * isPrivate: + * type: boolean + * nullable: true + * description: Optional flag for visibility + * example: false + * responses: + * NoSeasonData: + * description: No season data was provided. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SimpleResponse' + */ + + +/** + * @swagger + * + * /queue/: + * get: + * description: Get all available queues to the user from G5API. + * produces: + * - application/json + * tags: + * - queue + * responses: + * 200: + * description: All queues available to the user in the system. + * content: + * application/json: + * schema: + * type: object + * properties: + * seasons: + * type: array + * items: + * $ref: '#/components/schemas/QueueDescriptor' + * 404: + * $ref: '#/components/responses/NotFound' + * 500: + * $ref: '#/components/responses/Error' + */ +router.get('/', Utils.ensureAuthenticated, async (req, res) => { + try { + let role: string = 'user'; + if (req.user?.admin) role = 'admin'; + else if (req.user?.super_admin) role = 'super_admin'; + const queues = await QueueService.listQueues(req.user?.steam_id!, role); + res.status(200).json(queues); + } catch (error) { + console.error('Error listing queues:', error); + res.status(500).json({ error: 'Failed to list queues.' }); + } +}); + +/** + * @swagger + * + * /queue/:slug: + * get: + * description: Get a specific queue by its slug. + * produces: + * - application/json + * tags: + * - queue + * parameters: + * - name: slug + * in: path + * required: true + * description: The slug identifier of the queue. + * schema: + * type: string + * responses: + * 200: + * description: The requested queue descriptor. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/QueueDescriptor' + * 404: + * $ref: '#/components/responses/NotFound' + * 500: + * $ref: '#/components/responses/Error' + */ +router.get('/:slug', async (req, res) => { + const slug: string = req.params.slug; + + try { + let role: string = 'user'; + if (req.user?.admin) role = 'admin'; + else if (req.user?.super_admin) role = 'super_admin'; + const queue = await QueueService.getQueue(slug, role, req.user?.steam_id!); + res.status(200).json(queue); + } catch (error: Error | any) { + console.error('Error fetching queue:', error); + if (error.message.includes('not found')) { + return res.status(404).json({ error: 'Queue not found.' }); + } + res.status(500).json({ error: 'Failed to fetch queue.' }); + } +}); + +/** + * @swagger + * + * /queue/:slug/players: + * get: + * description: List all users in a specific queue. + * produces: + * - application/json + * tags: + * - queue + * parameters: + * - name: slug + * in: path + * required: true + * description: The slug identifier of the queue. + * schema: + * type: string + * - name: role + * in: query + * required: false + * description: Role of the requester (default is "user"). + * schema: + * type: string + * enum: [user, admin] + * responses: + * 200: + * description: List of users in the queue. + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/QueueItem' + * 403: + * description: Permission denied. + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "You do not have permission to remove other users from this queue." + * 500: + * $ref: '#/components/responses/Error' + */ +router.get('/:slug/players', Utils.ensureAuthenticated, async (req, res) => { + const slug: string = req.params.slug; + const requestorSteamId = req.user?.steam_id; + let role: string = 'user'; + if (!requestorSteamId) { + return res.status(401).json({ error: 'Unauthorized: Steam ID missing.' }); + } + + if (req.user?.admin) role = 'admin'; + else if (req.user?.super_admin) role = 'super_admin'; + + try { + const users = await QueueService.listUsersInQueue(slug, role, requestorSteamId); + res.status(200).json(users); + } catch (error) { + console.error('Error listing users in queue:', error); + res.status(500).json({ error: 'Failed to list users in queue.' }); + } +}); + +/** + * @swagger + * + * /queue/: + * post: + * description: Create a new queue in G5API using the authenticated user's Steam ID. + * consumes: + * - application/json + * produces: + * - application/json + * tags: + * - queue + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * maxPlayers: + * type: integer + * description: Maximum number of players allowed in the queue + * example: 10 + * private: + * type: boolean + * description: Whether the queue is private or will be listed publically. + * example: false + * required: false + * responses: + * 200: + * description: New season inserted successsfully. + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/SimpleResponse' + * 500: + * $ref: '#/components/responses/Error' + */ +router.post('/', Utils.ensureAuthenticated, async (req, res) => { + const maxPlayers: number = req.body[0].maxPlayers; + const isPrivate: boolean = req.body[0].private ? true : false; + + try { + await QueueService.createQueue(req.user?.steam_id!, maxPlayers, isPrivate); + res.json({ message: "Queue created successfully!" }); + } catch (error) { + console.error('Error creating queue:', error); + res.status(500).json({ error: 'Failed to create queue.' }); + } +}); + + +/** + * @swagger + * + * /queue/:slug: + * put: + * description: Adds or removes yourself from a specific queue. + * consumes: + * - application/json + * produces: + * - application/json + * tags: + * - queue + * parameters: + * - name: slug + * in: path + * required: true + * description: The slug identifier of the queue. + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * userId: + * type: string + * description: The Steam ID of the user. + * example: "steam_123456789" + * action: + * type: string + * description: Action to perform on the queue. + * enum: [join, leave] + * default: join + * responses: + * 200: + * description: User successfully added or removed from the queue. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * 400: + * description: Missing user ID or invalid action. + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "User ID is required." + * 500: + * $ref: '#/components/responses/Error' + */ +router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => { + const slug: string = req.params.slug; + const action: string = req.body.action ? req.body.action : 'join'; + + try { + if (action === 'join') { + await QueueService.addUserToQueue(slug, req.user?.steam_id!); + } else if (action === 'leave') { + await QueueService.removeUserFromQueue(slug, req.user?.steam_id!, req.user?.steam_id!); + } else { + return res.status(400).json({ error: 'Invalid action. Must be "join" or "leave".' }); + } + + res.status(200).json({ success: true }); + } catch (error) { + console.error(`Error processing ${action} action for queue:`, error); + res.status(500).json({ error: `Failed to ${action} user in queue.` }); + } + + /** + * @swagger + * + * /queue/: + * delete: + * description: Delete a specific queue. Only the owner or admin/super_admin can delete their queue. + * produces: + * - application/json + * tags: + * - queue + * parameters: + * - name: slug + * in: path + * required: true + * description: The slug identifier of the queue. + * schema: + * type: string + * responses: + * 200: + * description: Queue deleted successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * 403: + * description: Permission denied. + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * example: "You do not have permission to delete this queue." + * 404: + * $ref: '#/components/responses/NotFound' + * 500: + * $ref: '#/components/responses/Error' + */ + router.delete('/', Utils.ensureAuthenticated, async (req, res) => { + const slug: string = req.body[0].slug; + + try { + let role: string = 'user'; + if (req.user?.admin) role = 'admin'; + else if (req.user?.super_admin) role = 'super_admin'; + await QueueService.deleteQueue(slug, req.user?.steam_id!, role); + res.status(200).json({ message: "The queue has successfully been deleted!", success: true }); + } catch (error: Error | any) { + console.error('Error deleting queue:', error); + if (error.message.includes('permission')) { + return res.status(403).json({ error: error.message }); + } + if (error.message.includes('not found')) { + return res.status(404).json({ error: 'Queue not found.' }); + } + res.status(500).json({ error: 'Failed to delete queue.' }); + } + }); + +}); + + + +export default router; diff --git a/src/services/queue.ts b/src/services/queue.ts index 63580275..8b291127 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -8,7 +8,7 @@ const redis = createClient({ url: config.get("server.redisUrl"), }); const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL"); export class QueueService { - async createQueue(ttlSeconds = DEFAULT_TTL_SECONDS, ownerId?: string, maxPlayers: number = 10): Promise { + static async createQueue(ownerId?: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { let slug: string; let key: string; let attempts: number = 0; @@ -35,7 +35,7 @@ export class QueueService { expiresAt, ownerId, maxSize: maxPlayers, - isPrivate: false + isPrivate: isPrivate }; await redis.sAdd('queues', slug); @@ -45,17 +45,17 @@ export class QueueService { return descriptor; } - async deleteQueue( + static async deleteQueue( slug: string, - requesterSteamId: string, - role: 'user' | 'admin' | 'super_admin' + requestorSteamId: string, + role: string = "user" ): Promise { const key = `queue:${slug}`; const metaKey = `queue-meta:${slug}`; const meta = await getQueueMetaOrThrow(slug); // Permission check - const isOwner = meta.ownerId === requesterSteamId; + const isOwner = meta.ownerId === requestorSteamId; const isAdmin = role === 'admin' || role === 'super_admin'; if (!isOwner && !isAdmin) { @@ -68,23 +68,13 @@ export class QueueService { await redis.sRem('queues', slug); // Remove from global queue list } - async addUserToQueue( + static async addUserToQueue( slug: string, steamId: string, - requesterSteamId: string, - role: 'user' | 'admin' | 'super_admin' ): Promise { const key = `queue:${slug}`; const meta = await getQueueMetaOrThrow(slug); - // Permission check - if ( - role === 'user' && - steamId !== requesterSteamId && - meta.ownerId !== requesterSteamId - ) { - throw new Error('You do not have permission to add other users to this queue.'); - } const currentUsers = await redis.lRange(key, 0, -1); const alreadyInQueue = currentUsers.some((item: string) => { @@ -110,11 +100,11 @@ export class QueueService { await redis.rPush(key, JSON.stringify(item)); } - async removeUserFromQueue( + static async removeUserFromQueue( slug: string, steamId: string, - requesterSteamId: string, - role: 'user' | 'admin' | 'super_admin' + requestorSteamId: string, + role: string = "user" ): Promise { const key = `queue:${slug}`; const meta = await getQueueMetaOrThrow(slug); @@ -122,8 +112,8 @@ export class QueueService { // Permission check if ( role === 'user' && - steamId !== requesterSteamId && - meta.ownerId !== requesterSteamId + steamId !== requestorSteamId && + meta.ownerId !== requestorSteamId ) { throw new Error('You do not have permission to remove other users from this queue.'); } @@ -140,16 +130,15 @@ export class QueueService { return false; } - async listUsersInQueue(slug: string): Promise { + static async listUsersInQueue(slug: string, role: string = "user", requestorSteamId: string): Promise { const key = `queue:${slug}`; - const exists = await redis.exists(key); - if (!exists) throw new Error(`Queue ${slug} does not exist or has expired.`); + const meta = await getQueueMetaOrThrow(slug); const rawItems = await redis.lRange(key, 0, -1); return rawItems.map((item: string) => JSON.parse(item)); } - async listQueues(requesterSteamId: string, role: 'user' | 'admin' | 'super_admin'): Promise { + static async listQueues(requestorSteamId: string, role: string = "user"): Promise { const slugs = await redis.sMembers('queues'); const descriptors: QueueDescriptor[] = []; @@ -159,7 +148,7 @@ export class QueueService { const meta: QueueDescriptor = JSON.parse(metaRaw); - if (role === 'admin' || role === 'super_admin' || meta.ownerId === requesterSteamId) { + if (role === 'admin' || role === 'super_admin' || meta.ownerId === requestorSteamId || meta.isPrivate === false) { descriptors.push(meta); } } @@ -167,6 +156,14 @@ export class QueueService { return descriptors; } + static async getQueue(slug: string, role: string, requestorSteamId: string): Promise { + const meta = await getQueueMetaOrThrow(slug); + if (role === 'admin' || role === 'super_admin' || meta.ownerId === requestorSteamId || meta.isPrivate === false) { + return meta; + } + throw new Error('You do not have permission to remove other users from this queue.'); + } + } async function getQueueMetaOrThrow(slug: string): Promise { From ef7cbf8820cb462689a456f09eac49f121bb7e21 Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Mon, 10 Nov 2025 11:38:56 -0600 Subject: [PATCH 3/7] Update queues to properly join and leave, and list. --- src/routes/queue.ts | 14 +++++--------- src/services/queue.ts | 18 ++++++++++++------ src/types/queues/QueueDescriptor.ts | 1 - 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 999cba0b..69aed3d0 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -3,7 +3,7 @@ * resourcePath: /queue * description: Express API router for queue management in G5API. */ - +import config from "config"; import { Router } from 'express'; import Utils from "../utility/utils.js"; import { QueueService } from "../services/queue.js"; @@ -21,10 +21,6 @@ const router = Router(); * properties: * name: * type: string - * description: Human-readable name of the queue - * example: "Support Queue" - * slug: - * type: string * description: Unique identifier for the queue * example: "support-queue-abc123" * createdAt: @@ -142,7 +138,7 @@ router.get('/:slug', async (req, res) => { res.status(200).json(queue); } catch (error: Error | any) { console.error('Error fetching queue:', error); - if (error.message.includes('not found')) { + if (error.message.includes('does not exist')) { return res.status(404).json({ error: 'Queue not found.' }); } res.status(500).json({ error: 'Failed to fetch queue.' }); @@ -260,8 +256,8 @@ router.post('/', Utils.ensureAuthenticated, async (req, res) => { const isPrivate: boolean = req.body[0].private ? true : false; try { - await QueueService.createQueue(req.user?.steam_id!, maxPlayers, isPrivate); - res.json({ message: "Queue created successfully!" }); + const descriptor = await QueueService.createQueue(req.user?.steam_id!, maxPlayers, isPrivate); + res.json({ message: "Queue created successfully!", url: `${config.get("server.apiURL")}/queue/${descriptor.name}` }); } catch (error) { console.error('Error creating queue:', error); res.status(500).json({ error: 'Failed to create queue.' }); @@ -330,7 +326,7 @@ router.post('/', Utils.ensureAuthenticated, async (req, res) => { */ router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => { const slug: string = req.params.slug; - const action: string = req.body.action ? req.body.action : 'join'; + const action: string = req.body[0].action ? req.body[0].action : 'join'; try { if (action === 'join') { diff --git a/src/services/queue.ts b/src/services/queue.ts index 8b291127..7f8b543d 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -8,11 +8,14 @@ const redis = createClient({ url: config.get("server.redisUrl"), }); const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL"); export class QueueService { + static async createQueue(ownerId?: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { let slug: string; let key: string; let attempts: number = 0; - + if (redis.isOpen === false) { + await redis.connect(); + } do { slug = Utils.generateSlug(); key = `queue:${slug}`; @@ -30,7 +33,6 @@ export class QueueService { const descriptor: QueueDescriptor = { name: slug, - slug, createdAt, expiresAt, ownerId, @@ -139,6 +141,9 @@ export class QueueService { } static async listQueues(requestorSteamId: string, role: string = "user"): Promise { + if (redis.isOpen === false) { + await redis.connect(); + } const slugs = await redis.sMembers('queues'); const descriptors: QueueDescriptor[] = []; @@ -167,11 +172,12 @@ export class QueueService { } async function getQueueMetaOrThrow(slug: string): Promise { - const key = `queue:${slug}`; + if (redis.isOpen === false) { + await redis.connect(); + } const metaKey = `queue-meta:${slug}`; - - const exists = await redis.exists(key); - if (!exists) { + const members = await redis.sMembers('queues'); + if (!members.includes(slug)) { throw new Error(`Queue ${slug} does not exist or has expired.`); } diff --git a/src/types/queues/QueueDescriptor.ts b/src/types/queues/QueueDescriptor.ts index 1d1bfc94..173ecad0 100644 --- a/src/types/queues/QueueDescriptor.ts +++ b/src/types/queues/QueueDescriptor.ts @@ -1,6 +1,5 @@ export interface QueueDescriptor { name: string; // Human-readable name - slug: string; // Unique identifier createdAt: number; // Timestamp (ms) when queue was created expiresAt: number; // Timestamp (ms) when queue will expire ownerId?: string; // Optional user ID of the queue creator From b5624280fac79e1805f335064659a73887b78d69 Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Mon, 10 Nov 2025 12:45:22 -0600 Subject: [PATCH 4/7] Include tracking of current players in the queue descriptor. This should allow us to hit the max player limit and automatically remove the queue descriptor and start bringing in team arrays. For now, let's just assume teams are randomized always with no captains. --- src/routes/queue.ts | 10 ++++++++++ src/services/queue.ts | 19 +++++++++++++++++-- src/types/queues/QueueDescriptor.ts | 3 ++- 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 69aed3d0..5516a9b4 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -329,10 +329,20 @@ router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => { const action: string = req.body[0].action ? req.body[0].action : 'join'; try { + let currentQueueCount: number = await QueueService.getCurrentQueuePlayerCount(slug); + let maxQueueCount: number = await QueueService.getCurrentQueueMaxCount(slug); if (action === 'join') { await QueueService.addUserToQueue(slug, req.user?.steam_id!); + if (currentQueueCount == maxQueueCount) { + // TODO: Add in logic to create teams, or create a new queue with users' steam IDs and queue ID + // to get ready to shuffle teams. + } } else if (action === 'leave') { await QueueService.removeUserFromQueue(slug, req.user?.steam_id!, req.user?.steam_id!); + if (currentQueueCount == 0) { + // If no users are left in the queue, delete it. + await QueueService.deleteQueue(slug, req.user?.steam_id!, 'admin'); + } } else { return res.status(400).json({ error: 'Invalid action. Must be "join" or "leave".' }); } diff --git a/src/services/queue.ts b/src/services/queue.ts index 7f8b543d..d07b4a97 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -9,7 +9,7 @@ const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : export class QueueService { - static async createQueue(ownerId?: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { + static async createQueue(ownerId: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { let slug: string; let key: string; let attempts: number = 0; @@ -37,13 +37,16 @@ export class QueueService { expiresAt, ownerId, maxSize: maxPlayers, - isPrivate: isPrivate + isPrivate: isPrivate, + currentPlayers: 1 }; await redis.sAdd('queues', slug); await redis.expire(key, ttlSeconds); await redis.set(`queue-meta:${slug}`, JSON.stringify(descriptor), { EX: ttlSeconds }); + await this.addUserToQueue(slug, ownerId); + return descriptor; } @@ -100,6 +103,7 @@ export class QueueService { }; await redis.rPush(key, JSON.stringify(item)); + meta.currentPlayers += 1; } static async removeUserFromQueue( @@ -125,6 +129,7 @@ export class QueueService { const parsed = JSON.parse(item); if (parsed.steamId === steamId) { await redis.lRem(key, 1, item); + meta.currentPlayers -= 1; return true; } } @@ -169,6 +174,16 @@ export class QueueService { throw new Error('You do not have permission to remove other users from this queue.'); } + static async getCurrentQueuePlayerCount(slug: string): Promise { + const meta = await getQueueMetaOrThrow(slug); + return meta.currentPlayers; + } + + static async getCurrentQueueMaxCount(slug: string): Promise { + const meta = await getQueueMetaOrThrow(slug); + return meta.maxSize; + } + } async function getQueueMetaOrThrow(slug: string): Promise { diff --git a/src/types/queues/QueueDescriptor.ts b/src/types/queues/QueueDescriptor.ts index 173ecad0..69ca733e 100644 --- a/src/types/queues/QueueDescriptor.ts +++ b/src/types/queues/QueueDescriptor.ts @@ -3,6 +3,7 @@ export interface QueueDescriptor { createdAt: number; // Timestamp (ms) when queue was created expiresAt: number; // Timestamp (ms) when queue will expire ownerId?: string; // Optional user ID of the queue creator - maxSize?: number; // Optional max number of users allowed + maxSize: number; // Max number of players allowed in the queue isPrivate?: boolean; // Optional flag for visibility + currentPlayers: number; // Current number of players in the queue } \ No newline at end of file From cdd92e13501ec207181803af43358949dc1015cd Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Wed, 12 Nov 2025 10:42:22 -0600 Subject: [PATCH 5/7] Include tests for team creations. At max players create a team from the queue that is inserted into the database. --- __test__/queue.test.js | 83 +++++++++++++ jest_config/jest.queue.config.cjs | 16 +++ package.json | 3 +- src/routes/queue.ts | 10 +- src/services/queue.ts | 197 ++++++++++++++++++++++++++++++ 5 files changed, 306 insertions(+), 3 deletions(-) create mode 100644 __test__/queue.test.js create mode 100644 jest_config/jest.queue.config.cjs diff --git a/__test__/queue.test.js b/__test__/queue.test.js new file mode 100644 index 00000000..70176728 --- /dev/null +++ b/__test__/queue.test.js @@ -0,0 +1,83 @@ +import { agent } from 'supertest'; +import app from '../app.js'; +import { db } from '../src/services/db.js'; +import { QueueService } from '../src/services/queue.js'; +const request = agent(app); + +describe('Queue routes', () => { + beforeAll(() => { + // Authenticate mock steam user (mock strategy) + return request.get('/auth/steam/return').expect(302); + }); + + it('should create a queue and return URL', async () => { + const payload = [ { maxPlayers: 4, private: false } ]; + const res = await request + .post('/queue/') + .set('Content-Type', 'application/json') + .send(payload) + .expect(200); + + expect(res.body.url).toMatch(/\/queue\//); + // Save the slug for subsequent tests + const slug = res.body.url.split('/').pop(); + expect(slug).toBeDefined(); + // store on global for other tests + global.__TEST_QUEUE_SLUG = slug; + }); + + it('should add users to the queue and create teams when full', async () => { + const slug = global.__TEST_QUEUE_SLUG; + // Add 4 users; the first is the creator (already added) so add 3 more + // Using the mockProfile steam id and some fake ids for others + const extraUsers = ['76561198025644195','76561198025644196','76561198025644197']; + // Add users directly to the queue service to simulate distinct steam IDs (route uses req.user) + for (const id of extraUsers) { + await QueueService.addUserToQueue(slug, id); + } + + // Now trigger team creation from the service (would normally be called by the route once full) + const result = await QueueService.createTeamsFromQueue(slug); + expect(result).toBeDefined(); + expect(Array.isArray(result.teams)).toBe(true); + expect(result.teams.length).toBe(2); + + // Check DB for created teams; there should be at least 2 inserted with team_auth_names + const teams = await db.query('SELECT id FROM team WHERE name LIKE ?', [`team_%`]); + expect(teams.length).toBeGreaterThanOrEqual(2); + const teamId = teams[0].id; + const auths = await db.query('SELECT auth FROM team_auth_names WHERE team_id = ?', [teamId]); + expect(auths.length).toBeGreaterThan(0); + }); + + describe('rating normalization', () => { + test('uses median when some ratings are present and adds jitter for missing', () => { + const realRandom = Math.random; + Math.random = () => 0.5; + + const players = [ + { steamId: '1', timestamp: 1, hltvRating: 2 }, + { steamId: '2', timestamp: 2, hltvRating: 4 }, + { steamId: '3', timestamp: 3, hltvRating: undefined }, + ]; + const out = QueueService.normalizePlayerRatings(players); + expect(out.find(p => p.steamId === '3').hltvRating).toBeCloseTo(3); + + Math.random = realRandom; + }); + + test('all missing ratings fall back to 1.0 +/- jitter', () => { + const realRandom = Math.random; + Math.random = () => 0.25; + + const players = [ + { steamId: 'a', timestamp: 1, hltvRating: undefined }, + { steamId: 'b', timestamp: 2, hltvRating: undefined } + ]; + const out = QueueService.normalizePlayerRatings(players); + expect(out[0].hltvRating).toBeCloseTo(0.975); + + Math.random = realRandom; + }); + }); +}); diff --git a/jest_config/jest.queue.config.cjs b/jest_config/jest.queue.config.cjs new file mode 100644 index 00000000..f5a96b93 --- /dev/null +++ b/jest_config/jest.queue.config.cjs @@ -0,0 +1,16 @@ +process.env.NODE_ENV = "test"; +module.exports = { + preset: 'ts-jest/presets/js-with-ts-esm', + resolver: "jest-ts-webcompat-resolver", + clearMocks: true, + globalTeardown: "./test-teardown-globals.cjs", + testEnvironment: "node", + roots: [ + "../__test__" + ], + testMatch: [ + "**/__test__/queue.test.js", + "**/@(queue.)+(spec|test).[tj]s?(x)" + ], + verbose: false, +}; diff --git a/package.json b/package.json index c9442285..fb8760ba 100644 --- a/package.json +++ b/package.json @@ -51,11 +51,12 @@ "migrate-drop-prod": "MYSQL_FLAGS=\"-CONNECT_WITH_DB\" db-migrate --env production --config config/production.json db:drop get5", "migrate-drop-test": "MYSQL_FLAGS=\"-CONNECT_WITH_DB\" db-migrate --env test --config config/test.json db:drop get5test", "prod": "NODE_ENV=production yarn migrate-create-prod && yarn migrate-prod-upgrade", - "test": "yarn build && NODE_ENV=test && yarn test:setup-user && yarn migrate-drop-test && yarn migrate-create-test && yarn migrate-test-upgrade && yarn test:user && yarn test:gameservers && yarn test:teams && yarn test:matches && yarn test:seasons && yarn test:vetoes && yarn test:mapstats && yarn test:playerstats && yarn test:vetosides && yarn test:maplists", + "test": "yarn build && NODE_ENV=test && yarn test:setup-user && yarn migrate-drop-test && yarn migrate-create-test && yarn migrate-test-upgrade && yarn test:user && yarn test:gameservers && yarn test:teams && yarn test:matches && yarn test:seasons && yarn test:vetoes && yarn test:mapstats && yarn test:playerstats && yarn test:vetosides && yarn test:maplists && yarn test:queue", "test:gameservers": "yarn test:removeID && NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.gameservers.config.cjs", "test:mapstats": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.mapstats.config.cjs", "test:maplists": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.maplist.config.cjs", "test:matches": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.matches.config.cjs", + "test:queue": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.queue.config.cjs", "test:playerstats": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.playerstats.config.cjs", "test:removeID": "sed -i -e 's.\"steam_ids\": \"[0-9][0-9]*\".\"steam_ids\": \"super_admins,go,here\".g' ./config/test.json", "test:seasons": "NODE_OPTIONS=--experimental-vm-modules jest --testTimeout=10000 --detectOpenHandles --config ./jest_config/jest.seasons.config.cjs", diff --git a/src/routes/queue.ts b/src/routes/queue.ts index 5516a9b4..a006e2f7 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -334,8 +334,14 @@ router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => { if (action === 'join') { await QueueService.addUserToQueue(slug, req.user?.steam_id!); if (currentQueueCount == maxQueueCount) { - // TODO: Add in logic to create teams, or create a new queue with users' steam IDs and queue ID - // to get ready to shuffle teams. + // Queue is full — create teams and persist them. + try { + const result = await QueueService.createTeamsFromQueue(slug); + return res.status(200).json({ success: true, teams: result.teams }); + } catch (err) { + console.error('Error creating teams from queue:', err); + // Fall through to return success=true but without teams + } } } else if (action === 'leave') { await QueueService.removeUserFromQueue(slug, req.user?.steam_id!, req.user?.steam_id!); diff --git a/src/services/queue.ts b/src/services/queue.ts index d07b4a97..7035f099 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -3,6 +3,7 @@ import Utils from "../utility/utils.js"; import { QueueDescriptor } from "../types/queues/QueueDescriptor.js" import { QueueItem } from "../types/queues/QueueItem.js"; import { createClient } from "redis"; +import { db } from "./db.js"; const redis = createClient({ url: config.get("server.redisUrl"), }); const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL"); @@ -184,6 +185,202 @@ export class QueueService { return meta.maxSize; } + // Normalize player ratings helper + static normalizePlayerRatings(players: QueueItem[]): QueueItem[] { + const knownRatings = players + .map((p) => p.hltvRating) + .filter((r) => typeof r === 'number') as number[]; + let fallbackRating = 1.0; + if (knownRatings.length > 0) { + knownRatings.sort((a, b) => a - b); + const mid = Math.floor(knownRatings.length / 2); + fallbackRating = knownRatings.length % 2 === 0 + ? (knownRatings[mid - 1] + knownRatings[mid]) / 2 + : knownRatings[mid]; + } + + return players.map((p) => { + if (typeof p.hltvRating === 'number') return { ...p, hltvRating: p.hltvRating }; + const jitter = (Math.random() - 0.5) * 0.1 * fallbackRating; + return { ...p, hltvRating: fallbackRating + jitter }; + }); + } + + /** + * Create two teams from the queue for the given slug. + * - Uses the first `maxSize` players in the queue + * - Attempts to balance teams by `hltvRating` while keeping randomness + * - Stores result in `queue-teams:` and removes selected players from the queue + * - Team name is `team_` where CAPTAIN is the first member's steamId + */ + static async createTeamsFromQueue(slug: string): Promise<{ teams: { name: string; members: QueueItem[] }[] }> { + const key = `queue:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + // Ensure redis connected + if (redis.isOpen === false) { + await redis.connect(); + } + + const rawItems = await redis.lRange(key, 0, -1); + if (!rawItems || rawItems.length === 0) { + throw new Error(`Queue ${slug} is empty.`); + } + + const maxPlayers = meta.maxSize || rawItems.length; + + if (rawItems.length < maxPlayers) { + throw new Error(`Not enough players in queue to form teams. Have ${rawItems.length}, need ${maxPlayers}.`); + } + + // Take the first N entries (FIFO semantics) + const selectedRaw = rawItems.slice(0, maxPlayers); + const players: QueueItem[] = selectedRaw.map((r) => JSON.parse(r)); + + // Compute a robust fallback for missing ratings: use median of known ratings + const knownRatings = players + .map((p) => p.hltvRating) + .filter((r) => typeof r === 'number') as number[]; + let fallbackRating = 1.0; + if (knownRatings.length > 0) { + knownRatings.sort((a, b) => a - b); + const mid = Math.floor(knownRatings.length / 2); + fallbackRating = knownRatings.length % 2 === 0 + ? (knownRatings[mid - 1] + knownRatings[mid]) / 2 + : knownRatings[mid]; + } + + // Normalize ratings so every player has a numeric rating using helper + const normPlayers = QueueService.normalizePlayerRatings(players); + + // Sort players by rating descending (strongest first) + normPlayers.sort((a: QueueItem, b: QueueItem) => (b.hltvRating! - a.hltvRating!)); + + // Greedy assignment with small randomness to avoid deterministic splits + const teamA: QueueItem[] = []; + const teamB: QueueItem[] = []; + let sumA = 0; + let sumB = 0; + const flipProb = 0.10; // 10% chance to flip assignment to add randomness + + const targetSizeA = Math.ceil(maxPlayers / 2); + const targetSizeB = Math.floor(maxPlayers / 2); + + for (const p of normPlayers) { + // If one team is already full, push to the other + if (teamA.length >= targetSizeA) { + teamB.push(p); + sumB += p.hltvRating!; + continue; + } + if (teamB.length >= targetSizeB) { + teamA.push(p); + sumA += p.hltvRating!; + continue; + } + + // Normally assign to the team with smaller total rating + let assignToA = sumA <= sumB; + + // small random flip + if (Math.random() < flipProb) assignToA = !assignToA; + + if (assignToA) { + teamA.push(p); + sumA += p.hltvRating!; + } else { + teamB.push(p); + sumB += p.hltvRating!; + } + } + + // Final size-adjustment (move lowest-rated if needed) + while (teamA.length > targetSizeA) { + // move lowest-rated from A to B + teamA.sort((a, b) => a.hltvRating! - b.hltvRating!); + const moved = teamA.shift()!; + sumA -= moved.hltvRating!; + teamB.push(moved); + sumB += moved.hltvRating!; + } + while (teamB.length > targetSizeB) { + teamB.sort((a, b) => a.hltvRating! - b.hltvRating!); + const moved = teamB.shift()!; + sumB -= moved.hltvRating!; + teamA.push(moved); + sumA += moved.hltvRating!; + } + + // Captain is first user in each team array + const captainA = teamA[0]; + const captainB = teamB[0]; + + const teams = [ + { name: `team_${captainA?.steamId ?? 'A'}`, members: teamA }, + { name: `team_${captainB?.steamId ?? 'B'}`, members: teamB }, + ]; + + // Persist teams to database (team + team_auth_names) + // Resolve queue owner to internal user_id if present + let ownerUserId: number | null = 0; + try { + if (meta.ownerId) { + const ownerRows = await db.query('SELECT id FROM user WHERE steam_id = ?', [meta.ownerId]); + if (ownerRows && ownerRows.length > 0 && ownerRows[0].id) { + ownerUserId = ownerRows[0].id; + } + } + } catch (err) { + // fallback to 0 (system) if DB lookup fails + ownerUserId = 0; + } + + for (const t of teams) { + const teamInsert = await db.query("INSERT INTO team (user_id, name, flag, logo, tag, public_team) VALUES ?", [[[ + ownerUserId || 0, + t.name, + null, + null, + null, + 0 + ]]]); + // @ts-ignore insertId from RowDataPacket + const insertedTeamId = (teamInsert as any).insertId || null; + if (insertedTeamId) { + // prepare team_auth_names bulk insert + const authRows: Array> = []; + for (let i = 0; i < t.members.length; i++) { + const member = t.members[i]; + const isCaptain = i === 0 ? 1 : 0; + authRows.push([insertedTeamId, member.steamId, '', isCaptain, 0]); + } + if (authRows.length > 0) { + await db.query("INSERT INTO team_auth_names (team_id, auth, name, captain, coach) VALUES ?", [authRows]); + } + } + } + + // Store teams in Redis and remove selected players from queue + const teamsKey = `queue-teams:${slug}`; + // TTL based on remaining queue meta TTL + const remainingSeconds = Math.max(1, Math.floor((meta.expiresAt - Date.now()) / 1000)); + + await redis.set(teamsKey, JSON.stringify({ teams }), { EX: remainingSeconds }); + + // Remove selected players from queue list and update meta + for (const raw of selectedRaw) { + // remove one occurrence + await redis.lRem(key, 1, raw); + meta.currentPlayers -= 1; + } + + // Persist updated meta and expire + await redis.set(`queue-meta:${slug}`, JSON.stringify(meta), { EX: remainingSeconds }); + await redis.expire(key, remainingSeconds); + + return { teams }; + } + } async function getQueueMetaOrThrow(slug: string): Promise { From d9be73150f739fadb7232b50e272fd244a9aa787 Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:18:06 -0600 Subject: [PATCH 6/7] Add in logic for creating matches when the user queue pops. Roughed in some general logic for game server acquisition as well, have yet to test so will leave commented out. --- src/routes/queue.ts | 23 +-- src/services/queue.ts | 256 ++++++++++++++++++++++++++++------ src/types/queues/QueueItem.ts | 1 + 3 files changed, 225 insertions(+), 55 deletions(-) diff --git a/src/routes/queue.ts b/src/routes/queue.ts index a006e2f7..11f7101a 100644 --- a/src/routes/queue.ts +++ b/src/routes/queue.ts @@ -194,16 +194,12 @@ router.get('/:slug', async (req, res) => { router.get('/:slug/players', Utils.ensureAuthenticated, async (req, res) => { const slug: string = req.params.slug; const requestorSteamId = req.user?.steam_id; - let role: string = 'user'; if (!requestorSteamId) { return res.status(401).json({ error: 'Unauthorized: Steam ID missing.' }); } - - if (req.user?.admin) role = 'admin'; - else if (req.user?.super_admin) role = 'super_admin'; try { - const users = await QueueService.listUsersInQueue(slug, role, requestorSteamId); + const users = await QueueService.listUsersInQueue(slug); res.status(200).json(users); } catch (error) { console.error('Error listing users in queue:', error); @@ -256,7 +252,7 @@ router.post('/', Utils.ensureAuthenticated, async (req, res) => { const isPrivate: boolean = req.body[0].private ? true : false; try { - const descriptor = await QueueService.createQueue(req.user?.steam_id!, maxPlayers, isPrivate); + const descriptor = await QueueService.createQueue(req.user?.steam_id!, req.user?.name!, maxPlayers, isPrivate); res.json({ message: "Queue created successfully!", url: `${config.get("server.apiURL")}/queue/${descriptor.name}` }); } catch (error) { console.error('Error creating queue:', error); @@ -326,20 +322,25 @@ router.post('/', Utils.ensureAuthenticated, async (req, res) => { */ router.put('/:slug', Utils.ensureAuthenticated, async (req, res) => { const slug: string = req.params.slug; - const action: string = req.body[0].action ? req.body[0].action : 'join'; + const action: string = req.body[0]?.action ? req.body[0].action : 'join'; try { let currentQueueCount: number = await QueueService.getCurrentQueuePlayerCount(slug); let maxQueueCount: number = await QueueService.getCurrentQueueMaxCount(slug); if (action === 'join') { - await QueueService.addUserToQueue(slug, req.user?.steam_id!); + await QueueService.addUserToQueue(slug, req.user?.steam_id!, req.user?.name!); + currentQueueCount++; if (currentQueueCount == maxQueueCount) { // Queue is full — create teams and persist them. + // Create match from queue try { - const result = await QueueService.createTeamsFromQueue(slug); - return res.status(200).json({ success: true, teams: result.teams }); + const teamIds = await QueueService.createTeamsFromQueue(slug); + console.log('Created teams from full queue:', teamIds); + const matchId = await QueueService.createMatchFromQueue(slug, teamIds); + return res.status(200).json({ success: true, matchId: matchId, message: 'Match created successfully from full queue.' }); } catch (err) { - console.error('Error creating teams from queue:', err); + console.error('Error creating teams or match from queue:', err); + res.status(500).json({ error: `Failed to create teams or match from queue.` }); // Fall through to return success=true but without teams } } diff --git a/src/services/queue.ts b/src/services/queue.ts index 7035f099..9df37aaa 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -1,16 +1,20 @@ import config from "config"; +import { RowDataPacket } from "mysql2"; import Utils from "../utility/utils.js"; import { QueueDescriptor } from "../types/queues/QueueDescriptor.js" import { QueueItem } from "../types/queues/QueueItem.js"; import { createClient } from "redis"; import { db } from "./db.js"; +import GameServer from "../utility/serverrcon.js"; +import GlobalEmitter from "../utility/emitter.js"; +import { generate } from "randomstring"; const redis = createClient({ url: config.get("server.redisUrl"), }); const DEFAULT_TTL_SECONDS: number = config.get("server.queueTTL") == 0 ? 3600 : config.get("server.queueTTL"); export class QueueService { - static async createQueue(ownerId: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { + static async createQueue(ownerId: string, nickname: string, maxPlayers: number = 10, isPrivate: boolean = false, ttlSeconds: number = DEFAULT_TTL_SECONDS): Promise { let slug: string; let key: string; let attempts: number = 0; @@ -46,11 +50,180 @@ export class QueueService { await redis.expire(key, ttlSeconds); await redis.set(`queue-meta:${slug}`, JSON.stringify(descriptor), { EX: ttlSeconds }); - await this.addUserToQueue(slug, ownerId); + await this.addUserToQueue(slug, ownerId, nickname); return descriptor; } + /** + * Create a match record for a queue after teams have been created. + * - Picks an available server (owned by user or public) and marks it in_use + * - Uses the owner's `map_list` if present, otherwise falls back to default CS2 pool + */ + static async createMatchFromQueue( + slug: string, + teamIds: number[] + ): Promise { + const key = `queue:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + // Generate API key for the match (used when preparing the server) + const apiKey = generate({ length: 24, capitalization: "uppercase" }); + + // Default CS2 map pool + const defaultCs2Maps = [ + 'de_inferno', + 'de_ancient', + 'de_mirage', + 'de_nuke', + 'de_anubis', + 'de_dust2', + 'de_vertigo' + ]; + + // Try to load user's map_list if available + let mapPool: string[] = []; + let ownerUserId: number | null = await getUserIdFromMetaSlug(slug); + try { + if (ownerUserId && ownerUserId > 0) { + const rows: RowDataPacket[] = await db.query("SELECT map_name FROM map_list WHERE user_id = ? ORDER BY id", [ownerUserId]); + if (!rows.length) { + mapPool = rows.map((r: any) => r.map_name).filter(Boolean); + } + } + } catch (err) { + mapPool = []; + } + console.log("Map pool for user", ownerUserId, ":", mapPool); + if (!mapPool || mapPool.length === 0) mapPool = defaultCs2Maps; + + // Build base match object + const baseMatch: any = { + user_id: ownerUserId || 0, + team1_id: teamIds[0] || null, + team2_id: teamIds[1] || null, + start_time: new Date(), + max_maps: 1, + title: `[PUG] ${slug}`, + skip_veto: 0, + veto_mappool: mapPool.join(' '), + private_match: meta.isPrivate ? 1 : 0, + enforce_teams: 1, + is_pug: 1, + api_key: apiKey, + min_player_ready: meta.maxSize/2 + }; + + // Fetch candidate servers (include connection info) + /*let candidates: RowDataPacket[] = []; + try { + if (ownerUserId && ownerUserId > 0) { + candidates = await db.query( + "SELECT id, ip_string, port, rcon_password FROM game_server WHERE (public_server=1 OR user_id = ?) AND in_use=0", + [ownerUserId] + ); + } else { + candidates = await db.query( + "SELECT id, ip_string, port, rcon_password FROM game_server WHERE public_server=1 AND in_use=0" + ); + } + } catch (err) { + candidates = []; + } + console.log(`Found ${candidates.length} candidates servers for match from queue ${slug}.`); + + // Try available game servers (disabled for now). If one found then prepare match on it. + for (const cand of candidates) { + try { + const newServer: GameServer = new GameServer(cand.ip_string, cand.port, cand.rcon_password); + + // Check basic server readiness before any DB insert + const alive = await newServer.isServerAlive(); + const get5av = await newServer.isGet5Available().catch(() => false); + if (!alive || !get5av) { + // server not suitable, try next candidate + (GlobalEmitter as any).emit('match:creating', { matchId, serverId: cand.id, teams: teamIds, slug, message: 'Server not alive or not available' }); + continue; + } + + // Server looks usable — insert the match and then attempt to prepare it + const insertSet = await db.buildUpdateStatement({ ...baseMatch, server_id: cand.id }) as any; + const insertRes: any = await db.query("INSERT INTO `match` SET ?", [insertSet]); + const matchId = (insertRes as any).insertId; + + // mark server in use + await db.query("UPDATE game_server SET in_use = 1 WHERE id = ?", [cand.id]); + + // update plugin version on match if we can + try { + const get5Version: string = await newServer.getGet5Version(); + await db.query("UPDATE `match` SET plugin_version = ? WHERE id = ?", [get5Version, matchId]); + } catch (err) { + // ignore version retrieval errors + } + + // attempt to prepare match on server + try { + const prepared = await newServer.prepareGet5Match( + config.get("server.apiURL") + "/matches/" + matchId + "/config", + apiKey + ); + + if (!prepared) { + // cleanup match and free server + await db.query("DELETE FROM match_spectator WHERE match_id = ?", [matchId]); + await db.query("DELETE FROM match_cvar WHERE match_id = ?", [matchId]); + await db.query("DELETE FROM `match` WHERE id = ?", [matchId]); + await db.query("UPDATE game_server SET in_use = 0 WHERE id = ?", [cand.id]); + continue; // try next candidate + } + + // success: emit event and return + (GlobalEmitter as any).emit('match:created', { matchId, serverId: cand.id, teams: teamIds, slug }); + return matchId; + } catch (errPrepare) { + // prepare failed — cleanup and continue + try { + await db.query("DELETE FROM match_spectator WHERE match_id = ?", [matchId]); + await db.query("DELETE FROM match_cvar WHERE match_id = ?", [matchId]); + await db.query("DELETE FROM `match` WHERE id = ?", [matchId]); + await db.query("UPDATE game_server SET in_use = 0 WHERE id = ?", [cand.id]); + } catch (cleanupErr) { + // ignore cleanup errors + } + continue; + } + } catch (err: any) { + // On any error, try to cleanup and continue + try { + if (err && err.insertId) { + const mid = err.insertId; + await db.query("DELETE FROM match_spectator WHERE match_id = ?", [mid]); + await db.query("DELETE FROM match_cvar WHERE match_id = ?", [mid]); + await db.query("DELETE FROM `match` WHERE id = ?", [mid]); + } + } catch (e) { + // ignore cleanup errors + } + continue; + } + }*/ + + // Remove the queue from Redis, including global queue. + await this.deleteQueue(slug, meta.ownerId!); + + // No game server found, create match without server. + try { + const insertSet = await db.buildUpdateStatement({ ...baseMatch, server_id: null }) as any; + const insertRes: any = await db.query("INSERT INTO `match` SET ?", [insertSet]); + const matchId = (insertRes as any).insertId; + (GlobalEmitter as any).emit('match:created', { matchId, serverId: null, teams: teamIds, slug }); + return matchId; + } catch (err) { + console.error("createMatchFromQueue final insert failed:", err); + return null; + } + } + static async deleteQueue( slug: string, requestorSteamId: string, @@ -77,6 +250,7 @@ export class QueueService { static async addUserToQueue( slug: string, steamId: string, + name: string ): Promise { const key = `queue:${slug}`; const meta = await getQueueMetaOrThrow(slug); @@ -100,11 +274,12 @@ export class QueueService { const item: QueueItem = { steamId, timestamp: Date.now(), - hltvRating: hltvRating ?? undefined + hltvRating: hltvRating ?? undefined, + nickname: name }; - - await redis.rPush(key, JSON.stringify(item)); meta.currentPlayers += 1; + await redis.rPush(key, JSON.stringify(item)); + } static async removeUserFromQueue( @@ -138,9 +313,10 @@ export class QueueService { return false; } - static async listUsersInQueue(slug: string, role: string = "user", requestorSteamId: string): Promise { + static async listUsersInQueue(slug: string): Promise { const key = `queue:${slug}`; - const meta = await getQueueMetaOrThrow(slug); + // Use this to throw an error if something happens + await getQueueMetaOrThrow(slug); const rawItems = await redis.lRange(key, 0, -1); return rawItems.map((item: string) => JSON.parse(item)); @@ -210,10 +386,9 @@ export class QueueService { * Create two teams from the queue for the given slug. * - Uses the first `maxSize` players in the queue * - Attempts to balance teams by `hltvRating` while keeping randomness - * - Stores result in `queue-teams:` and removes selected players from the queue * - Team name is `team_` where CAPTAIN is the first member's steamId */ - static async createTeamsFromQueue(slug: string): Promise<{ teams: { name: string; members: QueueItem[] }[] }> { + static async createTeamsFromQueue(slug: string): Promise { const key = `queue:${slug}`; const meta = await getQueueMetaOrThrow(slug); @@ -235,7 +410,7 @@ export class QueueService { // Take the first N entries (FIFO semantics) const selectedRaw = rawItems.slice(0, maxPlayers); - const players: QueueItem[] = selectedRaw.map((r) => JSON.parse(r)); + const players: QueueItem[] = selectedRaw.map((r: string) => JSON.parse(r) as QueueItem); // Compute a robust fallback for missing ratings: use median of known ratings const knownRatings = players @@ -316,28 +491,19 @@ export class QueueService { const captainB = teamB[0]; const teams = [ - { name: `team_${captainA?.steamId ?? 'A'}`, members: teamA }, - { name: `team_${captainB?.steamId ?? 'B'}`, members: teamB }, + { name: `team_${captainA?.nickname ?? 'A'}`, members: teamA }, + { name: `team_${captainB?.nickname ?? 'B'}`, members: teamB }, ]; // Persist teams to database (team + team_auth_names) // Resolve queue owner to internal user_id if present - let ownerUserId: number | null = 0; - try { - if (meta.ownerId) { - const ownerRows = await db.query('SELECT id FROM user WHERE steam_id = ?', [meta.ownerId]); - if (ownerRows && ownerRows.length > 0 && ownerRows[0].id) { - ownerUserId = ownerRows[0].id; - } - } - } catch (err) { - // fallback to 0 (system) if DB lookup fails - ownerUserId = 0; - } - + let ownerUserId: number | null = await getUserIdFromMetaSlug(slug); + + console.log('ownerUserId:', ownerUserId); + const teamIds: number[] = []; for (const t of teams) { const teamInsert = await db.query("INSERT INTO team (user_id, name, flag, logo, tag, public_team) VALUES ?", [[[ - ownerUserId || 0, + ownerUserId, t.name, null, null, @@ -347,6 +513,7 @@ export class QueueService { // @ts-ignore insertId from RowDataPacket const insertedTeamId = (teamInsert as any).insertId || null; if (insertedTeamId) { + teamIds.push(insertedTeamId); // prepare team_auth_names bulk insert const authRows: Array> = []; for (let i = 0; i < t.members.length; i++) { @@ -358,29 +525,30 @@ export class QueueService { await db.query("INSERT INTO team_auth_names (team_id, auth, name, captain, coach) VALUES ?", [authRows]); } } - } + } - // Store teams in Redis and remove selected players from queue - const teamsKey = `queue-teams:${slug}`; - // TTL based on remaining queue meta TTL - const remainingSeconds = Math.max(1, Math.floor((meta.expiresAt - Date.now()) / 1000)); + return teamIds; + } - await redis.set(teamsKey, JSON.stringify({ teams }), { EX: remainingSeconds }); +} - // Remove selected players from queue list and update meta - for (const raw of selectedRaw) { - // remove one occurrence - await redis.lRem(key, 1, raw); - meta.currentPlayers -= 1; +async function getUserIdFromMetaSlug(slug: string): Promise { + const meta = await getQueueMetaOrThrow(slug); + let ownerUserId: number | null = 0; + if (!meta.ownerId) return null; + + try { + if (meta.ownerId) { + const ownerRows = await db.query('SELECT id FROM user WHERE steam_id = ?', [meta.ownerId]); + if (ownerRows.length) { + ownerUserId = ownerRows[0].id; + } } - - // Persist updated meta and expire - await redis.set(`queue-meta:${slug}`, JSON.stringify(meta), { EX: remainingSeconds }); - await redis.expire(key, remainingSeconds); - - return { teams }; + } catch (err) { + // fallback to 0 (system) if DB lookup fails + ownerUserId = 0; } - + return ownerUserId; } async function getQueueMetaOrThrow(slug: string): Promise { diff --git a/src/types/queues/QueueItem.ts b/src/types/queues/QueueItem.ts index cf01e5b2..5b685505 100644 --- a/src/types/queues/QueueItem.ts +++ b/src/types/queues/QueueItem.ts @@ -2,4 +2,5 @@ export interface QueueItem { steamId: string; timestamp: number; hltvRating?: number; + nickname?: string; } \ No newline at end of file From a8fe44f079981f19093204959fb06b445e1f9c5d Mon Sep 17 00:00:00 2001 From: Phlex <3514085+PhlexPlexico@users.noreply.github.com> Date: Thu, 20 Nov 2025 07:13:52 -0600 Subject: [PATCH 7/7] Update tests and change conditional in map pool --- __test__/queue.test.js | 4 ++-- src/services/queue.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/__test__/queue.test.js b/__test__/queue.test.js index 70176728..bfe8c396 100644 --- a/__test__/queue.test.js +++ b/__test__/queue.test.js @@ -39,8 +39,8 @@ describe('Queue routes', () => { // Now trigger team creation from the service (would normally be called by the route once full) const result = await QueueService.createTeamsFromQueue(slug); expect(result).toBeDefined(); - expect(Array.isArray(result.teams)).toBe(true); - expect(result.teams.length).toBe(2); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBe(2); // Check DB for created teams; there should be at least 2 inserted with team_auth_names const teams = await db.query('SELECT id FROM team WHERE name LIKE ?', [`team_%`]); diff --git a/src/services/queue.ts b/src/services/queue.ts index 9df37aaa..48fe3b22 100644 --- a/src/services/queue.ts +++ b/src/services/queue.ts @@ -64,7 +64,6 @@ export class QueueService { slug: string, teamIds: number[] ): Promise { - const key = `queue:${slug}`; const meta = await getQueueMetaOrThrow(slug); // Generate API key for the match (used when preparing the server) const apiKey = generate({ length: 24, capitalization: "uppercase" }); @@ -86,7 +85,7 @@ export class QueueService { try { if (ownerUserId && ownerUserId > 0) { const rows: RowDataPacket[] = await db.query("SELECT map_name FROM map_list WHERE user_id = ? ORDER BY id", [ownerUserId]); - if (!rows.length) { + if (rows.length) { mapPool = rows.map((r: any) => r.map_name).filter(Boolean); } } @@ -499,7 +498,6 @@ export class QueueService { // Resolve queue owner to internal user_id if present let ownerUserId: number | null = await getUserIdFromMetaSlug(slug); - console.log('ownerUserId:', ownerUserId); const teamIds: number[] = []; for (const t of teams) { const teamInsert = await db.query("INSERT INTO team (user_id, name, flag, logo, tag, public_team) VALUES ?", [[[ @@ -526,7 +524,6 @@ export class QueueService { } } } - return teamIds; }