import express, { Request, Response } from 'express'; import ReadSettings from './settings'; import { GetRandomPort, OpenGameInstance } from './helpers'; import { Settings, Player, Room, QueueEntry } from './types'; import { authenticateMatchmakingRequest, registerAuthRoutes } from './auth_bridge'; import { getPlayerLast10MatchRecord, insertMatchForNewRoom, registerMatchRoutes, updateMatchUserBlue, } from './matches'; const version = "v1.0"; console.log("Starting Cupid Matchmaker for unity " + version); const app = express(); // Only parse JSON for /auth — avoids parse errors on other routes; clients must send strict JSON (double-quoted keys). app.use('/auth', express.json()); app.use((req, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); if (req.method === 'OPTIONS') { res.sendStatus(204); return; } next(); }); const queueGraceTime = 2000; // Read settings const settings = ReadSettings(); if (settings == null) { console.error("Failed to read settings"); process.exit(1); } // Type assertion since we've already checked for null const typedSettings = settings as Settings; const logLevel = typedSettings.log_level; const configuredGame = typedSettings.games?.[0]; if (!configuredGame?.exe) { console.error("settings.json must include at least one game entry with exe"); process.exit(1); } const gameName = configuredGame.name || "game"; LogVerbose(typedSettings); registerAuthRoutes(app, typedSettings); registerMatchRoutes(app, typedSettings); let Rooms: Room[] = []; let Queue: QueueEntry[] = []; app.get('/settings', (req: Request, res: Response) => { if (req.query.password !== typedSettings.password) { res.send("403 Unauthorized"); return; } const m_settings = { minimum_players: typedSettings.minimum_players, maximum_players: typedSettings.maximum_players, waiting_time: typedSettings.waiting_time, port_range_min: typedSettings.port_range_min, port_range_max: typedSettings.port_range_max, games: typedSettings.games }; console.log(m_settings); res.send(m_settings); }); app.get('/', async (req: Request, res: Response) => { const auth = await authenticateMatchmakingRequest(req, typedSettings); if (!auth.ok) { res.status(auth.status).send(auth.message); return; } const { userId, username } = auth.user; try { // Check query let joinedRoom: Room | null = null; let alreadyOnQueue = false; Queue.forEach(element => { if (element.UserId === userId) { element.LastSeen = Date.now(); alreadyOnQueue = true; } else { if (Date.now() - element.LastSeen > queueGraceTime) { const index = Queue.indexOf(element); if (index > -1) { Queue.splice(index, 1); } Log(`Player ${element.Name} is afk, removing.`); } } }); // Check on rooms if not in the queue, Maybe they already joined a room let possibleRoom: Room | null = null; Rooms.forEach(element => { let roomValid = true; if (Date.now() - element.InitTime > typedSettings.waiting_time) { // This room is expired LogDebug("This room is expired, Removing now"); LogDebug(element); const index = Rooms.indexOf(element); if (index > -1) { Rooms.splice(index, 1); } roomValid = false; } if (roomValid) { element.Players.forEach(player => { if (player.UserId === userId) { joinedRoom = element; } }); if (element.Players.length === 0) { // This room is empty, remove it const index = Rooms.indexOf(element); if (index > -1) { Rooms.splice(index, 1); } } else if (element.Players.length < typedSettings.maximum_players && element.GameName === gameName) { possibleRoom = element; } } }); if (joinedRoom !== null) { // Already in a room. Stopping here res.send(roomResponseForViewer(joinedRoom, userId)); return; } // Neither in a room nor in queue, Let's see if (possibleRoom === null) { if (Queue.length >= typedSettings.minimum_players) { const newPort = GetRandomPort(typedSettings.port_range_min, typedSettings.port_range_max); const l10 = await getPlayerLast10MatchRecord(typedSettings, userId); const newRoom: Room = { Players: [{ Name: username, LastSeen: Date.now(), UserId: userId, ...l10 }], GameName: gameName, Port: newPort, InitTime: Date.now() }; const matchId = await insertMatchForNewRoom(typedSettings, userId); if (matchId != null) { newRoom.match_id = matchId; } Rooms.push(newRoom); OpenGameInstance(configuredGame.exe, newPort, matchId ?? undefined); res.send(roomResponseForViewer(newRoom, userId)); return; } } else { // We know possibleRoom is a Room type here since we checked it's not null const room: Room = possibleRoom; const l10 = await getPlayerLast10MatchRecord(typedSettings, userId); room.Players.push({ Name: username, LastSeen: Date.now(), UserId: userId, ...l10 }); if (room.match_id != null) { await updateMatchUserBlue(typedSettings, room.match_id, userId); } res.send(roomResponseForViewer(room, userId)); return; } // Not even got a new room. Back to queue if (!alreadyOnQueue) { const newQueueEntry: QueueEntry = { Name: username, LastSeen: Date.now(), UserId: userId }; LogVerbose(newQueueEntry); Queue.push(newQueueEntry); } LogVerbose("Rooms"); Rooms.forEach(element => { LogVerbose(element); }); LogVerbose("Queue"); LogVerbose(Queue); res.send("0"); } catch (err) { console.error("Matchmaking error:", err); res.status(500).send("Internal error"); } }); app.get('/cancel', async (req: Request, res: Response) => { const auth = await authenticateMatchmakingRequest(req, typedSettings); if (!auth.ok) { res.status(auth.status).send(auth.message); return; } const { userId } = auth.user; let foundUser = false; Queue.forEach((element) => { if (element.UserId === userId) { const index = Queue.indexOf(element); if (index > -1) { Queue.splice(index, 1); } res.send("1"); foundUser = true; return; } }); if (!foundUser) { res.send("Couldn't find user in the queue"); } }); /** GET / JSON only: same order as match DB — first joined = red, second = blue. */ function roomResponseForViewer(room: Room, viewerUserId: number): Room { const idx = room.Players.findIndex((p) => p.UserId === viewerUserId); const your_team: 'red' | 'blue' | null = idx === 0 ? 'red' : idx === 1 ? 'blue' : null; return { ...room, your_team }; } function Log(msg: any): void { console.log(msg); } function LogDebug(msg: any): void { if (logLevel > 0) { console.log(msg); } } function LogVerbose(msg: any): void { if (logLevel > 1) { console.log(msg); } } app.use((err: unknown, _req: Request, res: Response, next: express.NextFunction) => { const e = err as { status?: number; type?: string }; if (e.status === 400 && e.type === 'entity.parse.failed') { res.status(400).json({ ok: false, error: 'Invalid JSON body. Use strict JSON with double-quoted keys, e.g. {"username":"player1","password":"secret12"}', }); return; } next(err); }); app.listen(typedSettings.port); console.log("Listening on port " + typedSettings.port);