feature: basic matchmaking (#2227)

## Description:

Implement a basic matchmaking modal that connects to the api service and
waits for a game id. It then waits until the game starts and connects to
it.

Workers use long polling to check in with the matchmaking server and
receive player assignments.

## Please complete the following:

- [x] I have added screenshots for all UI updates
- [x] I process any text displayed to the user through translateText()
and I've added it to the en.json file
- [x] I have added relevant tests to the test directory
- [x] I confirm I have thoroughly tested these changes and take full
responsibility for any bugs introduced

## Please put your Discord username so you can be contacted if a bug or
regression is found:

evan

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Evan
2025-10-21 14:08:07 -07:00
committed by GitHub
parent dddf54be0b
commit 4ada4c7375
10 changed files with 320 additions and 9 deletions
+6 -2
View File
@@ -71,6 +71,8 @@ const TEAM_COUNTS = [
export class MapPlaylist {
private mapsPlaylist: MapWithMode[] = [];
constructor(private disableTeams: boolean = false) {}
public gameConfig(): GameConfig {
const { map, mode } = this.getNextMap();
@@ -135,8 +137,10 @@ export class MapPlaylist {
if (!this.addNextMap(this.mapsPlaylist, ffa, GameMode.FFA)) {
return false;
}
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
if (!this.disableTeams) {
if (!this.addNextMap(this.mapsPlaylist, team, GameMode.Team)) {
return false;
}
}
}
return true;
-5
View File
@@ -291,11 +291,6 @@ async function schedulePublicGame(playlist: MapPlaylist) {
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// SPA fallback route
app.get("*", function (req, res) {
res.sendFile(path.join(__dirname, "../../static/index.html"));
+90 -1
View File
@@ -11,11 +11,12 @@ import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
import { GameType } from "../core/game/Game";
import {
ClientMessageSchema,
GameID,
ID,
PartialGameRecordSchema,
ServerErrorMessage,
} from "../core/Schemas";
import { replacer } from "../core/Util";
import { generateID, replacer } from "../core/Util";
import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas";
import { archive, finalizeGameRecord } from "./Archive";
import { Client } from "./Client";
@@ -23,6 +24,7 @@ import { GameManager } from "./GameManager";
import { getUserMe, verifyClientToken } from "./jwt";
import { logger } from "./Logger";
import { MapPlaylist } from "./MapPlaylist";
import { PrivilegeRefresher } from "./PrivilegeRefresher";
import { initWorkerMetrics } from "./WorkerMetrics";
@@ -30,11 +32,24 @@ const config = getServerConfigFromServer();
const workerId = parseInt(process.env.WORKER_ID ?? "0");
const log = logger.child({ comp: `w_${workerId}` });
const playlist = new MapPlaylist(true);
// Worker setup
export async function startWorker() {
log.info(`Worker starting...`);
if (config.enableMatchmaking()) {
log.info("Starting matchmaking");
setTimeout(
() => {
pollLobby(gm);
},
1000 + Math.random() * 2000,
);
} else {
log.info("Matchmaking disabled");
}
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -450,3 +465,77 @@ export async function startWorker() {
log.error(`unhandled rejection at:`, promise, "reason:", reason);
});
}
async function pollLobby(gm: GameManager) {
try {
const url = `${config.jwtIssuer() + "/matchmaking/checkin"}`;
const gameId = generateGameIdForWorker();
if (gameId === null) {
log.warn(`Failed to generate game ID for worker ${workerId}`);
return;
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 20000);
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.apiKey(),
},
body: JSON.stringify({
id: workerId,
gameId: gameId,
ccu: gm.activeClients(),
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
log.warn(
`Failed to poll lobby: ${response.status} ${response.statusText}`,
);
return;
}
const data = await response.json();
log.info(`Lobby poll successful:`, data);
if (data.assignment) {
// TODO: Only allow specified players to join the game.
console.log(`Creating game ${gameId}`);
const game = gm.createGame(gameId, playlist.gameConfig());
setTimeout(() => {
// Wait a few seconds to allow clients to connect.
console.log(`Starting game ${gameId}`);
game.start();
}, 5000);
}
} catch (error) {
log.error(`Error polling lobby:`, error);
} finally {
setTimeout(
() => {
pollLobby(gm);
},
5000 + Math.random() * 1000,
);
}
}
// TODO: This is a hack to generate a game ID for the worker.
// It should be replaced with a more robust solution.
function generateGameIdForWorker(): GameID | null {
let attempts = 1000;
while (attempts > 0) {
const gameId = generateID();
if (workerId === config.workerIndex(gameId)) {
return gameId;
}
attempts--;
}
log.warn(`Failed to generate game ID for worker ${workerId}`);
return null;
}