Files
kickkingsapi/app.ts
T
2026-08-14 20:55:11 +00:00

527 lines
20 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,
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<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);
}
}
/** 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) {
// 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;
});
}
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 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);