Files
OpenFrontIO/src/client/RankedQueue.ts
T
2025-12-30 00:07:32 +01:00

456 lines
14 KiB
TypeScript

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();
// Function to send join queue message
const sendJoinMessage = () => {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(
JSON.stringify({
type: "join_queue",
queueType: this.queueType,
gameMode: this.gameMode,
}),
);
}
};
// Send join queue message immediately if connected, or wait for connection
if (this.ws) {
if (this.ws.readyState === WebSocket.OPEN) {
sendJoinMessage();
} else if (this.ws.readyState === WebSocket.CONNECTING) {
// Wait for connection to open before sending join message
this.ws.addEventListener("open", () => sendJoinMessage(), {
once: true,
});
}
}
}
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>
`;
}
}