diff --git a/__test__/queue.test.js b/__test__/queue.test.js new file mode 100644 index 00000000..bfe8c396 --- /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)).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_%`]); + 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/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/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/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 new file mode 100644 index 00000000..11f7101a --- /dev/null +++ b/src/routes/queue.ts @@ -0,0 +1,431 @@ +/** + * @swagger + * 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"; + +const router = Router(); + + +/** + * @swagger + * + * components: + * schemas: + * QueueDescriptor: + * type: object + * properties: + * name: + * 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('does not exist')) { + 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; + if (!requestorSteamId) { + return res.status(401).json({ error: 'Unauthorized: Steam ID missing.' }); + } + + try { + const users = await QueueService.listUsersInQueue(slug); + 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 { + 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); + 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[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!, req.user?.name!); + currentQueueCount++; + if (currentQueueCount == maxQueueCount) { + // Queue is full — create teams and persist them. + // Create match from queue + try { + 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 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 + } + } + } 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".' }); + } + + 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 new file mode 100644 index 00000000..48fe3b22 --- /dev/null +++ b/src/services/queue.ts @@ -0,0 +1,569 @@ +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, nickname: 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}`; + 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, + createdAt, + expiresAt, + ownerId, + maxSize: maxPlayers, + 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, 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 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, + role: string = "user" + ): Promise { + const key = `queue:${slug}`; + const metaKey = `queue-meta:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + // Permission check + const isOwner = meta.ownerId === requestorSteamId; + 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 + } + + static async addUserToQueue( + slug: string, + steamId: string, + name: string + ): Promise { + const key = `queue:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + + 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, + nickname: name + }; + meta.currentPlayers += 1; + await redis.rPush(key, JSON.stringify(item)); + + } + + static async removeUserFromQueue( + slug: string, + steamId: string, + requestorSteamId: string, + role: string = "user" + ): Promise { + const key = `queue:${slug}`; + const meta = await getQueueMetaOrThrow(slug); + + // Permission check + if ( + role === 'user' && + steamId !== requestorSteamId && + meta.ownerId !== requestorSteamId + ) { + 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); + meta.currentPlayers -= 1; + return true; + } + } + + return false; + } + + static async listUsersInQueue(slug: string): Promise { + const key = `queue:${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)); + } + + 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[] = []; + + 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 === requestorSteamId || meta.isPrivate === false) { + descriptors.push(meta); + } + } + + 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.'); + } + + 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; + } + + // 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 + * - Team name is `team_` where CAPTAIN is the first member's steamId + */ + static async createTeamsFromQueue(slug: string): Promise { + 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: string) => JSON.parse(r) as QueueItem); + + // 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?.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 = await getUserIdFromMetaSlug(slug); + + 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, + t.name, + null, + null, + null, + 0 + ]]]); + // @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++) { + 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]); + } + } + } + return teamIds; + } + +} + +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; + } + } + } catch (err) { + // fallback to 0 (system) if DB lookup fails + ownerUserId = 0; + } + return ownerUserId; +} + +async function getQueueMetaOrThrow(slug: string): Promise { + if (redis.isOpen === false) { + await redis.connect(); + } + const metaKey = `queue-meta:${slug}`; + const members = await redis.sMembers('queues'); + if (!members.includes(slug)) { + 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..69ca733e --- /dev/null +++ b/src/types/queues/QueueDescriptor.ts @@ -0,0 +1,9 @@ +export interface QueueDescriptor { + name: string; // Human-readable name + 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; // 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 diff --git a/src/types/queues/QueueItem.ts b/src/types/queues/QueueItem.ts new file mode 100644 index 00000000..5b685505 --- /dev/null +++ b/src/types/queues/QueueItem.ts @@ -0,0 +1,6 @@ +export interface QueueItem { + steamId: string; + timestamp: number; + hltvRating?: number; + nickname?: string; +} \ 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"