This commit is contained in:
Aotumuri
2026-01-25 09:54:25 +09:00
committed by Aotumuri
parent bc479af5c9
commit 68bdcd2d23
10 changed files with 749 additions and 249 deletions
+1
View File
@@ -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>
+7 -1
View File
@@ -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)",
+35 -1
View File
@@ -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`);
+9
View File
@@ -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 () => {
+187
View File
@@ -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>
`;
}
}
+35
View File
@@ -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
View File
@@ -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>
`;
}
+53
View File
@@ -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
View File
@@ -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
View File
@@ -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;