From 68bdcd2d236b7a457a06aebabb2bae3e1816d717 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sun, 25 Jan 2026 09:54:25 +0900 Subject: [PATCH 1/3] add --- index.html | 1 + resources/lang/en.json | 8 +- src/client/LobbySocket.ts | 36 +++- src/client/Main.ts | 9 + src/client/MapVoteModal.ts | 187 +++++++++++++++++ src/client/MapVoteStorage.ts | 35 ++++ src/client/PublicLobby.ts | 332 ++++++++++++++++++++----------- src/core/game/PublicLobbyMaps.ts | 53 +++++ src/server/MapPlaylist.ts | 190 +++++++----------- src/server/Master.ts | 133 ++++++++++++- 10 files changed, 742 insertions(+), 242 deletions(-) create mode 100644 src/client/MapVoteModal.ts create mode 100644 src/client/MapVoteStorage.ts create mode 100644 src/core/game/PublicLobbyMaps.ts diff --git a/index.html b/index.html index cad490b1ca..4765f0af51 100644 --- a/index.html +++ b/index.html @@ -270,6 +270,7 @@ + diff --git a/resources/lang/en.json b/resources/lang/en.json index a9663b2b06..b9299bbdb9 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -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)", diff --git a/src/client/LobbySocket.ts b/src/client/LobbySocket.ts index f398da0543..407ef77006 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -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`); diff --git a/src/client/Main.ts b/src/client/Main.ts index 8858b2f43f..518f7b1d51 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -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 () => { diff --git a/src/client/MapVoteModal.ts b/src/client/MapVoteModal.ts new file mode 100644 index 0000000000..2e5189454c --- /dev/null +++ b/src/client/MapVoteModal.ts @@ -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(); + + private readonly availableMaps = new Set(publicLobbyMaps); + private handleUserMeResponse = (event: Event) => { + const customEvent = event as CustomEvent; + 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` +
+

+ ${translateText(`map_categories.${categoryKey}`)} +

+
+ ${maps.map((mapValue) => { + const mapKey = Object.entries(GameMapType).find( + ([, v]) => v === mapValue, + )?.[0]; + return html` +
this.toggleMapSelection(mapValue)} + class="cursor-pointer transition-transform duration-200 active:scale-95" + > + +
+ `; + })} +
+
+ `; + } + + 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`
+ ${translateText("public_lobby.vote_login_required")} +
`; + + const content = html` +
+ ${modalHeader({ + title: translateText("public_lobby.vote_title"), + onBack: () => this.close(), + ariaLabel: translateText("common.back"), + rightContent: loginBanner, + })} + +
+
+
+
+
+ + + +
+

+ ${translateText("map.map")} +

+
+ +
+

${translateText("public_lobby.vote_description")}

+

+ ${translateText("public_lobby.vote_saved")} +

+
+ +
+ ${categories.map(([categoryKey, maps]) => + this.renderCategory(categoryKey, maps), + )} +
+
+
+
+
+ `; + + if (this.inline) { + return content; + } + + return html` + + ${content} + + `; + } +} diff --git a/src/client/MapVoteStorage.ts b/src/client/MapVoteStorage.ts new file mode 100644 index 0000000000..26a6ce2e71 --- /dev/null +++ b/src/client/MapVoteStorage.ts @@ -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); + } +} diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 4c895ab8f1..bd86e6be83 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -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 = 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(); - 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; + 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` - + + +
+
+ - + `; } diff --git a/src/core/game/PublicLobbyMaps.ts b/src/core/game/PublicLobbyMaps.ts new file mode 100644 index 0000000000..7b628ecd62 --- /dev/null +++ b/src/core/game/PublicLobbyMaps.ts @@ -0,0 +1,53 @@ +import { GameMapType } from "./Game"; + +export const publicLobbyMapWeights: Partial> = { + [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; diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 5bbc11b004..638f3f8ed2 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -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> = { - 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 { - const { map, mode } = this.getNextMap(); + public async gameConfig( + mapVotes?: Map, + ): Promise { + 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()!; - } + private getNextMap(mapVotes?: Map): 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 { + 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; + } + + 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; } - log.error("Failed to generate a valid map playlist"); } - // Even if it failed, playlist will be partially populated. - return this.mapsPlaylist.shift()!; + 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; - } } diff --git a/src/server/Master.ts b/src/server/Master.ts index fd3fdbb22c..816f932a6e 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -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 = new Set(); const connectedClients: Set = new Set(); +const publicLobbyMapSet = new Set(publicLobbyMaps); +const mapVotesByUser = new Map>(); +const mapVoteConnectionsByUser = new Map>(); +const mapVoteUserByConnection = new Map(); + +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 { + const unique = new Set(); + 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) { + if (maps.size === 0) { + mapVotesByUser.delete(userId); + return; + } + mapVotesByUser.set(userId, maps); +} + +function collectMapVoteWeights(): Map { + const weights = new Map(); + 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; From 6443fe790c3ffe86706eaf006998e46ca3e3c2ef Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sun, 25 Jan 2026 10:58:01 +0900 Subject: [PATCH 2/3] debug --- src/server/MapPlaylist.ts | 3 ++- src/server/Master.ts | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/server/MapPlaylist.ts b/src/server/MapPlaylist.ts index 638f3f8ed2..983071ba4d 100644 --- a/src/server/MapPlaylist.ts +++ b/src/server/MapPlaylist.ts @@ -39,7 +39,8 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ export class MapPlaylist { private recentMaps: GameMapType[] = []; private modeSequenceIndex = 0; - private readonly maxRecentMaps = 5; + // TODO: Adjust based on feedback + private readonly maxRecentMaps = 0; // e.g., 5 private readonly modeSequence: GameMode[]; constructor(private disableTeams: boolean = false) { diff --git a/src/server/Master.ts b/src/server/Master.ts index 816f932a6e..69cf2b6bcd 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -80,6 +80,8 @@ const publicLobbyMapSet = new Set(publicLobbyMaps); const mapVotesByUser = new Map>(); const mapVoteConnectionsByUser = new Map>(); const mapVoteUserByConnection = new Map(); +// TODO: Adjust based on feedback +const MAP_VOTE_WEIGHT = 10000000000; // for debug purposes, make votes very impactful 'w' const MapVoteMessageSchema = z.object({ type: z.literal("map_vote"), @@ -180,7 +182,7 @@ function collectMapVoteWeights(): Map { const weights = new Map(); for (const maps of mapVotesByUser.values()) { for (const map of maps) { - weights.set(map, (weights.get(map) ?? 0) + 1); + weights.set(map, (weights.get(map) ?? 0) + MAP_VOTE_WEIGHT); } } return weights; From 3c9f66288b721cc0bbc2dfc2db134222c7a67867 Mon Sep 17 00:00:00 2001 From: Aotumuri Date: Sun, 25 Jan 2026 16:45:47 +0900 Subject: [PATCH 3/3] fix --- resources/lang/en.json | 9 +++++--- src/client/LobbySocket.ts | 7 +++++++ src/client/MapVoteModal.ts | 43 ++++++++++++++++++++++++++++++++++++++ src/client/PublicLobby.ts | 40 +++++++++++++++++++++++++++++++---- src/server/Master.ts | 37 ++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 7 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index b9299bbdb9..84f5a17d43 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 407ef77006..dbc5aaf129 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 2e5189454c..93c8e2c7a4 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 bd86e6be83..df54e2ce3b 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 69cf2b6bcd..57cdf1c555 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);