mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:30:45 +00:00
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:
@@ -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.",
|
||||
|
||||
+15
-1
@@ -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();
|
||||
|
||||
@@ -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<typeof setInterval> | 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`
|
||||
<o-modal
|
||||
id="matchmaking-modal"
|
||||
title="${translateText("matchmaking_modal.title")}"
|
||||
>
|
||||
${this.renderInner()}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="z-[9999]">
|
||||
<button
|
||||
@click="${this.open}"
|
||||
class="w-full h-16 bg-blue-600 hover:bg-blue-700 text-white rounded-full shadow-2xl hover:shadow-3xl transition-all duration-200 flex items-center justify-center text-xl focus:outline-none focus:ring-4 focus:ring-blue-500 focus:ring-offset-4"
|
||||
title="${translateText("matchmaking_modal.title")}"
|
||||
>
|
||||
Matchmaking
|
||||
</button>
|
||||
</div>
|
||||
<matchmaking-modal></matchmaking-modal>
|
||||
`;
|
||||
}
|
||||
|
||||
private open() {
|
||||
this.matchmakingModal?.open();
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.matchmakingModal?.close();
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,9 @@
|
||||
<div>
|
||||
<public-lobby class="block"></public-lobby>
|
||||
</div>
|
||||
<div>
|
||||
<matchmaking-button class="w-[20%] md:w-[15%]"></matchmaking-button>
|
||||
</div>
|
||||
<div class="container__row container__row--equal">
|
||||
<o-button
|
||||
id="host-lobby-button"
|
||||
|
||||
@@ -63,6 +63,7 @@ export interface ServerConfig {
|
||||
cloudflareCredsPath(): string;
|
||||
stripePublishableKey(): string;
|
||||
allowedFlares(): string[] | undefined;
|
||||
enableMatchmaking(): boolean;
|
||||
}
|
||||
|
||||
export interface NukeMagnitude {
|
||||
|
||||
@@ -213,6 +213,9 @@ export abstract class DefaultServerConfig implements ServerConfig {
|
||||
workerPortByIndex(index: number): number {
|
||||
return 3001 + index;
|
||||
}
|
||||
enableMatchmaking(): boolean {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class DefaultConfig implements Config {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user