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..84f5a17d43 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -331,7 +331,16 @@ "teams_hvn_detailed": "{num} Humans vs {num} Nations", "teams": "{num} teams", "players_per_team": "of {num}", - "started": "Started" + "started": "Started", + "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_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 f398da0543..dbc5aaf129 100644 --- a/src/client/LobbySocket.ts +++ b/src/client/LobbySocket.ts @@ -1,11 +1,16 @@ +import { GameMapType } from "../core/game/Game"; 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 { @@ -19,6 +24,9 @@ export class PublicLobbySocket { private readonly maxWsAttempts: number; 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( onLobbiesUpdate: LobbyUpdateHandler, @@ -28,6 +36,8 @@ export class PublicLobbySocket { this.reconnectDelay = options?.reconnectDelay ?? 3000; this.maxWsAttempts = options?.maxWsAttempts ?? 3; this.pollIntervalMs = options?.pollIntervalMs ?? 1000; + this.onVoteRequest = options?.onVoteRequest; + this.onVoteStats = options?.onVoteStats; } start() { @@ -71,6 +81,7 @@ export class PublicLobbySocket { this.wsReconnectTimeout = null; } this.stopFallbackPolling(); + this.flushPendingVote(); } private handleMessage(event: MessageEvent) { @@ -78,6 +89,11 @@ 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?.(); + } 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); @@ -114,7 +130,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 +178,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..93c8e2c7a4 --- /dev/null +++ b/src/client/MapVoteModal.ts @@ -0,0 +1,230 @@ +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 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[], + ): 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..df54e2ce3b 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,43 @@ 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; + @state() private activeVoteCount: number = 0; 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(); + }, + onVoteStats: (activeVoteCount) => { + this.activeVoteCount = activeVoteCount; + }, + }, ); + 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); + }; + 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; @@ -38,15 +72,57 @@ export class PublicLobby extends LitElement { connectedCallback() { super.connectedCallback(); + document.addEventListener("userMeResponse", this.handleUserMeResponse); + document.addEventListener("map-vote-change", this.handleMapVoteChangeEvent); + document.addEventListener("map-vote-submit", this.handleMapVoteSubmitEvent); this.lobbySocket.start(); } disconnectedCallback() { super.disconnectedCallback(); + document.removeEventListener("userMeResponse", this.handleUserMeResponse); + document.removeEventListener( + "map-vote-change", + this.handleMapVoteChangeEvent, + ); + document.removeEventListener( + "map-vote-submit", + this.handleMapVoteSubmitEvent, + ); 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 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 }) + | null; + modal?.open(); + } + private handleLobbiesUpdate(lobbies: GameInfo[]) { this.lobbies = lobbies; this.lobbies.forEach((l) => { @@ -121,131 +197,179 @@ export class PublicLobby extends LitElement { const mapImageSrc = this.mapImages.get(lobby.gameID); return html` - +
+ + ${translateText("public_lobby.vote_cta")} + + + ${translateText("public_lobby.vote_count_label", { + count: this.activeVoteCount, + })} +
+ - - ${fullModeLabel - ? html` - ${fullModeLabel} - ` - : ""} - - - ${timeRemaining > 0 - ? html` - - ${timeDisplay} - - ` - : html` - ${translateText("public_lobby.started")} - `} - - -
- - ${modifierLabel.length > 0 - ? html`
- ${modifierLabel.map( - (label) => html` - - ${label} - - `, - )} -
` - : html``} - - -
- -
- -
-
- ${this.currLobby - ? isStarting - ? html`${translateText("public_lobby.starting_game")}` - : html`${translateText("public_lobby.waiting_for_players")} - ${[0, 1, 2] - .map((i) => - i === this.joiningDotIndex ? "•" : "·", - ) - .join("")}` - : html`${translateText("public_lobby.join")}`} -
+
+
+
- +
`; } 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..983071ba4d 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,22 @@ const TEAM_WEIGHTS: { config: TeamCountConfig; weight: number }[] = [ ]; export class MapPlaylist { - private mapsPlaylist: MapWithMode[] = []; + private recentMaps: GameMapType[] = []; + private modeSequenceIndex = 0; + // TODO: Adjust based on feedback + private readonly maxRecentMaps = 0; // e.g., 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 +138,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 +283,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..57cdf1c555 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,18 @@ 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(); +// 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"), + token: z.string(), + maps: z.array(z.nativeEnum(GameMapType)), +}); // Broadcast lobbies to all connected clients function broadcastLobbies() { @@ -95,6 +111,144 @@ 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 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) => { + 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); + broadcastMapVoteStats(); + } else { + mapVoteConnectionsByUser.set(userId, connections); + } +} + +function setUserVote(userId: string, maps: Set) { + if (maps.size === 0) { + mapVotesByUser.delete(userId); + broadcastMapVoteStats(); + return; + } + mapVotesByUser.set(userId, maps); + broadcastMapVoteStats(); +} + +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) + MAP_VOTE_WEIGHT); + } + } + return weights; +} + +function clearMapVotes() { + mapVotesByUser.clear(); + broadcastMapVoteStats(); +} + +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) { @@ -116,14 +270,26 @@ 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); + 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 +480,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 +490,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;