mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-02 07:38:12 +00:00
init implementation complete flow
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user