593 lines
22 KiB
TypeScript
593 lines
22 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,
|
|
insertRematchMatch,
|
|
loadUsernamesForUserPair,
|
|
computeRcPrizeFromEntryFee,
|
|
getBetFeePercentFromSettings,
|
|
getDefaultMatchEntryFeeRc,
|
|
getSettingsTableDict,
|
|
MATCH_ENTRY_FEE_RC,
|
|
registerMatchRoutes,
|
|
getAdminMmrLeaderboard,
|
|
updateMatchUserBlue,
|
|
type CreateRematchRoomFn,
|
|
type OnMatchSettledFn,
|
|
type OnBothPlayersConnectedFn,
|
|
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<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);
|
|
}
|
|
}
|
|
|
|
function removeRoomByMatchId(matchId: number): void {
|
|
const index = Rooms.findIndex((room) => room.match_id === matchId);
|
|
if (index < 0) return;
|
|
const room = Rooms[index]!;
|
|
const names = room.Players.map((p) => p.Name).join(', ');
|
|
Rooms.splice(index, 1);
|
|
logInfo('Room removed after match settled', { match_id: matchId, port: room.Port });
|
|
recordHistory(
|
|
`Room on port ${room.Port} removed (match ${matchId} settled) — had: ${names || 'nobody'}`
|
|
);
|
|
}
|
|
|
|
const onMatchSettled: OnMatchSettledFn = async (matchId) => {
|
|
await runMatchmakerExclusive(async () => {
|
|
removeRoomByMatchId(matchId);
|
|
});
|
|
};
|
|
|
|
function markRoomBothConnected(matchId: number): void {
|
|
const room = Rooms.find((r) => r.match_id === matchId);
|
|
if (!room || room.both_connected) return;
|
|
room.both_connected = true;
|
|
logInfo('Both players connected to match', { match_id: matchId, port: room.Port });
|
|
recordHistory(`Both players connected on port ${room.Port} (match ${matchId})`);
|
|
}
|
|
|
|
const onBothPlayersConnected: OnBothPlayersConnectedFn = async (matchId) => {
|
|
await runMatchmakerExclusive(async () => {
|
|
markRoomBothConnected(matchId);
|
|
});
|
|
};
|
|
|
|
/** 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, onMatchSettled, onBothPlayersConnected });
|
|
|
|
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 });
|
|
});
|
|
|
|
/** Hidden MMR leaderboard (not shown to players). */
|
|
app.get('/admin/mmr', async (req: Request, res: Response) => {
|
|
if (req.query.password !== typedSettings.password) {
|
|
res.status(403).send('403 Unauthorized');
|
|
return;
|
|
}
|
|
const limit = parseAdminLinesParam(req.query.limit, 100, 500);
|
|
const result = await getAdminMmrLeaderboard(typedSettings, limit);
|
|
if (!result.ok) {
|
|
res.status(503).json({ ok: false, error: result.error });
|
|
return;
|
|
}
|
|
res.json({ ok: true, players: result.players });
|
|
});
|
|
|
|
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) {
|
|
// In-game players stop GET / heartbeats; do not expire them or reopen the slot.
|
|
if (!element.instance_started) {
|
|
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;
|
|
});
|
|
}
|
|
if (element.both_connected) {
|
|
const seated = element.Players.find((player) => player.UserId === userId);
|
|
if (seated) {
|
|
element.Players = element.Players.filter((player) => player.UserId !== userId);
|
|
logInfo(`${username} left connected match to matchmake again`, {
|
|
port: element.Port,
|
|
match_id: element.match_id ?? null,
|
|
});
|
|
recordHistory(
|
|
`${username} left connected match on port ${element.Port} (match id ${element.match_id ?? '—'}) — matchmaking again`
|
|
);
|
|
}
|
|
} else {
|
|
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.instance_started &&
|
|
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);
|
|
logInfo(`${username} seated in room`, {
|
|
port: newPort,
|
|
match_id: newRoom.match_id ?? null,
|
|
instance_started: Boolean(newRoom.instance_started),
|
|
});
|
|
recordHistory(
|
|
`${username} created a room on port ${newPort} (match id ${newRoom.match_id ?? '—'}) — waiting for opponent`
|
|
);
|
|
res.json(roomMatchSuccessEnvelope(newRoom, betFeePercent));
|
|
return;
|
|
}
|
|
} else {
|
|
const room: Room = possibleRoom;
|
|
const alreadySeated = room.Players.some((p) => p.UserId === userId);
|
|
const hasFreeSlot = room.Players.length < typedSettings.maximum_players;
|
|
if (!room.instance_started && hasFreeSlot && !alreadySeated) {
|
|
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}`
|
|
);
|
|
}
|
|
logInfo(`${username} seated in room`, {
|
|
port: room.Port,
|
|
match_id: room.match_id ?? null,
|
|
instance_started: Boolean(room.instance_started),
|
|
});
|
|
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 isInWaitingRoom = Rooms.some(
|
|
(room) => !room.instance_started && room.Players.some((player) => player.UserId === userId)
|
|
);
|
|
if (isInWaitingRoom) {
|
|
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);
|