prod v1
This commit is contained in:
@@ -9,6 +9,15 @@ import {
|
||||
registerMatchRoutes,
|
||||
updateMatchUserBlue,
|
||||
} 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);
|
||||
@@ -29,6 +38,10 @@ app.use((req, res, next) => {
|
||||
|
||||
const queueGraceTime = 2000;
|
||||
|
||||
function isPlayerExpired(player: Player): boolean {
|
||||
return Date.now() - player.LastSeen > queueGraceTime;
|
||||
}
|
||||
|
||||
// Read settings
|
||||
const settings = ReadSettings();
|
||||
if (settings == null) {
|
||||
@@ -40,6 +53,9 @@ if (settings == 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");
|
||||
@@ -47,13 +63,31 @@ if (!configuredGame?.exe) {
|
||||
}
|
||||
const gameName = configuredGame.name || "game";
|
||||
|
||||
LogVerbose(typedSettings);
|
||||
logVerbose(logLevel, typedSettings);
|
||||
|
||||
registerAuthRoutes(app, typedSettings);
|
||||
registerMatchRoutes(app, typedSettings);
|
||||
|
||||
let Rooms: Room[] = [];
|
||||
let Queue: QueueEntry[] = [];
|
||||
const reopenInFlightPorts = new Set<number>();
|
||||
|
||||
/** One matchmaking mutation at a time — GET / awaited work used to overlap and both players created separate rooms. */
|
||||
let matchmakerExclusive = Promise.resolve();
|
||||
function runMatchmakerExclusive<T>(fn: () => Promise<T>): Promise<T> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
app.get('/settings', (req: Request, res: Response) => {
|
||||
if (req.query.password !== typedSettings.password) {
|
||||
@@ -68,10 +102,44 @@ app.get('/settings', (req: Request, res: Response) => {
|
||||
port_range_max: typedSettings.port_range_max,
|
||||
games: typedSettings.games
|
||||
};
|
||||
console.log(m_settings);
|
||||
logInfo(m_settings);
|
||||
res.send(m_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) {
|
||||
@@ -81,6 +149,7 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
const { userId, username } = auth.user;
|
||||
|
||||
try {
|
||||
await runMatchmakerExclusive(async () => {
|
||||
|
||||
// Check query
|
||||
let joinedRoom: Room | null = null;
|
||||
@@ -96,7 +165,8 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
if (index > -1) {
|
||||
Queue.splice(index, 1);
|
||||
}
|
||||
Log(`Player ${element.Name} is afk, removing.`);
|
||||
logInfo(`Player ${element.Name} is afk, removing.`);
|
||||
recordHistory(`${element.Name} removed from queue (AFK)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -108,8 +178,12 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
let roomValid = true;
|
||||
if (Date.now() - element.InitTime > typedSettings.waiting_time) {
|
||||
// This room is expired
|
||||
LogDebug("This room is expired, Removing now");
|
||||
LogDebug(element);
|
||||
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);
|
||||
@@ -117,8 +191,17 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -128,6 +211,7 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
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;
|
||||
@@ -136,6 +220,7 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
if (joinedRoom !== null) { // Already in a room. Stopping here
|
||||
removeUserFromQueue(userId);
|
||||
res.send(roomResponseForViewer(joinedRoom, userId));
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +241,10 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
newRoom.match_id = matchId;
|
||||
}
|
||||
Rooms.push(newRoom);
|
||||
OpenGameInstance(configuredGame.exe, newPort, matchId ?? undefined);
|
||||
removeUserFromQueue(userId);
|
||||
recordHistory(
|
||||
`${username} created a room on port ${newPort} (match id ${newRoom.match_id ?? '—'}) — waiting for opponent`
|
||||
);
|
||||
res.send(roomResponseForViewer(newRoom, userId));
|
||||
return;
|
||||
}
|
||||
@@ -165,9 +253,20 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
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.send(roomResponseForViewer(room, userId));
|
||||
return;
|
||||
}
|
||||
@@ -175,19 +274,21 @@ app.get('/', async (req: Request, res: Response) => {
|
||||
// Not even got a new room. Back to queue
|
||||
if (!alreadyOnQueue) {
|
||||
const newQueueEntry: QueueEntry = { Name: username, LastSeen: Date.now(), UserId: userId };
|
||||
LogVerbose(newQueueEntry);
|
||||
logVerbose(logLevel, newQueueEntry);
|
||||
Queue.push(newQueueEntry);
|
||||
recordHistory(`${username} started queuing`);
|
||||
}
|
||||
LogVerbose("Rooms");
|
||||
logVerbose(logLevel, 'Rooms');
|
||||
Rooms.forEach(element => {
|
||||
LogVerbose(element);
|
||||
logVerbose(logLevel, element);
|
||||
});
|
||||
LogVerbose("Queue");
|
||||
LogVerbose(Queue);
|
||||
logVerbose(logLevel, 'Queue');
|
||||
logVerbose(logLevel, Queue);
|
||||
res.send("0");
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Matchmaking error:", err);
|
||||
res.status(500).send("Internal error");
|
||||
if (!res.headersSent) res.status(500).send("Internal error");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -198,20 +299,88 @@ app.get('/cancel', async (req: Request, res: Response) => {
|
||||
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;
|
||||
|
||||
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");
|
||||
}
|
||||
});
|
||||
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 newRoom: Room = {
|
||||
Players: [],
|
||||
GameName: gameName,
|
||||
Port: port,
|
||||
InitTime: Date.now(),
|
||||
instance_started: true,
|
||||
};
|
||||
const matchId = await insertMatchForNewRoom(typedSettings);
|
||||
if (matchId != null) {
|
||||
newRoom.match_id = 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -222,22 +391,6 @@ function roomResponseForViewer(room: Room, viewerUserId: number): Room {
|
||||
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') {
|
||||
|
||||
Reference in New Issue
Block a user