From 5027083c48935398f3c197cc7ac51faf098cf322 Mon Sep 17 00:00:00 2001 From: Trajkov Dimitar Date: Fri, 31 Oct 2025 14:34:13 +0100 Subject: [PATCH] init implementation complete flow --- index.html | 3 + resources/lang/en.json | 21 ++ src/client/Main.ts | 1 + src/client/RankedQueue.ts | 441 ++++++++++++++++++++++++++++++++ src/core/ApiSchemas.ts | 1 + src/server/MapSelection.ts | 119 +++++++++ src/server/MatchmakingPoller.ts | 123 +++++++++ src/server/RankedGameConfig.ts | 69 +++++ src/server/Worker.ts | 46 ++++ 9 files changed, 824 insertions(+) create mode 100644 src/client/RankedQueue.ts create mode 100644 src/server/MapSelection.ts create mode 100644 src/server/MatchmakingPoller.ts create mode 100644 src/server/RankedGameConfig.ts diff --git a/index.html b/index.html index c1b08e82f..7bfcfbd24 100644 --- a/index.html +++ b/index.html @@ -284,6 +284,9 @@
+
+ +
({ + rank: index + 1, + username: entry.username ?? "Anonymous", + currentElo: entry.currentElo, + gamesPlayed: entry.gamesPlayed, + wins: entry.wins, + losses: entry.losses, + })); + console.log("Fetched leaderboard:", this.leaderboard.length, "players"); + } + } catch (error) { + console.error("Failed to fetch leaderboard:", error); + // Don't show error to user, just silently fail + } finally { + this.isLoadingLeaderboard = false; + } + } + + private cleanup() { + if (this.reconnectTimeout !== null) { + clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + private async connectWebSocket() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; // Already connected + } + + this.isConnecting = true; + this.error = null; + + try { + // Get authentication information + const loginResult = isLoggedIn(); + if (loginResult === false) { + throw new Error("Please log in to join ranked matchmaking"); + } + + const token = loginResult.token; + + // Determine WebSocket URL based on environment + // In development, use local matchmaking server; in production, use API + const matchmakingBase = process?.env?.MATCHMAKING_WS_URL; + const wsUrl = matchmakingBase + ? `${matchmakingBase}/matchmaking/join` + : (() => { + const apiBase = getApiBase(); + const protocol = apiBase.startsWith("https://") ? "wss:" : "ws:"; + const host = apiBase.replace(/^https?:\/\//, ""); + return `${protocol}//${host}/matchmaking/join`; + })(); + + console.log("Connecting to matchmaking WebSocket:", wsUrl); + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log("Connected to matchmaking service"); + this.isConnecting = false; + this.reconnectAttempts = 0; + + // Send authentication message + this.ws?.send( + JSON.stringify({ + type: "auth", + playToken: token, + queueType: this.queueType, + gameMode: this.gameMode, + }), + ); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + this.handleMessage(message); + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + this.error = "Connection error. Please try again."; + this.isConnecting = false; + }; + + this.ws.onclose = () => { + console.log("WebSocket closed"); + this.ws = null; + + // Attempt reconnection if we were in queue + if ( + this.inQueue && + this.reconnectAttempts < this.maxReconnectAttempts + ) { + this.reconnectAttempts++; + console.log( + `Attempting reconnection ${this.reconnectAttempts}/${this.maxReconnectAttempts}`, + ); + this.reconnectTimeout = window.setTimeout( + () => this.connectWebSocket(), + 2000 * this.reconnectAttempts, + ); + } else if (this.inQueue) { + this.error = "Connection lost. Please rejoin the queue."; + this.inQueue = false; + this.isConnecting = false; + } + }; + } catch (error) { + console.error("Error connecting to matchmaking:", error); + this.error = error instanceof Error ? error.message : "Failed to connect"; + this.isConnecting = false; + } + } + + private handleMessage(message: any) { + switch (message.type) { + case "auth_success": + console.log("Authentication successful"); + if (message.playerElo !== undefined) { + this.playerElo = message.playerElo; + } + break; + + case "queue_joined": + console.log("Joined queue"); + this.inQueue = true; + if (message.queueStatus) { + this.queueStatus = message.queueStatus; + } + break; + + case "queue_status": + console.log("Queue status update:", message.status); + this.queueStatus = message.status; + break; + + case "match_found": + console.log("Match found!", message); + this.handleMatchFound(message.gameId, message.assignment); + break; + + case "error": + console.error("Matchmaking error:", message.error); + this.error = message.error; + this.inQueue = false; + break; + + default: + console.log("Unknown message type:", message.type); + } + } + + private handleMatchFound(gameId: string, assignment: any) { + console.log(`Match found! Joining game ${gameId}`); + + // Set URL hash to trigger automatic join + history.pushState(null, "", `#join=${gameId}`); + + // Dispatch event to join the game + this.dispatchEvent( + new CustomEvent("join-lobby", { + detail: { + gameID: gameId, + clientID: this.clientID, + } as JoinLobbyEvent, + bubbles: true, + composed: true, + }), + ); + + // Clean up + this.inQueue = false; + this.queueStatus = null; + this.cleanup(); + } + + private async joinQueue() { + if (this.inQueue) { + return; + } + + // Connect WebSocket if not connected + await this.connectWebSocket(); + + // Send join queue message + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "join_queue", + queueType: this.queueType, + gameMode: this.gameMode, + }), + ); + } + } + + private leaveQueue() { + if (!this.inQueue) { + return; + } + + // Send leave queue message + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "leave_queue", + }), + ); + } + + this.inQueue = false; + this.queueStatus = null; + this.cleanup(); + } + + private setQueueType(type: QueueType) { + if (this.inQueue) { + return; // Can't change while in queue + } + this.queueType = type; + } + + private setGameMode(mode: GameMode) { + if (this.inQueue) { + return; // Can't change while in queue + } + this.gameMode = mode; + } + + render() { + return html` +
+ + + + + + + + ${this.showLeaderboard + ? html` +
+ ${this.isLoadingLeaderboard + ? html`
+ ${translateText("ranked_queue.loading_leaderboard")} +
` + : this.leaderboard.length === 0 + ? html`
+ ${translateText("ranked_queue.no_ranked_players")} +
` + : html` +
+ ${this.leaderboard.slice(0, 10).map( + (entry) => html` +
+
+
+ #${entry.rank} +
+
+
+ ${entry.username} +
+
+ ${entry.gamesPlayed} + ${translateText("ranked_queue.games")} • + ${entry.wins}${translateText( + "ranked_queue.wins_short", + )} + ${entry.losses}${translateText( + "ranked_queue.losses_short", + )} +
+
+
+
+
+ ${entry.currentElo} +
+
+ ${translateText("ranked_queue.elo")} +
+
+
+ `, + )} +
+ `} +
+ ` + : ""} +
+ `; + } +} diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index 775e86c49..2485939ad 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -51,6 +51,7 @@ export const UserMeResponseSchema = z.object({ publicId: z.string(), roles: z.string().array().optional(), flares: z.string().array().optional(), + elo: z.number().optional(), }), }); export type UserMeResponse = z.infer; diff --git a/src/server/MapSelection.ts b/src/server/MapSelection.ts new file mode 100644 index 000000000..59db81f1d --- /dev/null +++ b/src/server/MapSelection.ts @@ -0,0 +1,119 @@ +import { GameMapType, GameMode } from "../core/game/Game"; +// import { TeamCountConfig } from "../core/Schemas"; + +const MAP_CAPACITIES: Record = { + [GameMapType.Africa]: [100, 70, 50], + [GameMapType.Asia]: [50, 40, 30], + [GameMapType.Australia]: [70, 40, 30], + [GameMapType.Baikal]: [100, 70, 50], + [GameMapType.BetweenTwoSeas]: [70, 50, 40], + [GameMapType.BlackSea]: [50, 30, 30], + [GameMapType.Britannia]: [50, 30, 20], + [GameMapType.DeglaciatedAntarctica]: [50, 40, 30], + [GameMapType.EastAsia]: [50, 30, 20], + [GameMapType.Europe]: [100, 70, 50], + [GameMapType.EuropeClassic]: [50, 30, 30], + [GameMapType.FalklandIslands]: [50, 30, 20], + [GameMapType.FaroeIslands]: [20, 15, 10], + [GameMapType.GatewayToTheAtlantic]: [100, 70, 50], + [GameMapType.GiantWorldMap]: [100, 70, 50], + [GameMapType.Halkidiki]: [100, 50, 40], + [GameMapType.Iceland]: [50, 40, 30], + [GameMapType.Italia]: [50, 30, 20], + [GameMapType.Japan]: [20, 15, 10], + [GameMapType.Mars]: [70, 40, 30], + [GameMapType.Mena]: [70, 50, 40], + [GameMapType.Montreal]: [60, 40, 30], + [GameMapType.NorthAmerica]: [70, 40, 30], + [GameMapType.Oceania]: [10, 10, 10], + [GameMapType.Pangaea]: [20, 15, 10], + [GameMapType.Pluto]: [100, 70, 50], + [GameMapType.SouthAmerica]: [70, 50, 40], + [GameMapType.StraitOfGibraltar]: [100, 70, 50], + [GameMapType.World]: [50, 30, 20], + [GameMapType.Yenisei]: [150, 100, 70], +}; + +export interface MapSelectionCriteria { + playerCount: number; + gameMode: GameMode; + queueType: "ranked" | "unranked"; +} + +/** + * Select appropriate map for ranked match based on player count and game mode + * Uses map capacity and competitive map preferences + */ +export function selectMapForRanked( + criteria: MapSelectionCriteria, +): GameMapType { + const { playerCount, gameMode } = criteria; + + // Get maps that can handle this player count + const suitableMaps = getSuitableMaps(playerCount); + + // For ranked, prefer competitive maps + const rankedMaps = + gameMode === GameMode.FFA + ? [ + GameMapType.World, + GameMapType.Europe, + GameMapType.Asia, + GameMapType.NorthAmerica, + GameMapType.Africa, + GameMapType.Britannia, + ] + : [GameMapType.World, GameMapType.Europe]; // Team mode + + // Find intersection + const viableMaps = suitableMaps.filter((map) => rankedMaps.includes(map)); + + // Pick best fit (prefer maps closest to player count) + return pickBestFit(viableMaps, playerCount); +} + +/** + * Get all maps that can handle the given player count + * Map is suitable if playerCount is between small and large capacity + */ +function getSuitableMaps(playerCount: number): GameMapType[] { + const suitable: GameMapType[] = []; + + for (const [mapKey, [large, , small]] of Object.entries(MAP_CAPACITIES)) { + const map = mapKey as GameMapType; + // Map can handle if playerCount is between small and large + if (playerCount >= small && playerCount <= large) { + suitable.push(map); + } + } + + return suitable; +} + +/** + * Pick the best fitting map based on player count + * Selects map where player count is closest to the middle of its capacity range + */ +function pickBestFit(maps: GameMapType[], playerCount: number): GameMapType { + if (maps.length === 0) { + // Fallback to World if no perfect match + return GameMapType.World; + } + + // Pick map where playerCount is closest to the middle of its range + let bestMap = maps[0]; + let bestScore = Infinity; + + for (const map of maps) { + const [large, , small] = MAP_CAPACITIES[map]; + const midPoint = (large + small) / 2; + const distance = Math.abs(playerCount - midPoint); + + if (distance < bestScore) { + bestScore = distance; + bestMap = map; + } + } + + return bestMap; +} diff --git a/src/server/MatchmakingPoller.ts b/src/server/MatchmakingPoller.ts new file mode 100644 index 000000000..3c6e3b87e --- /dev/null +++ b/src/server/MatchmakingPoller.ts @@ -0,0 +1,123 @@ +import { Logger } from "winston"; +import { ServerConfig } from "../core/configuration/Config"; +import { generateID, simpleHash } from "../core/Util"; + +export interface MatchAssignment { + players: string[]; // Player tokens + config: { + queueType: "ranked" | "unranked"; + gameMode: "ffa" | "team"; + playerCount: number; + teamConfig?: unknown; // TODO: define team config + }; +} + +export class MatchmakingPoller { + private serverId: string; + private isPolling: boolean = false; + + constructor( + private config: ServerConfig, + private log: Logger, + private onAssignment: ( + gameId: string, + assignment: MatchAssignment, + ) => Promise, + private getCCU: () => number, + ) { + this.serverId = `worker-${process.env.WORKER_ID ?? "0"}`; + } + + start() { + if (this.isPolling) return; + this.isPolling = true; + this.poll(); + } + + stop() { + this.isPolling = false; + } + + /** + * Generate a game ID that will hash to this worker + * This ensures clients connect to the correct worker + */ + private generateWorkerGameID(workerId: number, numWorkers: number): string { + // Keep generating IDs until we find one that hashes to this worker + let attempts = 0; + const maxAttempts = 1000; // Safety limit + while (attempts < maxAttempts) { + const id = generateID(); + if (simpleHash(id) % numWorkers === workerId) { + return id; + } + attempts++; + } + // Fallback: this should never happen, but return any ID + this.log.warn( + `Failed to generate ID for worker ${workerId} after ${maxAttempts} attempts`, + ); + return generateID(); + } + + private async poll() { + while (this.isPolling) { + try { + const workerId = parseInt(process.env.WORKER_ID ?? "0"); + const numWorkers = this.config.numWorkers(); + const gameId = this.generateWorkerGameID(workerId, numWorkers); + const ccu = this.getCCU(); + + this.log.info("Polling matchmaking service", { + id: workerId, + ccu: ccu, + }); + + const response = await fetch( + `${this.config.jwtIssuer()}/matchmaking/checkin`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": process.env.GAME_SERVER_API_KEY ?? "", + }, + body: JSON.stringify({ + id: workerId, + ccu: ccu, + gameId: gameId, + }), + }, + ); + + if (response.ok) { + const data = await response.json(); + + if (data.assignment) { + this.log.info("Received match assignment", { + gameId, + players: data.assignment.players.length, + }); + + await this.onAssignment(gameId, data.assignment); + } + } else { + this.log.error("Matchmaking check-in failed", { + status: response.status, + statusText: response.statusText, + }); + } + + // Wait before next poll (10 seconds) + await this.sleep(10000); + } catch (error) { + this.log.error("Matchmaking polling error", { error }); + // Wait before retrying on error + await this.sleep(5000); + } + } + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/server/RankedGameConfig.ts b/src/server/RankedGameConfig.ts new file mode 100644 index 000000000..73093574e --- /dev/null +++ b/src/server/RankedGameConfig.ts @@ -0,0 +1,69 @@ +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "../core/game/Game"; +import { GameConfig, TeamCountConfig } from "../core/Schemas"; + +export interface RankedMatchConfig { + queueType: "ranked" | "unranked"; + gameMode: "ffa" | "team"; + playerCount: number; + teamConfig?: unknown; +} + +/** + * Build a complete GameConfig for a ranked match + * Uses the same bot rules as public games (400 bots) + * Applies competitive settings appropriate for ranked play + */ +export function buildRankedGameConfig( + map: GameMapType, + matchConfig: RankedMatchConfig, +): GameConfig { + const { gameMode, playerCount } = matchConfig; + const mode = gameMode === "ffa" ? GameMode.FFA : GameMode.Team; + + return { + gameMap: map, + gameMapSize: selectMapSize(playerCount), + gameType: GameType.Public, + gameMode: mode, + maxPlayers: playerCount, + + bots: 400, + difficulty: Difficulty.Medium, + disableNPCs: false, + + // Donation rules + donateGold: mode === GameMode.Team, + donateTroops: mode === GameMode.Team, + + // Standard settings + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + maxTimerValue: undefined, + + // No disabled units in ranked + disabledUnits: [], + + // Team configuration + playerTeams: matchConfig.teamConfig as TeamCountConfig | undefined, + }; +} + +/** + * Select appropriate map size based on player count + * - Compact: 1-10 players + * - Normal: 11+ players + */ +function selectMapSize(playerCount: number): GameMapSize { + if (playerCount <= 10) { + return GameMapSize.Compact; + } else { + return GameMapSize.Normal; + } +} diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 996c9f9fe..03b8848ad 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -25,8 +25,12 @@ import { getUserMe, verifyClientToken } from "./jwt"; import { logger } from "./Logger"; import { GameEnv } from "../core/configuration/Config"; +import { GameMode } from "../core/game/Game"; import { MapPlaylist } from "./MapPlaylist"; +import { selectMapForRanked } from "./MapSelection"; +import { MatchmakingPoller } from "./MatchmakingPoller"; import { PrivilegeRefresher } from "./PrivilegeRefresher"; +import { buildRankedGameConfig } from "./RankedGameConfig"; import { verifyTurnstileToken } from "./Turnstile"; import { initWorkerMetrics } from "./WorkerMetrics"; @@ -71,6 +75,48 @@ export async function startWorker() { ); privilegeRefresher.start(); + // Matchmaking poller for ranked games + const matchmakingEnabled = process.env.MATCHMAKING_ENABLED === "true"; + + if (matchmakingEnabled) { + const matchmakingPoller = new MatchmakingPoller( + config, + log, + async (gameId, assignment) => { + // Select map based on player count + const selectedMap = selectMapForRanked({ + playerCount: assignment.config.playerCount, + gameMode: + assignment.config.gameMode === "ffa" ? GameMode.FFA : GameMode.Team, + queueType: assignment.config.queueType, + }); + + // Build full game config + const gameConfig = buildRankedGameConfig( + selectedMap, + assignment.config, + ); + + // Create game + gm.createGame(gameId, gameConfig); + + log.info("Created ranked match", { + gameId, + map: selectedMap, + mode: assignment.config.gameMode, + players: assignment.config.playerCount, + }); + + // TODO: Notify players to connect to this game + // Players already have gameId from Lobby websocket + }, + () => gm.activeClients(), // Get CCU from GameManager + ); + + matchmakingPoller.start(); + log.info("Matchmaking poller started"); + } + // Middleware to handle /wX path prefix app.use((req, res, next) => { // Extract the original path without the worker prefix