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, insertRematchMatch, loadUsernamesForUserPair, computeRcPrizeFromEntryFee, getBetFeePercentFromSettings, getDefaultMatchEntryFeeRc, getSettingsTableDict, MATCH_ENTRY_FEE_RC, registerMatchRoutes, updateMatchUserBlue, type CreateRematchRoomFn, type MatchRoomSuccessEnvelope, } from './matches'; import { initServerLog, logInfo, logDebug, logVerbose, recordHistory, readHistoryTail, readLogTail, } from './server_log'; 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; function isPlayerExpired(player: Player): boolean { return Date.now() - player.LastSeen > queueGraceTime; } // 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; initServerLog(typedSettings.log_directory?.trim() || 'logs'); recordHistory('Matchmaker started'); 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(logLevel, typedSettings); registerAuthRoutes(app, typedSettings); let Rooms: Room[] = []; let Queue: QueueEntry[] = []; const reopenInFlightPorts = new Set(); /** One matchmaking mutation at a time — GET / awaited work used to overlap and both players created separate rooms. */ let matchmakerExclusive = Promise.resolve(); function runMatchmakerExclusive(fn: () => Promise): Promise { const run = matchmakerExclusive.then(() => fn()); matchmakerExclusive = run.then( () => undefined, () => undefined, ); return run; } function removeUserFromQueue(userId: number): void { for (let i = Queue.length - 1; i >= 0; i--) { if (Queue[i].UserId === userId) Queue.splice(i, 1); } } /** GET / JSON only: same order as match DB — first joined = red, second = blue. Always includes `entry_fee`. */ function roomResponseForViewer(room: Room, viewerUserId: number, betFeePercent: number): Room { const idx = room.Players.findIndex((p) => p.UserId === viewerUserId); const your_team: 'red' | 'blue' | null = idx === 0 ? 'red' : idx === 1 ? 'blue' : null; const entry_fee = room.entry_fee ?? MATCH_ENTRY_FEE_RC; const rc_prize = computeRcPrizeFromEntryFee(entry_fee, betFeePercent); return { ...room, entry_fee, rc_prize, your_team }; } /** Same envelope as `POST /internal/rematch` success: both sides when seated, else `null` for open slot. */ function roomMatchSuccessEnvelope(room: Room, betFeePercent: number): MatchRoomSuccessEnvelope { const entry_fee = room.entry_fee ?? MATCH_ENTRY_FEE_RC; const rc_prize = computeRcPrizeFromEntryFee(entry_fee, betFeePercent); const redId = room.Players[0]?.UserId; const blueId = room.Players[1]?.UserId; return { ok: true, entry_fee, rc_prize, for_red: redId != null ? roomResponseForViewer(room, redId, betFeePercent) : null, for_blue: blueId != null ? roomResponseForViewer(room, blueId, betFeePercent) : null, }; } const createRematchRoom: CreateRematchRoomFn = async ({ userRedId, userBlueId, entryFee }) => { return runMatchmakerExclusive(async () => { const betFeePercent = await getBetFeePercentFromSettings(typedSettings); const names = await loadUsernamesForUserPair(typedSettings, userRedId, userBlueId); if (!names) { return { ok: false, status: 404, error: 'One or both players not found' }; } for (const room of Rooms) { room.Players = room.Players.filter((p) => p.UserId !== userRedId && p.UserId !== userBlueId); } for (let i = Rooms.length - 1; i >= 0; i--) { if (Rooms[i]!.Players.length === 0) { Rooms.splice(i, 1); } } removeUserFromQueue(userRedId); removeUserFromQueue(userBlueId); const matchId = await insertRematchMatch(typedSettings, userRedId, userBlueId, entryFee); if (matchId == null) { return { ok: false, status: 500, error: 'Failed to create match row' }; } const newPort = GetRandomPort(typedSettings.port_range_min, typedSettings.port_range_max); const [l10Red, l10Blue] = await Promise.all([ getPlayerLast10MatchRecord(typedSettings, userRedId), getPlayerLast10MatchRecord(typedSettings, userBlueId), ]); const redName = names.nameById.get(userRedId)!; const blueName = names.nameById.get(userBlueId)!; const newRoom: Room = { Players: [ { Name: redName, LastSeen: Date.now(), UserId: userRedId, ...l10Red }, { Name: blueName, LastSeen: Date.now(), UserId: userBlueId, ...l10Blue }, ], GameName: gameName, Port: newPort, InitTime: Date.now(), entry_fee: entryFee, match_id: matchId, instance_started: true, }; Rooms.push(newRoom); OpenGameInstance(configuredGame.exe, newRoom.Port, newRoom.match_id); recordHistory( `Rematch room port ${newPort} match ${matchId} — ${redName} vs ${blueName} (entry_fee ${entryFee})` ); return roomMatchSuccessEnvelope(newRoom, betFeePercent); }); }; registerMatchRoutes(app, typedSettings, { createRematchRoom }); app.get('/settings', async (req: Request, res: Response) => { if (req.query.password !== typedSettings.password) { res.send("403 Unauthorized"); return; } const entry_fee = await getDefaultMatchEntryFeeRc(typedSettings); const bet_fee = await getBetFeePercentFromSettings(typedSettings); const rc_prize = computeRcPrizeFromEntryFee(entry_fee, bet_fee); const table_settings = await getSettingsTableDict(typedSettings); 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, entry_fee, bet_fee, rc_prize, table_settings, }; logInfo(m_settings); res.send(m_settings); }); app.get('/table_settings', async (_req: Request, res: Response) => { const table_settings = await getSettingsTableDict(typedSettings); res.json(table_settings); }); function parseAdminLinesParam(raw: unknown, defaultLines: number, cap: number): number { if (typeof raw !== 'string') return defaultLines; const n = parseInt(raw, 10); if (!Number.isFinite(n) || n < 1) return defaultLines; return Math.min(n, cap); } /** Raw log file tail (same content rules as console: respects log_level for debug/verbose). */ app.get('/admin/logs', (req: Request, res: Response) => { if (req.query.password !== typedSettings.password) { res.status(403).send('403 Unauthorized'); return; } const lines = parseAdminLinesParam(req.query.lines, 2000, 20000); const text = readLogTail(lines, 4 * 1024 * 1024); res.type('text/plain; charset=utf-8').send(text); }); /** Brief action timeline (JSON). */ app.get('/admin/history', (req: Request, res: Response) => { if (req.query.password !== typedSettings.password) { res.status(403).send('403 Unauthorized'); return; } const lines = parseAdminLinesParam(req.query.lines, 500, 5000); const raw = readHistoryTail(lines, 512 * 1024); const entries = raw.map((line) => { const tab = line.indexOf('\t'); if (tab === -1) return { at: '', message: line }; return { at: line.slice(0, tab), message: line.slice(tab + 1) }; }); res.json({ ok: true, entries }); }); 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 { await runMatchmakerExclusive(async () => { const betFeePercent = await getBetFeePercentFromSettings(typedSettings); // 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); } logInfo(`Player ${element.Name} is afk, removing.`); recordHistory(`${element.Name} removed from queue (AFK)`); } } }); // 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(logLevel, 'This room is expired, Removing now'); logDebug(logLevel, element); const expiredNames = element.Players.map((p) => p.Name).join(', '); recordHistory( `Room expired (port ${element.Port}, match id ${element.match_id ?? '—'}) — removed; had: ${expiredNames || 'nobody'}` ); const index = Rooms.indexOf(element); if (index > -1) { Rooms.splice(index, 1); } roomValid = false; } if (roomValid) { element.Players = element.Players.filter((player) => { const expired = isPlayerExpired(player); if (expired) { logInfo(`Player ${player.Name} expired in room ${element.Port}, removing.`); recordHistory(`${player.Name} timed out in room on port ${element.Port} (removed)`); } return !expired; }); element.Players.forEach(player => { if (player.UserId === userId) { player.LastSeen = Date.now(); 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); recordHistory(`Room on port ${element.Port} removed (empty)`); } } else if (element.Players.length < typedSettings.maximum_players && element.GameName === gameName) { possibleRoom = element; } } }); if (joinedRoom !== null) { // Already in a room. Stopping here removeUserFromQueue(userId); res.json(roomMatchSuccessEnvelope(joinedRoom, betFeePercent)); return; } // Neither in a room nor in queue, Let's see if (possibleRoom === null) { if (Queue.length >= typedSettings.minimum_players) { const insertResult = await insertMatchForNewRoom(typedSettings, userId); 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(), entry_fee: insertResult.entry_fee, }; if (insertResult.matchId != null) { newRoom.match_id = insertResult.matchId; } Rooms.push(newRoom); removeUserFromQueue(userId); recordHistory( `${username} created a room on port ${newPort} (match id ${newRoom.match_id ?? '—'}) — waiting for opponent` ); res.json(roomMatchSuccessEnvelope(newRoom, betFeePercent)); 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 }); removeUserFromQueue(userId); if (room.match_id != null) { await updateMatchUserBlue(typedSettings, room.match_id, userId); } const hasTwoActivePlayers = room.Players.length === 2 && room.Players.every((player) => !isPlayerExpired(player)); if (hasTwoActivePlayers && !room.instance_started) { room.instance_started = true; OpenGameInstance(configuredGame.exe, room.Port, room.match_id ?? undefined); const [p0, p1] = room.Players; recordHistory( `new room created with port ${room.Port} and matchId ${room.match_id ?? '—'} for ${p0.Name} and ${p1.Name}` ); } res.json(roomMatchSuccessEnvelope(room, betFeePercent)); return; } // Not even got a new room. Back to queue if (!alreadyOnQueue) { const newQueueEntry: QueueEntry = { Name: username, LastSeen: Date.now(), UserId: userId }; logVerbose(logLevel, newQueueEntry); Queue.push(newQueueEntry); recordHistory(`${username} started queuing`); } logVerbose(logLevel, 'Rooms'); Rooms.forEach(element => { logVerbose(logLevel, element); }); logVerbose(logLevel, 'Queue'); logVerbose(logLevel, Queue); res.send("0"); }); } catch (err) { console.error("Matchmaking error:", err); if (!res.headersSent) 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; await runMatchmakerExclusive(async () => { const isInRoom = Rooms.some((room) => room.Players.some((player) => player.UserId === userId)); if (isInRoom) { res.status(409).send("Can't cancel while already in a room"); return; } let foundUser = false; Queue.forEach((element) => { if (element.UserId === userId) { const index = Queue.indexOf(element); if (index > -1) { Queue.splice(index, 1); recordHistory(`${element.Name} cancelled queuing`); } res.send("1"); foundUser = true; return; } }); if (!foundUser) { res.send("Couldn't find user in the queue"); } }); }); app.get('/reopen', async (req: Request, res: Response) => { if (req.query.password !== typedSettings.password) { res.status(403).send("403 Unauthorized"); return; } const rawPort = req.query.port; const port = typeof rawPort === 'string' ? parseInt(rawPort, 10) : NaN; if (!Number.isInteger(port)) { res.status(400).json({ ok: false, error: "Invalid port" }); return; } if (port < typedSettings.port_range_min || port > typedSettings.port_range_max) { res.status(400).json({ ok: false, error: "Port out of configured range" }); return; } if (reopenInFlightPorts.has(port)) { res.status(409).json({ ok: false, error: "Reopen already in progress for this port" }); return; } reopenInFlightPorts.add(port); try { await runMatchmakerExclusive(async () => { const hasRunningInstance = Rooms.some((room) => room.Port === port && room.instance_started); if (hasRunningInstance) { res.status(409).json({ ok: false, error: "A game is already running on this port" }); return; } const insertResult = await insertMatchForNewRoom(typedSettings); const newRoom: Room = { Players: [], GameName: gameName, Port: port, InitTime: Date.now(), entry_fee: insertResult.entry_fee, instance_started: true, }; if (insertResult.matchId != null) { newRoom.match_id = insertResult.matchId; } Rooms.push(newRoom); OpenGameInstance(configuredGame.exe, newRoom.Port, newRoom.match_id ?? undefined); recordHistory( `Admin reopened game on port ${newRoom.Port} (match id ${newRoom.match_id ?? '—'})` ); res.json({ ok: true, port: newRoom.Port, match_id: newRoom.match_id ?? null }); }); } catch (err) { console.error("Reopen error:", err); res.status(500).json({ ok: false, error: "Internal error" }); } finally { reopenInFlightPorts.delete(port); } }); 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","email":"you@example.com","password":"secret12"}', }); return; } next(err); }); app.listen(typedSettings.port); console.log("Listening on port " + typedSettings.port);