diff --git a/resources/lang/en.json b/resources/lang/en.json index cecb533a4..e3a47ef60 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -222,6 +222,12 @@ "teams_Quads": "Quads (teams of 4)", "teams": "{num} teams" }, + "matchmaking_modal": { + "title": "Matchmaking", + "connecting": "Connecting to matchmaking server...", + "searching": "Searching for game...", + "waiting_for_game": "Waiting for game to start..." + }, "username": { "enter_username": "Enter your username", "not_string": "Username must be a string.", diff --git a/src/client/Main.ts b/src/client/Main.ts index 2d61e78b6..81ff4550b 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -22,6 +22,8 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; +import "./Matchmaking"; +import { MatchmakingModal } from "./Matchmaking"; import { NewsModal } from "./NewsModal"; import "./PublicLobby"; import { PublicLobby } from "./PublicLobby"; @@ -100,6 +102,7 @@ class Client { private userSettings: UserSettings = new UserSettings(); private patternsModal: TerritoryPatternsModal; private tokenLoginModal: TokenLoginModal; + private matchmakingModal: MatchmakingModal; private gutterAds: GutterAds; @@ -256,6 +259,16 @@ class Client { console.warn("Token login modal element not found"); } + this.matchmakingModal = document.querySelector( + "matchmaking-modal", + ) as MatchmakingModal; + if ( + !this.matchmakingModal || + !(this.matchmakingModal instanceof MatchmakingModal) + ) { + console.warn("Matchmaking modal element not found"); + } + const onUserMe = async (userMeResponse: UserMeResponse | false) => { document.dispatchEvent( new CustomEvent("userMeResponse", { @@ -598,6 +611,7 @@ class Client { "flag-input-modal", "account-button", "token-login", + "matchmaking-modal", ].forEach((tag) => { const modal = document.querySelector(tag) as HTMLElement & { close?: () => void; @@ -697,7 +711,7 @@ document.addEventListener("DOMContentLoaded", () => { }); // WARNING: DO NOT EXPOSE THIS ID -function getPlayToken(): string { +export function getPlayToken(): string { const result = isLoggedIn(); if (result !== false) return result.token; return getPersistentIDFromCookie(); diff --git a/src/client/Matchmaking.ts b/src/client/Matchmaking.ts new file mode 100644 index 000000000..e9118f790 --- /dev/null +++ b/src/client/Matchmaking.ts @@ -0,0 +1,193 @@ +import { html, LitElement } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; +import { generateID } from "../core/Util"; +import "./components/Difficulties"; +import "./components/PatternButton"; +import { getPlayToken, JoinLobbyEvent } from "./Main"; +import { translateText } from "./Utils"; + +@customElement("matchmaking-modal") +export class MatchmakingModal extends LitElement { + private gameCheckInterval: ReturnType | null = null; + private connected = false; + @state() private socket: WebSocket | null = null; + + @state() private gameID: string | null = null; + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + constructor() { + super(); + } + + createRenderRoot() { + return this; + } + + render() { + return html` + + ${this.renderInner()} + + `; + } + + private renderInner() { + if (!this.connected) { + return html`${translateText("matchmaking_modal.connecting")}`; + } + if (this.gameID === null) { + return html`${translateText("matchmaking_modal.searching")}`; + } else { + return html`${translateText("matchmaking_modal.waiting_for_game")}`; + } + } + + private async connect() { + const config = await getServerConfigFromClient(); + + this.socket = new WebSocket(`${config.jwtIssuer()}/matchmaking/join`); + this.socket.onopen = () => { + console.log("Connected to matchmaking server"); + setTimeout(() => { + // Set a delay so the user can see the "connecting" message, + // otherwise the "searching" message will be shown immediately. + this.connected = true; + this.requestUpdate(); + }, 1000); + this.socket?.send( + JSON.stringify({ + type: "auth", + playToken: getPlayToken(), + }), + ); + }; + this.socket.onmessage = (event) => { + console.log(event.data); + const data = JSON.parse(event.data); + if (data.type === "match-assignment") { + this.socket?.close(); + console.log(`matchmaking: got game ID: ${data.gameId}`); + this.gameID = data.gameId; + } + }; + this.socket.onerror = (event: ErrorEvent) => { + console.error("WebSocket error occurred:", event); + }; + this.socket.onclose = (event) => { + console.log("Matchmaking server closed connection"); + }; + } + + public close() { + this.connected = false; + this.socket?.close(); + this.modalEl?.close(); + if (this.gameCheckInterval) { + clearInterval(this.gameCheckInterval); + this.gameCheckInterval = null; + } + } + + public async open() { + this.modalEl?.open(); + this.requestUpdate(); + this.connect(); + this.gameCheckInterval = setInterval(() => this.checkGame(), 3000); + } + + private async checkGame() { + if (this.gameID === null) { + return; + } + const config = await getServerConfigFromClient(); + const url = `/${config.workerPath(this.gameID)}/api/game/${this.gameID}/exists`; + + const response = await fetch(url, { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + + const gameInfo = await response.json(); + + if (response.status !== 200) { + console.error(`Error checking game ${this.gameID}: ${response.status}`); + return; + } + + if (!gameInfo.exists) { + console.info(`Game ${this.gameID} does not exist or hasn't started yet`); + return; + } + + if (this.gameCheckInterval) { + clearInterval(this.gameCheckInterval); + this.gameCheckInterval = null; + } + + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: this.gameID, + clientID: generateID(), + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + } +} + +@customElement("matchmaking-button") +export class MatchmakingButton extends LitElement { + @query("matchmaking-modal") private matchmakingModal: MatchmakingModal; + @state() private matchmakingEnabled = false; + + constructor() { + super(); + } + + async connectedCallback() { + super.connectedCallback(); + const config = await getServerConfigFromClient(); + this.matchmakingEnabled = config.enableMatchmaking(); + } + + createRenderRoot() { + return this; + } + + render() { + if (!this.matchmakingEnabled) { + return html``; + } + + return html` +
+ +
+ + `; + } + + private open() { + this.matchmakingModal?.open(); + } + + public close() { + this.matchmakingModal?.close(); + this.requestUpdate(); + } +} diff --git a/src/client/index.html b/src/client/index.html index f103179ed..7a765995c 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -221,6 +221,9 @@
+
+ +
{ - return new Promise((resolve) => setTimeout(resolve, ms)); -} - // SPA fallback route app.get("*", function (req, res) { res.sendFile(path.join(__dirname, "../../static/index.html")); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 9406f4fe3..212f1bcf0 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -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; +} diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 6488dc99b..6f20fa1cd 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -4,6 +4,9 @@ import { GameMapType } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { + enableMatchmaking(): boolean { + throw new Error("Method not implemented."); + } apiKey(): string { throw new Error("Method not implemented."); }