diff --git a/resources/lang/en.json b/resources/lang/en.json index b9299bbdb..84f5a17d4 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -332,12 +332,15 @@ "teams": "{num} teams", "players_per_team": "of {num}", "started": "Started", - "current": "Current public lobby", - "vote_button": "Vote on maps", + "vote_cta": "Vote for next map!", + "vote_count_label": "{count, plural, one {# vote influences the next map} other {# votes influence the next map}}", + "vote_count_tooltip": "Number of players currently voting (connected & waiting in public lobby)", "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." + "vote_login_required": "Log in to submit votes.", + "vote_submit": "Vote", + "vote_toast_submitted": "Vote submitted!" }, "matchmaking_modal": { "title": "1v1 Ranked Matchmaking (ALPHA)", diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts index 407ef7700..dbc5aaf12 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -3,12 +3,14 @@ import { GameInfo } from "../core/Schemas"; type LobbyUpdateHandler = (lobbies: GameInfo[]) => void; type VoteRequestHandler = () => void; +type VoteStatsHandler = (activeVoteCount: number) => void; interface LobbySocketOptions { reconnectDelay?: number; maxWsAttempts?: number; pollIntervalMs?: number; onVoteRequest?: VoteRequestHandler; + onVoteStats?: VoteStatsHandler; } export class PublicLobbySocket { @@ -23,6 +25,7 @@ export class PublicLobbySocket { private readonly pollIntervalMs: number; private readonly onLobbiesUpdate: LobbyUpdateHandler; private readonly onVoteRequest?: VoteRequestHandler; + private readonly onVoteStats?: VoteStatsHandler; private pendingVote: { token: string; maps: GameMapType[] } | null = null; constructor( @@ -34,6 +37,7 @@ export class PublicLobbySocket { this.maxWsAttempts = options?.maxWsAttempts ?? 3; this.pollIntervalMs = options?.pollIntervalMs ?? 1000; this.onVoteRequest = options?.onVoteRequest; + this.onVoteStats = options?.onVoteStats; } start() { @@ -87,6 +91,9 @@ export class PublicLobbySocket { this.onLobbiesUpdate(message.data?.lobbies ?? []); } else if (message.type === "map_vote_request") { this.onVoteRequest?.(); + } else if (message.type === "map_vote_stats") { + const activeVoteCount = Number(message.data?.activeVoteCount ?? 0); + this.onVoteStats?.(activeVoteCount); } } catch (error) { console.error("Error parsing WebSocket message:", error); diff --git a/src/client/MapVoteModal.ts b/src/client/MapVoteModal.ts index 2e5189454..93c8e2c7a 100644 --- a/src/client/MapVoteModal.ts +++ b/src/client/MapVoteModal.ts @@ -59,6 +59,30 @@ export class MapVoteModal extends BaseModal { ); } + private handleVoteSubmit = () => { + const maps = Array.from(this.selectedMaps); + saveStoredMapVotes(maps); + this.dispatchEvent( + new CustomEvent("map-vote-submit", { + detail: { maps }, + bubbles: true, + composed: true, + }), + ); + window.dispatchEvent( + new CustomEvent("show-message", { + detail: { + message: this.loggedIn + ? translateText("public_lobby.vote_toast_submitted") + : translateText("public_lobby.vote_saved"), + color: "green", + duration: 2500, + }, + }), + ); + this.close(); + }; + private renderCategory( categoryKey: string, maps: GameMapType[], @@ -166,6 +190,25 @@ export class MapVoteModal extends BaseModal { + +
+ + +
`; diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index bd86e6be8..df54e2ce3 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -29,6 +29,7 @@ export class PublicLobby extends LitElement { @state() private mapImages: Map = new Map(); @state() private joiningDotIndex: number = 0; @state() private isLoggedIn: boolean = false; + @state() private activeVoteCount: number = 0; private joiningInterval: number | null = null; private currLobby: GameInfo | null = null; @@ -40,6 +41,9 @@ export class PublicLobby extends LitElement { onVoteRequest: () => { void this.sendStoredVotes(); }, + onVoteStats: (activeVoteCount) => { + this.activeVoteCount = activeVoteCount; + }, }, ); private handleUserMeResponse = (event: Event) => { @@ -56,6 +60,11 @@ export class PublicLobby extends LitElement { if (!customEvent.detail?.maps) return; void this.handleMapVoteChange(customEvent); }; + private handleMapVoteSubmitEvent = (event: Event) => { + const customEvent = event as CustomEvent<{ maps: GameMapType[] }>; + if (!customEvent.detail?.maps) return; + void this.handleMapVoteSubmit(customEvent.detail.maps); + }; createRenderRoot() { return this; @@ -65,6 +74,7 @@ export class PublicLobby extends LitElement { super.connectedCallback(); document.addEventListener("userMeResponse", this.handleUserMeResponse); document.addEventListener("map-vote-change", this.handleMapVoteChangeEvent); + document.addEventListener("map-vote-submit", this.handleMapVoteSubmitEvent); this.lobbySocket.start(); } @@ -75,6 +85,10 @@ export class PublicLobby extends LitElement { "map-vote-change", this.handleMapVoteChangeEvent, ); + document.removeEventListener( + "map-vote-submit", + this.handleMapVoteSubmitEvent, + ); this.lobbySocket.stop(); this.stopJoiningAnimation(); } @@ -95,6 +109,13 @@ export class PublicLobby extends LitElement { this.lobbySocket.sendMapVote(auth.jwt, event.detail.maps); } + private async handleMapVoteSubmit(maps: GameMapType[]) { + if (!this.isLoggedIn) return; + const auth = await userAuth(); + if (!auth) return; + this.lobbySocket.sendMapVote(auth.jwt, maps); + } + private openMapVoteModal() { const modal = document.querySelector("map-vote-modal") as | (HTMLElement & { open: () => void }) @@ -177,10 +198,7 @@ export class PublicLobby extends LitElement { return html`
-
- ${translateText("public_lobby.current")} +
+
+ + ${translateText("public_lobby.vote_cta")} + + + ${translateText("public_lobby.vote_count_label", { + count: this.activeVoteCount, + })} + +
diff --git a/src/server/Master.ts b/src/server/Master.ts index 69cf2b6bc..57cdf1c55 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -131,6 +131,33 @@ function broadcastMapVoteRequest() { }); } +function getActiveVoteCount(): number { + return mapVotesByUser.size; +} + +function broadcastMapVoteStats() { + const message = JSON.stringify({ + type: "map_vote_stats", + data: { activeVoteCount: getActiveVoteCount() }, + }); + 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 { const unique = new Set(); maps.forEach((map) => { @@ -165,6 +192,7 @@ function unregisterVoteConnection(ws: WebSocket) { if (connections.size === 0) { mapVoteConnectionsByUser.delete(userId); mapVotesByUser.delete(userId); + broadcastMapVoteStats(); } else { mapVoteConnectionsByUser.set(userId, connections); } @@ -173,9 +201,11 @@ function unregisterVoteConnection(ws: WebSocket) { function setUserVote(userId: string, maps: Set) { if (maps.size === 0) { mapVotesByUser.delete(userId); + broadcastMapVoteStats(); return; } mapVotesByUser.set(userId, maps); + broadcastMapVoteStats(); } function collectMapVoteWeights(): Map { @@ -190,6 +220,7 @@ function collectMapVoteWeights(): Map { function clearMapVotes() { mapVotesByUser.clear(); + broadcastMapVoteStats(); } async function handleLobbyMessage(ws: WebSocket, raw: WebSocket.RawData) { @@ -239,6 +270,12 @@ export async function startMaster() { ws.send( JSON.stringify({ type: "lobbies_update", data: publicLobbiesData }), ); + ws.send( + JSON.stringify({ + type: "map_vote_stats", + data: { activeVoteCount: getActiveVoteCount() }, + }), + ); ws.on("close", () => { connectedClients.delete(ws);