init implementation complete flow

This commit is contained in:
Trajkov Dimitar
2025-10-31 14:34:13 +01:00
parent ae81acd8e3
commit 5027083c48
9 changed files with 824 additions and 0 deletions
+3
View File
@@ -284,6 +284,9 @@
<div>
<matchmaking-button class="w-[20%] md:w-[15%]"></matchmaking-button>
</div>
<div>
<ranked-queue class="block"></ranked-queue>
</div>
<div class="container__row container__row--equal">
<o-button
id="host-lobby-button"
+21
View File
@@ -286,6 +286,27 @@
"searching": "Searching for game...",
"waiting_for_game": "Waiting for game to start..."
},
"ranked_queue": {
"ranked": "Ranked",
"unranked": "Unranked",
"join_queue": "Join Queue",
"join_ranked_queue": "Join Ranked Queue",
"leave_queue": "Leave Queue",
"searching": "Searching for Match...",
"players_in_queue": "players in queue",
"estimated_wait": "Estimated wait",
"connecting": "Connecting...",
"your_elo": "Your ELO:",
"loading_elo": "Loading ELO...",
"view_leaderboard": "View Leaderboard",
"hide_leaderboard": "Hide Leaderboard",
"loading_leaderboard": "Loading leaderboard...",
"no_ranked_players": "No ranked players yet",
"elo": "ELO",
"games": "games",
"wins_short": "W",
"losses_short": "L"
},
"username": {
"enter_username": "Enter your username",
"not_string": "Username must be a string.",
+1
View File
@@ -32,6 +32,7 @@ import { MatchmakingModal } from "./Matchmaking";
import "./NewsModal";
import "./PublicLobby";
import { PublicLobby } from "./PublicLobby";
import "./RankedQueue";
import { SinglePlayerModal } from "./SinglePlayerModal";
import "./StatsModal";
import { TerritoryPatternsModal } from "./TerritoryPatternsModal";
+441
View File
@@ -0,0 +1,441 @@
import { LitElement, html } from "lit";
import { customElement, state } from "lit/decorators.js";
import { generateID } from "../core/Util";
import { JoinLobbyEvent } from "./Main";
import { translateText } from "./Utils";
import { getApiBase, getUserMe, isLoggedIn } from "./jwt";
type QueueType = "ranked" | "unranked";
type GameMode = "ffa" | "team";
interface QueueStatus {
queueSize: number;
estimatedWaitTime?: number;
position?: number;
}
interface LeaderboardEntry {
rank: number;
username: string;
currentElo: number;
gamesPlayed: number;
wins: number;
losses: number;
}
@customElement("ranked-queue")
export class RankedQueue extends LitElement {
@state() private inQueue: boolean = false;
@state() private queueType: QueueType = "ranked";
@state() private gameMode: GameMode = "ffa";
@state() private queueStatus: QueueStatus | null = null;
@state() private playerElo: number | null = null;
@state() private isConnecting: boolean = false;
@state() private error: string | null = null;
@state() private isLoadingElo: boolean = false;
@state() private leaderboard: LeaderboardEntry[] = [];
@state() private isLoadingLeaderboard: boolean = false;
@state() private showLeaderboard: boolean = false;
private ws: WebSocket | null = null;
private clientID: string = generateID();
private reconnectAttempts: number = 0;
private maxReconnectAttempts: number = 3;
private reconnectTimeout: number | null = null;
createRenderRoot() {
return this;
}
async connectedCallback() {
super.connectedCallback();
// Fetch player ELO and leaderboard immediately when component loads
await Promise.all([this.fetchPlayerElo(), this.fetchLeaderboard()]);
}
disconnectedCallback() {
super.disconnectedCallback();
this.cleanup();
}
/**
* Fetch player's ELO rating from /users/@me endpoint
*/
private async fetchPlayerElo() {
this.isLoadingElo = true;
try {
const userMe = await getUserMe();
if (userMe !== false && userMe.player.elo !== undefined) {
this.playerElo = userMe.player.elo;
}
} catch (error) {
console.error("Failed to fetch player ELO:", error);
} finally {
this.isLoadingElo = false;
}
}
/**
* Fetch leaderboard via HTTP API
*/
private async fetchLeaderboard() {
this.isLoadingLeaderboard = true;
try {
const apiBase = getApiBase();
const response = await fetch(`${apiBase}/leaderboard/public/ffa`);
if (response.ok) {
const data = await response.json();
// Add rank to each entry
this.leaderboard = data.map((entry: any, index: number) => ({
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`
<div class="flex flex-col gap-3">
<!-- Join Queue Button -->
<button
@click=${this.inQueue
? () => this.leaveQueue()
: () => this.joinQueue()}
?disabled=${this.isConnecting}
class="w-full h-16 rounded-xl font-medium text-lg transition-opacity duration-200 ${this
.inQueue
? "bg-gradient-to-r from-red-600 to-red-500 hover:opacity-90"
: "bg-gradient-to-r from-purple-600 to-purple-500 hover:opacity-90"} text-white ${this
.isConnecting
? "opacity-50 cursor-not-allowed"
: ""}"
>
<div class="flex flex-col items-center justify-center">
<div>
${this.isConnecting
? translateText("ranked_queue.connecting")
: this.inQueue
? translateText("ranked_queue.leave_queue")
: translateText("ranked_queue.join_ranked_queue")}
</div>
${!this.inQueue && this.playerElo !== null
? html`<div class="text-sm mt-1 opacity-90">
${translateText("ranked_queue.your_elo")} ${this.playerElo}
</div>`
: !this.inQueue && this.isLoadingElo
? html`<div class="text-sm mt-1 opacity-90">
${translateText("ranked_queue.loading_elo")}
</div>`
: ""}
${this.error
? html`<div class="text-sm mt-1 text-red-200">${this.error}</div>`
: ""}
</div>
</button>
<!-- Leaderboard Toggle Button -->
<button
@click=${() => (this.showLeaderboard = !this.showLeaderboard)}
class="w-full py-2 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-sm font-medium transition-colors"
>
${this.showLeaderboard
? translateText("ranked_queue.hide_leaderboard")
: translateText("ranked_queue.view_leaderboard")}
</button>
<!-- Leaderboard Display -->
${this.showLeaderboard
? html`
<div
class="bg-gray-800 rounded-xl p-4 text-white max-h-96 overflow-y-auto"
>
${this.isLoadingLeaderboard
? html`<div class="text-center py-4">
${translateText("ranked_queue.loading_leaderboard")}
</div>`
: this.leaderboard.length === 0
? html`<div class="text-center py-4 text-gray-400">
${translateText("ranked_queue.no_ranked_players")}
</div>`
: html`
<div class="space-y-2">
${this.leaderboard.slice(0, 10).map(
(entry) => html`
<div
class="flex items-center justify-between p-2 bg-gray-700 rounded-lg hover:bg-gray-600 transition-colors"
>
<div class="flex items-center gap-3">
<div
class="font-bold text-lg ${entry.rank <= 3
? "text-yellow-400"
: "text-gray-400"}"
>
#${entry.rank}
</div>
<div>
<div class="font-medium">
${entry.username}
</div>
<div class="text-xs text-gray-400">
${entry.gamesPlayed}
${translateText("ranked_queue.games")}
${entry.wins}${translateText(
"ranked_queue.wins_short",
)}
${entry.losses}${translateText(
"ranked_queue.losses_short",
)}
</div>
</div>
</div>
<div class="text-right">
<div class="font-bold text-purple-400">
${entry.currentElo}
</div>
<div class="text-xs text-gray-400">
${translateText("ranked_queue.elo")}
</div>
</div>
</div>
`,
)}
</div>
`}
</div>
`
: ""}
</div>
`;
}
}
+1
View File
@@ -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<typeof UserMeResponseSchema>;
+119
View File
@@ -0,0 +1,119 @@
import { GameMapType, GameMode } from "../core/game/Game";
// import { TeamCountConfig } from "../core/Schemas";
const MAP_CAPACITIES: Record<GameMapType, [number, number, number]> = {
[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;
}
+123
View File
@@ -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<void>,
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
+69
View File
@@ -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;
}
}
+46
View File
@@ -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