Files
kickkingsapi/app.ts
T
2026-04-04 18:43:36 +00:00

255 lines
8.2 KiB
TypeScript

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);