mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 12:51:30 +00:00
add
This commit is contained in:
@@ -270,6 +270,7 @@
|
||||
<alert-frame></alert-frame>
|
||||
<chat-modal></chat-modal>
|
||||
<multi-tab-modal></multi-tab-modal>
|
||||
<map-vote-modal></map-vote-modal>
|
||||
<game-left-sidebar></game-left-sidebar>
|
||||
<performance-overlay></performance-overlay>
|
||||
<player-info-overlay></player-info-overlay>
|
||||
|
||||
@@ -331,7 +331,13 @@
|
||||
"teams_hvn_detailed": "{num} Humans vs {num} Nations",
|
||||
"teams": "{num} teams",
|
||||
"players_per_team": "of {num}",
|
||||
"started": "Started"
|
||||
"started": "Started",
|
||||
"current": "Current public lobby",
|
||||
"vote_button": "Vote on maps",
|
||||
"vote_title": "Map Voting",
|
||||
"vote_description": "Select any number of maps to influence the next public lobby.",
|
||||
"vote_saved": "Your selection is saved on this device.",
|
||||
"vote_login_required": "Log in to submit votes."
|
||||
},
|
||||
"matchmaking_modal": {
|
||||
"title": "1v1 Ranked Matchmaking (ALPHA)",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
|
||||
type LobbyUpdateHandler = (lobbies: GameInfo[]) => void;
|
||||
type VoteRequestHandler = () => void;
|
||||
|
||||
interface LobbySocketOptions {
|
||||
reconnectDelay?: number;
|
||||
maxWsAttempts?: number;
|
||||
pollIntervalMs?: number;
|
||||
onVoteRequest?: VoteRequestHandler;
|
||||
}
|
||||
|
||||
export class PublicLobbySocket {
|
||||
@@ -19,6 +22,8 @@ export class PublicLobbySocket {
|
||||
private readonly maxWsAttempts: number;
|
||||
private readonly pollIntervalMs: number;
|
||||
private readonly onLobbiesUpdate: LobbyUpdateHandler;
|
||||
private readonly onVoteRequest?: VoteRequestHandler;
|
||||
private pendingVote: { token: string; maps: GameMapType[] } | null = null;
|
||||
|
||||
constructor(
|
||||
onLobbiesUpdate: LobbyUpdateHandler,
|
||||
@@ -28,6 +33,7 @@ export class PublicLobbySocket {
|
||||
this.reconnectDelay = options?.reconnectDelay ?? 3000;
|
||||
this.maxWsAttempts = options?.maxWsAttempts ?? 3;
|
||||
this.pollIntervalMs = options?.pollIntervalMs ?? 1000;
|
||||
this.onVoteRequest = options?.onVoteRequest;
|
||||
}
|
||||
|
||||
start() {
|
||||
@@ -71,6 +77,7 @@ export class PublicLobbySocket {
|
||||
this.wsReconnectTimeout = null;
|
||||
}
|
||||
this.stopFallbackPolling();
|
||||
this.flushPendingVote();
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent) {
|
||||
@@ -78,6 +85,8 @@ export class PublicLobbySocket {
|
||||
const message = JSON.parse(event.data as string);
|
||||
if (message.type === "lobbies_update") {
|
||||
this.onLobbiesUpdate(message.data?.lobbies ?? []);
|
||||
} else if (message.type === "map_vote_request") {
|
||||
this.onVoteRequest?.();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing WebSocket message:", error);
|
||||
@@ -114,7 +123,7 @@ export class PublicLobbySocket {
|
||||
console.error("WebSocket error:", error);
|
||||
}
|
||||
|
||||
private handleConnectError(error: unknown) {
|
||||
private handleConnectError(error: Error | Event | string) {
|
||||
console.error("Error connecting WebSocket:", error);
|
||||
if (!this.wsAttemptCounted) {
|
||||
this.wsAttemptCounted = true;
|
||||
@@ -162,6 +171,31 @@ export class PublicLobbySocket {
|
||||
}
|
||||
}
|
||||
|
||||
public sendMapVote(token: string, maps: GameMapType[]) {
|
||||
this.pendingVote = { token, maps };
|
||||
this.flushPendingVote();
|
||||
}
|
||||
|
||||
public clearMapVote() {
|
||||
this.pendingVote = null;
|
||||
}
|
||||
|
||||
private flushPendingVote() {
|
||||
if (!this.pendingVote) return;
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
|
||||
try {
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: "map_vote",
|
||||
token: this.pendingVote.token,
|
||||
maps: this.pendingVote.maps,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to send map vote:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchLobbiesHTTP() {
|
||||
try {
|
||||
const response = await fetch(`/api/public_lobbies`);
|
||||
|
||||
@@ -26,6 +26,7 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal";
|
||||
import "./LangSelector";
|
||||
import { LangSelector } from "./LangSelector";
|
||||
import { initLayout } from "./Layout";
|
||||
import { MapVoteModal } from "./MapVoteModal";
|
||||
import "./Matchmaking";
|
||||
import { MatchmakingModal } from "./Matchmaking";
|
||||
import { initNavigation } from "./Navigation";
|
||||
@@ -222,6 +223,7 @@ class Client {
|
||||
private patternsModal: TerritoryPatternsModal;
|
||||
private tokenLoginModal: TokenLoginModal;
|
||||
private matchmakingModal: MatchmakingModal;
|
||||
private mapVoteModal: MapVoteModal | null = null;
|
||||
|
||||
private gutterAds: GutterAds;
|
||||
|
||||
@@ -274,6 +276,13 @@ class Client {
|
||||
console.warn("Username input element not found");
|
||||
}
|
||||
|
||||
this.mapVoteModal = document.querySelector(
|
||||
"map-vote-modal",
|
||||
) as MapVoteModal;
|
||||
if (!this.mapVoteModal) {
|
||||
console.warn("Map vote modal element not found");
|
||||
}
|
||||
|
||||
this.publicLobby = document.querySelector("public-lobby") as PublicLobby;
|
||||
|
||||
window.addEventListener("beforeunload", async () => {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { TemplateResult, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { translateText } from "../client/Utils";
|
||||
import type { UserMeResponse } from "../core/ApiSchemas";
|
||||
import { GameMapType, mapCategories } from "../core/game/Game";
|
||||
import { publicLobbyMaps } from "../core/game/PublicLobbyMaps";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
import "./components/baseComponents/Modal";
|
||||
import { BaseModal } from "./components/BaseModal";
|
||||
import "./components/Maps";
|
||||
import { modalHeader } from "./components/ui/ModalHeader";
|
||||
import { loadStoredMapVotes, saveStoredMapVotes } from "./MapVoteStorage";
|
||||
|
||||
@customElement("map-vote-modal")
|
||||
export class MapVoteModal extends BaseModal {
|
||||
@property({ type: Boolean }) loggedIn = false;
|
||||
@state() private selectedMaps = new Set<GameMapType>();
|
||||
|
||||
private readonly availableMaps = new Set(publicLobbyMaps);
|
||||
private handleUserMeResponse = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<UserMeResponse | false>;
|
||||
this.loggedIn = hasLinkedAccount(customEvent.detail);
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = "map-vote-modal";
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("userMeResponse", this.handleUserMeResponse);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
document.removeEventListener("userMeResponse", this.handleUserMeResponse);
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
protected onOpen(): void {
|
||||
this.selectedMaps = new Set(loadStoredMapVotes());
|
||||
}
|
||||
|
||||
private toggleMapSelection(map: GameMapType) {
|
||||
const next = new Set(this.selectedMaps);
|
||||
if (next.has(map)) {
|
||||
next.delete(map);
|
||||
} else {
|
||||
next.add(map);
|
||||
}
|
||||
this.selectedMaps = next;
|
||||
saveStoredMapVotes(Array.from(next));
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("map-vote-change", {
|
||||
detail: { maps: Array.from(next) },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private renderCategory(
|
||||
categoryKey: string,
|
||||
maps: GameMapType[],
|
||||
): TemplateResult {
|
||||
if (maps.length === 0) return html``;
|
||||
return html`
|
||||
<div class="w-full">
|
||||
<h4
|
||||
class="text-xs font-bold text-white/40 uppercase tracking-widest mb-4 pl-2"
|
||||
>
|
||||
${translateText(`map_categories.${categoryKey}`)}
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
${maps.map((mapValue) => {
|
||||
const mapKey = Object.entries(GameMapType).find(
|
||||
([, v]) => v === mapValue,
|
||||
)?.[0];
|
||||
return html`
|
||||
<div
|
||||
@click=${() => this.toggleMapSelection(mapValue)}
|
||||
class="cursor-pointer transition-transform duration-200 active:scale-95"
|
||||
>
|
||||
<map-display
|
||||
.mapKey=${mapKey}
|
||||
.selected=${this.selectedMaps.has(mapValue)}
|
||||
.translation=${translateText(`map.${mapKey?.toLowerCase()}`)}
|
||||
></map-display>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const categoryEntries = Object.entries(mapCategories) as Array<
|
||||
[string, GameMapType[]]
|
||||
>;
|
||||
const categories: Array<[string, GameMapType[]]> = categoryEntries
|
||||
.map(
|
||||
([categoryKey, maps]) =>
|
||||
[categoryKey, maps.filter((map) => this.availableMaps.has(map))] as [
|
||||
string,
|
||||
GameMapType[],
|
||||
],
|
||||
)
|
||||
.filter(([, maps]) => maps.length > 0);
|
||||
const loginBanner = this.loggedIn
|
||||
? undefined
|
||||
: html`<div
|
||||
class="px-3 py-2 text-xs font-bold uppercase tracking-wider transition-colors duration-200 rounded-lg bg-yellow-500/20 text-yellow-400 border border-yellow-500/30 whitespace-nowrap shrink-0"
|
||||
>
|
||||
${translateText("public_lobby.vote_login_required")}
|
||||
</div>`;
|
||||
|
||||
const content = html`
|
||||
<div class="h-full flex flex-col overflow-hidden select-none">
|
||||
${modalHeader({
|
||||
title: translateText("public_lobby.vote_title"),
|
||||
onBack: () => this.close(),
|
||||
ariaLabel: translateText("common.back"),
|
||||
rightContent: loginBanner,
|
||||
})}
|
||||
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar px-6 pb-6 mr-1">
|
||||
<div class="max-w-5xl mx-auto space-y-6 pt-4">
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
class="flex items-center gap-4 pb-2 border-b border-white/10"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center text-blue-400"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="w-5 h-5"
|
||||
>
|
||||
<path
|
||||
d="M21.731 2.269a2.625 2.625 0 00-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 000-3.712zM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 00-1.32 2.214l-.8 2.685a.75.75 0 00.933.933l2.685-.8a5.25 5.25 0 002.214-1.32L19.513 8.2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-white uppercase tracking-wider"
|
||||
>
|
||||
${translateText("map.map")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 text-sm text-white/70">
|
||||
<p>${translateText("public_lobby.vote_description")}</p>
|
||||
<p class="text-white/50">
|
||||
${translateText("public_lobby.vote_saved")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
${categories.map(([categoryKey, maps]) =>
|
||||
this.renderCategory(categoryKey, maps),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (this.inline) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return html`
|
||||
<o-modal
|
||||
title=""
|
||||
?hideCloseButton=${true}
|
||||
?inline=${this.inline}
|
||||
hideHeader
|
||||
>
|
||||
${content}
|
||||
</o-modal>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { publicLobbyMaps } from "../core/game/PublicLobbyMaps";
|
||||
|
||||
const MAP_VOTE_STORAGE_KEY = "publicLobby.mapVotes";
|
||||
|
||||
export function loadStoredMapVotes(): GameMapType[] {
|
||||
if (typeof localStorage === "undefined") return [];
|
||||
try {
|
||||
const raw = localStorage.getItem(MAP_VOTE_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
const allowedMaps = new Set(publicLobbyMaps);
|
||||
return parsed.filter(
|
||||
(map): map is GameMapType =>
|
||||
typeof map === "string" && allowedMaps.has(map as GameMapType),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("Failed to read map votes from localStorage:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStoredMapVotes(maps: GameMapType[]): void {
|
||||
if (typeof localStorage === "undefined") return;
|
||||
try {
|
||||
const allowedMaps = new Set(publicLobbyMaps);
|
||||
const unique = Array.from(
|
||||
new Set(maps.filter((map) => allowedMaps.has(map))),
|
||||
);
|
||||
localStorage.setItem(MAP_VOTE_STORAGE_KEY, JSON.stringify(unique));
|
||||
} catch (error) {
|
||||
console.warn("Failed to save map votes to localStorage:", error);
|
||||
}
|
||||
}
|
||||
+218
-126
@@ -1,6 +1,7 @@
|
||||
import { html, LitElement } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
import { renderDuration, translateText } from "../client/Utils";
|
||||
import type { UserMeResponse } from "../core/ApiSchemas";
|
||||
import {
|
||||
Duos,
|
||||
GameMapType,
|
||||
@@ -12,8 +13,12 @@ import {
|
||||
} from "../core/game/Game";
|
||||
import { GameID, GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { hasLinkedAccount } from "./Api";
|
||||
import { userAuth } from "./Auth";
|
||||
import { PublicLobbySocket } from "./LobbySocket";
|
||||
import { JoinLobbyEvent } from "./Main";
|
||||
import "./MapVoteModal";
|
||||
import { loadStoredMapVotes } from "./MapVoteStorage";
|
||||
import { terrainMapFileLoader } from "./TerrainMapFileLoader";
|
||||
|
||||
@customElement("public-lobby")
|
||||
@@ -23,14 +28,34 @@ export class PublicLobby extends LitElement {
|
||||
@state() private isButtonDebounced: boolean = false;
|
||||
@state() private mapImages: Map<GameID, string> = new Map();
|
||||
@state() private joiningDotIndex: number = 0;
|
||||
@state() private isLoggedIn: boolean = false;
|
||||
|
||||
private joiningInterval: number | null = null;
|
||||
private currLobby: GameInfo | null = null;
|
||||
private debounceDelay: number = 150;
|
||||
private lobbyIDToStart = new Map<GameID, number>();
|
||||
private lobbySocket = new PublicLobbySocket((lobbies) =>
|
||||
this.handleLobbiesUpdate(lobbies),
|
||||
private lobbySocket = new PublicLobbySocket(
|
||||
(lobbies) => this.handleLobbiesUpdate(lobbies),
|
||||
{
|
||||
onVoteRequest: () => {
|
||||
void this.sendStoredVotes();
|
||||
},
|
||||
},
|
||||
);
|
||||
private handleUserMeResponse = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<UserMeResponse | false>;
|
||||
this.isLoggedIn = hasLinkedAccount(customEvent.detail);
|
||||
if (this.isLoggedIn) {
|
||||
void this.sendStoredVotes();
|
||||
} else {
|
||||
this.lobbySocket.clearMapVote();
|
||||
}
|
||||
};
|
||||
private handleMapVoteChangeEvent = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ maps: GameMapType[] }>;
|
||||
if (!customEvent.detail?.maps) return;
|
||||
void this.handleMapVoteChange(customEvent);
|
||||
};
|
||||
|
||||
createRenderRoot() {
|
||||
return this;
|
||||
@@ -38,15 +63,45 @@ export class PublicLobby extends LitElement {
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.addEventListener("userMeResponse", this.handleUserMeResponse);
|
||||
document.addEventListener("map-vote-change", this.handleMapVoteChangeEvent);
|
||||
this.lobbySocket.start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("userMeResponse", this.handleUserMeResponse);
|
||||
document.removeEventListener(
|
||||
"map-vote-change",
|
||||
this.handleMapVoteChangeEvent,
|
||||
);
|
||||
this.lobbySocket.stop();
|
||||
this.stopJoiningAnimation();
|
||||
}
|
||||
|
||||
private async sendStoredVotes() {
|
||||
if (!this.isLoggedIn) return;
|
||||
const auth = await userAuth();
|
||||
if (!auth) return;
|
||||
this.lobbySocket.sendMapVote(auth.jwt, loadStoredMapVotes());
|
||||
}
|
||||
|
||||
private async handleMapVoteChange(
|
||||
event: CustomEvent<{ maps: GameMapType[] }>,
|
||||
) {
|
||||
if (!this.isLoggedIn) return;
|
||||
const auth = await userAuth();
|
||||
if (!auth) return;
|
||||
this.lobbySocket.sendMapVote(auth.jwt, event.detail.maps);
|
||||
}
|
||||
|
||||
private openMapVoteModal() {
|
||||
const modal = document.querySelector("map-vote-modal") as
|
||||
| (HTMLElement & { open: () => void })
|
||||
| null;
|
||||
modal?.open();
|
||||
}
|
||||
|
||||
private handleLobbiesUpdate(lobbies: GameInfo[]) {
|
||||
this.lobbies = lobbies;
|
||||
this.lobbies.forEach((l) => {
|
||||
@@ -121,131 +176,168 @@ export class PublicLobby extends LitElement {
|
||||
const mapImageSrc = this.mapImages.get(lobby.gameID);
|
||||
|
||||
return html`
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
?disabled=${this.isButtonDebounced}
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
|
||||
.isLobbyHighlighted
|
||||
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
|
||||
: "hover:scale-[1.01]"} active:scale-[0.98] ${this.isButtonDebounced
|
||||
? "cursor-not-allowed"
|
||||
: ""}"
|
||||
>
|
||||
<div class="font-sans w-full h-full flex flex-col">
|
||||
<!-- Main card gradient - stops before text -->
|
||||
<div class="absolute inset-0 pointer-events-none z-10"></div>
|
||||
|
||||
<!-- Map Image Area with gradient overlay -->
|
||||
<div class="flex-1 w-full relative overflow-hidden">
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="absolute inset-0 w-full h-full object-cover object-center z-10"
|
||||
/>`
|
||||
: ""}
|
||||
<!-- Vignette overlay for dark edges -->
|
||||
<div class="pointer-events-none absolute inset-0 z-20"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Badge in top left -->
|
||||
${fullModeLabel
|
||||
? html`<span
|
||||
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
|
||||
>
|
||||
${fullModeLabel}
|
||||
</span>`
|
||||
: ""}
|
||||
|
||||
<!-- Timer in top right -->
|
||||
${timeRemaining > 0
|
||||
? html`
|
||||
<span
|
||||
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base tracking-widest z-30 bg-blue-600 text-white"
|
||||
>
|
||||
${timeDisplay}
|
||||
</span>
|
||||
`
|
||||
: html`<span
|
||||
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-green-600 text-white"
|
||||
>
|
||||
${translateText("public_lobby.started")}
|
||||
</span>`}
|
||||
|
||||
<!-- Content Banner -->
|
||||
<div class="absolute bottom-0 left-0 right-0 z-20">
|
||||
<!-- Modifier badges placed just above the gradient overlay -->
|
||||
${modifierLabel.length > 0
|
||||
? html`<div
|
||||
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
|
||||
>
|
||||
${modifierLabel.map(
|
||||
(label) => html`
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
|
||||
>
|
||||
${label}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Gradient overlay for text area - adds extra darkening -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-black/60 to-black/90 pointer-events-none"
|
||||
></div>
|
||||
|
||||
<div class="relative p-6 flex flex-col gap-2 text-left">
|
||||
<!-- Header row: Status/Join on left, Player Count on right -->
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-base uppercase tracking-widest text-white">
|
||||
${this.currLobby
|
||||
? isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText("public_lobby.starting_game")}</span
|
||||
>`
|
||||
: html`<span class="text-orange-400"
|
||||
>${translateText("public_lobby.waiting_for_players")}
|
||||
${[0, 1, 2]
|
||||
.map((i) =>
|
||||
i === this.joiningDotIndex ? "•" : "·",
|
||||
)
|
||||
.join("")}</span
|
||||
>`
|
||||
: html`${translateText("public_lobby.join")}`}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-white z-30">
|
||||
<span class="text-base font-bold uppercase tracking-widest"
|
||||
>${lobby.numClients}/${lobby.gameConfig.maxPlayers}</span
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Name - Full Width -->
|
||||
<div
|
||||
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
|
||||
>
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- modifiers moved above gradient overlay -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs uppercase tracking-widest text-white/60 px-1"
|
||||
>
|
||||
<span>${translateText("public_lobby.current")}</span>
|
||||
<button
|
||||
class="group flex items-center justify-center w-7 h-7 rounded-full border border-white/10 bg-slate-900/70 hover:bg-slate-800/90 text-white/80 hover:text-white transition-colors"
|
||||
@click=${this.openMapVoteModal}
|
||||
type="button"
|
||||
aria-label=${translateText("public_lobby.vote_button")}
|
||||
title=${translateText("public_lobby.vote_button")}
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.707a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div class="relative">
|
||||
<button
|
||||
@click=${() => this.lobbyClicked(lobby)}
|
||||
?disabled=${this.isButtonDebounced}
|
||||
class="group relative isolate flex flex-col w-full h-80 lg:h-96 overflow-hidden rounded-2xl transition-all duration-200 bg-[#3d7bab] ${this
|
||||
.isLobbyHighlighted
|
||||
? "ring-2 ring-blue-600 scale-[1.01] opacity-70"
|
||||
: "hover:scale-[1.01]"} active:scale-[0.98] ${this
|
||||
.isButtonDebounced
|
||||
? "cursor-not-allowed"
|
||||
: ""}"
|
||||
>
|
||||
<div class="font-sans w-full h-full flex flex-col">
|
||||
<!-- Main card gradient - stops before text -->
|
||||
<div class="absolute inset-0 pointer-events-none z-10"></div>
|
||||
|
||||
<!-- Map Image Area with gradient overlay -->
|
||||
<div class="flex-1 w-full relative overflow-hidden">
|
||||
${mapImageSrc
|
||||
? html`<img
|
||||
src="${mapImageSrc}"
|
||||
alt="${lobby.gameConfig.gameMap}"
|
||||
class="absolute inset-0 w-full h-full object-cover object-center z-10"
|
||||
/>`
|
||||
: ""}
|
||||
<!-- Vignette overlay for dark edges -->
|
||||
<div class="pointer-events-none absolute inset-0 z-20"></div>
|
||||
</div>
|
||||
|
||||
<!-- Mode Badge in top left -->
|
||||
${fullModeLabel
|
||||
? html`<span
|
||||
class="absolute top-4 left-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-slate-800 text-white ring-1 ring-white/10 shadow-sm"
|
||||
>
|
||||
${fullModeLabel}
|
||||
</span>`
|
||||
: ""}
|
||||
|
||||
<!-- Timer in top right -->
|
||||
${timeRemaining > 0
|
||||
? html`
|
||||
<span
|
||||
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base tracking-widest z-30 bg-blue-600 text-white"
|
||||
>
|
||||
${timeDisplay}
|
||||
</span>
|
||||
`
|
||||
: html`<span
|
||||
class="absolute top-4 right-4 px-4 py-1 rounded font-bold text-sm lg:text-base uppercase tracking-widest z-30 bg-green-600 text-white"
|
||||
>
|
||||
${translateText("public_lobby.started")}
|
||||
</span>`}
|
||||
|
||||
<!-- Content Banner -->
|
||||
<div class="absolute bottom-0 left-0 right-0 z-20">
|
||||
<!-- Modifier badges placed just above the gradient overlay -->
|
||||
${modifierLabel.length > 0
|
||||
? html`<div
|
||||
class="absolute -top-8 left-4 z-30 flex gap-2 flex-wrap"
|
||||
>
|
||||
${modifierLabel.map(
|
||||
(label) => html`
|
||||
<span
|
||||
class="px-2 py-0.5 rounded text-xs font-medium uppercase tracking-wide bg-purple-600 text-white"
|
||||
>
|
||||
${label}
|
||||
</span>
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: html``}
|
||||
|
||||
<!-- Gradient overlay for text area - adds extra darkening -->
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-b from-black/60 to-black/90 pointer-events-none"
|
||||
></div>
|
||||
|
||||
<div class="relative p-6 flex flex-col gap-2 text-left">
|
||||
<!-- Header row: Status/Join on left, Player Count on right -->
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="text-base uppercase tracking-widest text-white">
|
||||
${this.currLobby
|
||||
? isStarting
|
||||
? html`<span class="text-green-400 animate-pulse"
|
||||
>${translateText(
|
||||
"public_lobby.starting_game",
|
||||
)}</span
|
||||
>`
|
||||
: html`<span class="text-orange-400"
|
||||
>${translateText(
|
||||
"public_lobby.waiting_for_players",
|
||||
)}
|
||||
${[0, 1, 2]
|
||||
.map((i) =>
|
||||
i === this.joiningDotIndex ? "•" : "·",
|
||||
)
|
||||
.join("")}</span
|
||||
>`
|
||||
: html`${translateText("public_lobby.join")}`}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-white z-30">
|
||||
<span
|
||||
class="text-base font-bold uppercase tracking-widest"
|
||||
>${lobby.numClients}/${lobby.gameConfig
|
||||
.maxPlayers}</span
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-white"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3zM6 8a2 2 0 11-4 0 2 2 0 014 0zM16 18v-3a5.972 5.972 0 00-.75-2.906A3.005 3.005 0 0119 15v3h-3zM4.75 12.094A5.973 5.973 0 004 15v3H1v-3a3 3 0 013.75-2.906z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Name - Full Width -->
|
||||
<div
|
||||
class="text-2xl lg:text-3xl font-bold text-white leading-none uppercase tracking-widest w-full"
|
||||
>
|
||||
${translateText(
|
||||
`map.${lobby.gameConfig.gameMap.toLowerCase().replace(/[\s.]+/g, "")}`,
|
||||
)}
|
||||
</div>
|
||||
|
||||
<!-- modifiers moved above gradient overlay -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { GameMapType } from "./Game";
|
||||
|
||||
export const publicLobbyMapWeights: Partial<Record<GameMapType, number>> = {
|
||||
[GameMapType.Africa]: 7,
|
||||
[GameMapType.Asia]: 6,
|
||||
[GameMapType.Australia]: 4,
|
||||
[GameMapType.Achiran]: 5,
|
||||
[GameMapType.Baikal]: 5,
|
||||
[GameMapType.BetweenTwoSeas]: 5,
|
||||
[GameMapType.BlackSea]: 6,
|
||||
[GameMapType.Britannia]: 5,
|
||||
[GameMapType.BritanniaClassic]: 4,
|
||||
[GameMapType.DeglaciatedAntarctica]: 4,
|
||||
[GameMapType.EastAsia]: 5,
|
||||
[GameMapType.Europe]: 3,
|
||||
[GameMapType.EuropeClassic]: 3,
|
||||
[GameMapType.FalklandIslands]: 4,
|
||||
[GameMapType.FaroeIslands]: 4,
|
||||
[GameMapType.FourIslands]: 4,
|
||||
[GameMapType.GatewayToTheAtlantic]: 5,
|
||||
[GameMapType.GulfOfStLawrence]: 4,
|
||||
[GameMapType.Halkidiki]: 4,
|
||||
[GameMapType.Iceland]: 4,
|
||||
[GameMapType.Italia]: 6,
|
||||
[GameMapType.Japan]: 6,
|
||||
[GameMapType.Lisbon]: 4,
|
||||
[GameMapType.Manicouagan]: 4,
|
||||
[GameMapType.Mars]: 3,
|
||||
[GameMapType.Mena]: 6,
|
||||
[GameMapType.Montreal]: 6,
|
||||
[GameMapType.NewYorkCity]: 3,
|
||||
[GameMapType.NorthAmerica]: 5,
|
||||
[GameMapType.Pangaea]: 5,
|
||||
[GameMapType.Pluto]: 6,
|
||||
[GameMapType.SouthAmerica]: 5,
|
||||
[GameMapType.StraitOfGibraltar]: 5,
|
||||
[GameMapType.Svalmel]: 8,
|
||||
[GameMapType.World]: 8,
|
||||
[GameMapType.Lemnos]: 3,
|
||||
[GameMapType.TwoLakes]: 6,
|
||||
[GameMapType.StraitOfHormuz]: 4,
|
||||
[GameMapType.Surrounded]: 4,
|
||||
[GameMapType.DidierFrance]: 1,
|
||||
[GameMapType.AmazonRiver]: 3,
|
||||
[GameMapType.Sierpinski]: 10,
|
||||
};
|
||||
|
||||
export const publicLobbyMaps: GameMapType[] = Object.keys(publicLobbyMapWeights)
|
||||
.filter((map) => (publicLobbyMapWeights[map as GameMapType] ?? 0) > 0)
|
||||
.map((map) => map as GameMapType);
|
||||
|
||||
export const getPublicLobbyMapWeight = (map: GameMapType): number =>
|
||||
publicLobbyMapWeights[map] ?? 0;
|
||||
+72
-120
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Difficulty,
|
||||
Duos,
|
||||
GameMapName,
|
||||
GameMapSize,
|
||||
GameMapType,
|
||||
GameMode,
|
||||
@@ -12,60 +11,13 @@ import {
|
||||
RankedType,
|
||||
Trios,
|
||||
} from "../core/game/Game";
|
||||
import { PseudoRandom } from "../core/PseudoRandom";
|
||||
import {
|
||||
getPublicLobbyMapWeight,
|
||||
publicLobbyMaps,
|
||||
} from "../core/game/PublicLobbyMaps";
|
||||
import { GameConfig, TeamCountConfig } from "../core/Schemas";
|
||||
import { logger } from "./Logger";
|
||||
import { getMapLandTiles } from "./MapLandTiles";
|
||||
|
||||
const log = logger.child({});
|
||||
|
||||
// How many times each map should appear in the playlist.
|
||||
// Note: The Partial should eventually be removed for better type safety.
|
||||
const frequency: Partial<Record<GameMapName, number>> = {
|
||||
Africa: 7,
|
||||
Asia: 6,
|
||||
Australia: 4,
|
||||
Achiran: 5,
|
||||
Baikal: 5,
|
||||
BetweenTwoSeas: 5,
|
||||
BlackSea: 6,
|
||||
Britannia: 5,
|
||||
BritanniaClassic: 4,
|
||||
DeglaciatedAntarctica: 4,
|
||||
EastAsia: 5,
|
||||
Europe: 3,
|
||||
EuropeClassic: 3,
|
||||
FalklandIslands: 4,
|
||||
FaroeIslands: 4,
|
||||
FourIslands: 4,
|
||||
GatewayToTheAtlantic: 5,
|
||||
GulfOfStLawrence: 4,
|
||||
Halkidiki: 4,
|
||||
Iceland: 4,
|
||||
Italia: 6,
|
||||
Japan: 6,
|
||||
Lisbon: 4,
|
||||
Manicouagan: 4,
|
||||
Mars: 3,
|
||||
Mena: 6,
|
||||
Montreal: 6,
|
||||
NewYorkCity: 3,
|
||||
NorthAmerica: 5,
|
||||
Pangaea: 5,
|
||||
Pluto: 6,
|
||||
SouthAmerica: 5,
|
||||
StraitOfGibraltar: 5,
|
||||
Svalmel: 8,
|
||||
World: 8,
|
||||
Lemnos: 3,
|
||||
TwoLakes: 6,
|
||||
StraitOfHormuz: 4,
|
||||
Surrounded: 4,
|
||||
DidierFrance: 1,
|
||||
AmazonRiver: 3,
|
||||
Sierpinski: 10,
|
||||
};
|
||||
|
||||
interface MapWithMode {
|
||||
map: GameMapType;
|
||||
mode: GameMode;
|
||||
@@ -85,12 +37,21 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [
|
||||
];
|
||||
|
||||
export class MapPlaylist {
|
||||
private mapsPlaylist: MapWithMode[] = [];
|
||||
private recentMaps: GameMapType[] = [];
|
||||
private modeSequenceIndex = 0;
|
||||
private readonly maxRecentMaps = 5;
|
||||
private readonly modeSequence: GameMode[];
|
||||
|
||||
constructor(private disableTeams: boolean = false) {}
|
||||
constructor(private disableTeams: boolean = false) {
|
||||
this.modeSequence = disableTeams
|
||||
? [GameMode.FFA]
|
||||
: [GameMode.FFA, GameMode.Team, GameMode.FFA];
|
||||
}
|
||||
|
||||
public async gameConfig(): Promise<GameConfig> {
|
||||
const { map, mode } = this.getNextMap();
|
||||
public async gameConfig(
|
||||
mapVotes?: Map<GameMapType, number>,
|
||||
): Promise<GameConfig> {
|
||||
const { map, mode } = this.getNextMap(mapVotes);
|
||||
|
||||
const playerTeams =
|
||||
mode === GameMode.Team ? this.getTeamCount() : undefined;
|
||||
@@ -176,19 +137,62 @@ export class MapPlaylist {
|
||||
} satisfies GameConfig;
|
||||
}
|
||||
|
||||
private getNextMap(): MapWithMode {
|
||||
if (this.mapsPlaylist.length === 0) {
|
||||
const numAttempts = 10000;
|
||||
for (let i = 0; i < numAttempts; i++) {
|
||||
if (this.shuffleMapsPlaylist()) {
|
||||
log.info(`Generated map playlist in ${i} attempts`);
|
||||
return this.mapsPlaylist.shift()!;
|
||||
}
|
||||
}
|
||||
log.error("Failed to generate a valid map playlist");
|
||||
private getNextMap(mapVotes?: Map<GameMapType, number>): MapWithMode {
|
||||
const mode = this.getNextMode();
|
||||
const map = this.getWeightedMap(mapVotes);
|
||||
return { map, mode };
|
||||
}
|
||||
|
||||
private getNextMode(): GameMode {
|
||||
const mode = this.modeSequence[this.modeSequenceIndex];
|
||||
this.modeSequenceIndex =
|
||||
(this.modeSequenceIndex + 1) % this.modeSequence.length;
|
||||
return mode;
|
||||
}
|
||||
|
||||
private getWeightedMap(mapVotes?: Map<GameMapType, number>): GameMapType {
|
||||
const weightedMaps = publicLobbyMaps
|
||||
.map((map) => ({
|
||||
map,
|
||||
weight: getPublicLobbyMapWeight(map) + (mapVotes?.get(map) ?? 0),
|
||||
}))
|
||||
.filter(({ weight }) => weight > 0);
|
||||
|
||||
if (weightedMaps.length === 0) {
|
||||
return publicLobbyMaps[0] ?? GameMapType.World;
|
||||
}
|
||||
// Even if it failed, playlist will be partially populated.
|
||||
return this.mapsPlaylist.shift()!;
|
||||
|
||||
const recentSet = new Set(this.recentMaps);
|
||||
const hasNonRecent = weightedMaps.some(({ map }) => !recentSet.has(map));
|
||||
const candidateMaps = hasNonRecent
|
||||
? weightedMaps.filter(({ map }) => !recentSet.has(map))
|
||||
: weightedMaps;
|
||||
const selected = this.pickWeightedMap(candidateMaps);
|
||||
|
||||
this.recentMaps.push(selected);
|
||||
if (this.recentMaps.length > this.maxRecentMaps) {
|
||||
this.recentMaps.shift();
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
private pickWeightedMap(
|
||||
weightedMaps: Array<{ map: GameMapType; weight: number }>,
|
||||
): GameMapType {
|
||||
const totalWeight = weightedMaps.reduce(
|
||||
(sum, { weight }) => sum + weight,
|
||||
0,
|
||||
);
|
||||
const roll = Math.random() * totalWeight;
|
||||
let cumulativeWeight = 0;
|
||||
for (const { map, weight } of weightedMaps) {
|
||||
cumulativeWeight += weight;
|
||||
if (roll < cumulativeWeight) {
|
||||
return map;
|
||||
}
|
||||
}
|
||||
return weightedMaps[0]?.map ?? GameMapType.World;
|
||||
}
|
||||
|
||||
private getTeamCount(): TeamCountConfig {
|
||||
@@ -278,56 +282,4 @@ export class MapPlaylist {
|
||||
roundToNearest5(limitedBase * 0.5),
|
||||
];
|
||||
}
|
||||
|
||||
private shuffleMapsPlaylist(): boolean {
|
||||
const maps: GameMapType[] = [];
|
||||
(Object.keys(GameMapType) as GameMapName[]).forEach((key) => {
|
||||
for (let i = 0; i < (frequency[key] ?? 0); i++) {
|
||||
maps.push(GameMapType[key]);
|
||||
}
|
||||
});
|
||||
|
||||
const rand = new PseudoRandom(Date.now());
|
||||
|
||||
const ffa1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const team1: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
const ffa2: GameMapType[] = rand.shuffleArray([...maps]);
|
||||
|
||||
this.mapsPlaylist = [];
|
||||
for (let i = 0; i < maps.length; i++) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa1, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
if (!this.disableTeams) {
|
||||
if (!this.addNextMap(this.mapsPlaylist, team1, GameMode.Team)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!this.addNextMap(this.mapsPlaylist, ffa2, GameMode.FFA)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private addNextMap(
|
||||
playlist: MapWithMode[],
|
||||
nextEls: GameMapType[],
|
||||
mode: GameMode,
|
||||
): boolean {
|
||||
const nonConsecutiveNum = 5;
|
||||
const lastEls = playlist
|
||||
.slice(playlist.length - nonConsecutiveNum)
|
||||
.map((m) => m.map);
|
||||
for (let i = 0; i < nextEls.length; i++) {
|
||||
const next = nextEls[i];
|
||||
if (lastEls.includes(next)) {
|
||||
continue;
|
||||
}
|
||||
nextEls.splice(i, 1);
|
||||
playlist.push({ map: next, mode: mode });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+132
-1
@@ -6,10 +6,14 @@ import http from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocket, WebSocketServer } from "ws";
|
||||
import { z } from "zod";
|
||||
import { GameEnv } from "../core/configuration/Config";
|
||||
import { getServerConfigFromServer } from "../core/configuration/ConfigLoader";
|
||||
import { GameMapType } from "../core/game/Game";
|
||||
import { publicLobbyMaps } from "../core/game/PublicLobbyMaps";
|
||||
import { GameInfo } from "../core/Schemas";
|
||||
import { generateID } from "../core/Util";
|
||||
import { verifyClientToken } from "./jwt";
|
||||
import { logger } from "./Logger";
|
||||
import { MapPlaylist } from "./MapPlaylist";
|
||||
import { startPolling } from "./PollingLoop";
|
||||
@@ -72,6 +76,16 @@ let publicLobbiesData: { lobbies: GameInfo[] } = { lobbies: [] };
|
||||
|
||||
const publicLobbyIDs: Set<string> = new Set();
|
||||
const connectedClients: Set<WebSocket> = new Set();
|
||||
const publicLobbyMapSet = new Set(publicLobbyMaps);
|
||||
const mapVotesByUser = new Map<string, Set<GameMapType>>();
|
||||
const mapVoteConnectionsByUser = new Map<string, Set<WebSocket>>();
|
||||
const mapVoteUserByConnection = new Map<WebSocket, string>();
|
||||
|
||||
const MapVoteMessageSchema = z.object({
|
||||
type: z.literal("map_vote"),
|
||||
token: z.string(),
|
||||
maps: z.array(z.nativeEnum(GameMapType)),
|
||||
});
|
||||
|
||||
// Broadcast lobbies to all connected clients
|
||||
function broadcastLobbies() {
|
||||
@@ -95,6 +109,113 @@ function broadcastLobbies() {
|
||||
});
|
||||
}
|
||||
|
||||
function broadcastMapVoteRequest() {
|
||||
const message = JSON.stringify({ type: "map_vote_request" });
|
||||
const clientsToRemove: WebSocket[] = [];
|
||||
|
||||
connectedClients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
} else if (
|
||||
client.readyState === WebSocket.CLOSED ||
|
||||
client.readyState === WebSocket.CLOSING
|
||||
) {
|
||||
clientsToRemove.push(client);
|
||||
}
|
||||
});
|
||||
|
||||
clientsToRemove.forEach((client) => {
|
||||
connectedClients.delete(client);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeVotedMaps(maps: GameMapType[]): Set<GameMapType> {
|
||||
const unique = new Set<GameMapType>();
|
||||
maps.forEach((map) => {
|
||||
if (publicLobbyMapSet.has(map)) {
|
||||
unique.add(map);
|
||||
}
|
||||
});
|
||||
return unique;
|
||||
}
|
||||
|
||||
function registerVoteConnection(userId: string, ws: WebSocket) {
|
||||
const existingUserId = mapVoteUserByConnection.get(ws);
|
||||
if (existingUserId && existingUserId !== userId) {
|
||||
unregisterVoteConnection(ws);
|
||||
}
|
||||
|
||||
mapVoteUserByConnection.set(ws, userId);
|
||||
const connections = mapVoteConnectionsByUser.get(userId) ?? new Set();
|
||||
connections.add(ws);
|
||||
mapVoteConnectionsByUser.set(userId, connections);
|
||||
}
|
||||
|
||||
function unregisterVoteConnection(ws: WebSocket) {
|
||||
const userId = mapVoteUserByConnection.get(ws);
|
||||
if (!userId) return;
|
||||
|
||||
mapVoteUserByConnection.delete(ws);
|
||||
const connections = mapVoteConnectionsByUser.get(userId);
|
||||
if (!connections) return;
|
||||
|
||||
connections.delete(ws);
|
||||
if (connections.size === 0) {
|
||||
mapVoteConnectionsByUser.delete(userId);
|
||||
mapVotesByUser.delete(userId);
|
||||
} else {
|
||||
mapVoteConnectionsByUser.set(userId, connections);
|
||||
}
|
||||
}
|
||||
|
||||
function setUserVote(userId: string, maps: Set<GameMapType>) {
|
||||
if (maps.size === 0) {
|
||||
mapVotesByUser.delete(userId);
|
||||
return;
|
||||
}
|
||||
mapVotesByUser.set(userId, maps);
|
||||
}
|
||||
|
||||
function collectMapVoteWeights(): Map<GameMapType, number> {
|
||||
const weights = new Map<GameMapType, number>();
|
||||
for (const maps of mapVotesByUser.values()) {
|
||||
for (const map of maps) {
|
||||
weights.set(map, (weights.get(map) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return weights;
|
||||
}
|
||||
|
||||
function clearMapVotes() {
|
||||
mapVotesByUser.clear();
|
||||
}
|
||||
|
||||
async function handleLobbyMessage(ws: WebSocket, raw: WebSocket.RawData) {
|
||||
let payload: object | null = null;
|
||||
try {
|
||||
payload = JSON.parse(raw.toString()) as object;
|
||||
} catch (error) {
|
||||
log.warn("Failed to parse lobby WebSocket message", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = MapVoteMessageSchema.safeParse(payload);
|
||||
if (!parsed.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { token, maps } = parsed.data;
|
||||
const verification = await verifyClientToken(token, config);
|
||||
if (verification.type !== "success" || !verification.claims) {
|
||||
log.warn("Rejected map vote from unauthenticated client");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = verification.persistentId;
|
||||
registerVoteConnection(userId, ws);
|
||||
setUserVote(userId, normalizeVotedMaps(maps));
|
||||
}
|
||||
|
||||
// Start the master process
|
||||
export async function startMaster() {
|
||||
if (!cluster.isPrimary) {
|
||||
@@ -119,11 +240,17 @@ export async function startMaster() {
|
||||
|
||||
ws.on("close", () => {
|
||||
connectedClients.delete(ws);
|
||||
unregisterVoteConnection(ws);
|
||||
});
|
||||
|
||||
ws.on("message", (data) => {
|
||||
void handleLobbyMessage(ws, data);
|
||||
});
|
||||
|
||||
ws.on("error", (error) => {
|
||||
log.error(`WebSocket error:`, error);
|
||||
connectedClients.delete(ws);
|
||||
unregisterVoteConnection(ws);
|
||||
try {
|
||||
if (
|
||||
ws.readyState === WebSocket.OPEN ||
|
||||
@@ -314,6 +441,8 @@ async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
|
||||
// Send request to the worker to start the game
|
||||
try {
|
||||
const mapVoteWeights = collectMapVoteWeights();
|
||||
const gameConfig = await playlist.gameConfig(mapVoteWeights);
|
||||
const response = await fetch(
|
||||
`http://localhost:${config.workerPort(gameID)}/api/create_game/${gameID}`,
|
||||
{
|
||||
@@ -322,13 +451,15 @@ async function schedulePublicGame(playlist: MapPlaylist) {
|
||||
"Content-Type": "application/json",
|
||||
[config.adminHeader()]: config.adminToken(),
|
||||
},
|
||||
body: JSON.stringify(await playlist.gameConfig()),
|
||||
body: JSON.stringify(gameConfig),
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to schedule public game: ${response.statusText}`);
|
||||
}
|
||||
clearMapVotes();
|
||||
broadcastMapVoteRequest();
|
||||
} catch (error) {
|
||||
log.error(`Failed to schedule public game on worker ${workerPath}:`, error);
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user