diff --git a/index.html b/index.html index 2ab3f22a6c..ef30d146af 100644 --- a/index.html +++ b/index.html @@ -455,6 +455,8 @@ + + diff --git a/resources/lang/en.json b/resources/lang/en.json index 0e1e5fd533..5c8ef99140 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -862,5 +862,13 @@ "stats_games_played": "Games Played", "mode_ffa": "Free-for-All", "mode_team": "Team" + }, + "lobby_notification_modal": { + "title": "Lobby Notifications", + "min": "Min:", + "max": "Max:", + "sound_notifications": "Sound Notifications", + "enable_hint": "Select a game mode to enable notifications", + "team_count_range": "Players per Team" } } diff --git a/src/client/LangSelector.ts b/src/client/LangSelector.ts index 782d82f772..3fc804b75d 100644 --- a/src/client/LangSelector.ts +++ b/src/client/LangSelector.ts @@ -195,6 +195,7 @@ export class LangSelector extends LitElement { "o-modal", "o-button", "territory-patterns-modal", + "lobby-notification-modal", ]; document.title = this.translateText("main.title") ?? document.title; diff --git a/src/client/LobbyNotificationButton.ts b/src/client/LobbyNotificationButton.ts new file mode 100644 index 0000000000..7d26854b94 --- /dev/null +++ b/src/client/LobbyNotificationButton.ts @@ -0,0 +1,30 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { translateText } from "./Utils"; + +@customElement("lobby-notification-button") +export class LobbyNotificationButton extends LitElement { + createRenderRoot() { + return this; + } + + private openModal() { + const event = new CustomEvent("open-notification-modal", { + bubbles: true, + composed: true, + }); + window.dispatchEvent(event); + } + + render() { + return html` + + `; + } +} diff --git a/src/client/LobbyNotificationManager.ts b/src/client/LobbyNotificationManager.ts new file mode 100644 index 0000000000..9f789af8cf --- /dev/null +++ b/src/client/LobbyNotificationManager.ts @@ -0,0 +1,176 @@ +import { GameConfig, GameInfo } from "../core/Schemas"; +import { GameMode } from "../core/game/Game"; + +interface NotificationSettings { + ffaEnabled: boolean; + teamEnabled: boolean; + soundEnabled: boolean; + minTeamCount: number; + maxTeamCount: number; +} + +export class LobbyNotificationManager { + private settings: NotificationSettings | null = null; + private audioContext: AudioContext | null = null; + private seenLobbies: Set = new Set(); + + constructor() { + this.loadSettings(); + this.setupEventListeners(); + } + + private setupEventListeners() { + window.addEventListener( + "notification-settings-changed", + this.handleSettingsChanged, + ); + window.addEventListener("lobbies-updated", this.handleLobbiesUpdated); + } + + private handleSettingsChanged = (e: Event) => { + const event = e as CustomEvent; + this.settings = event.detail; + }; + + private loadSettings() { + try { + const saved = localStorage.getItem("lobbyNotificationSettings"); + if (saved) { + this.settings = JSON.parse(saved); + } + } catch (error) { + console.error("Failed to load notification settings:", error); + } + } + + private handleLobbiesUpdated = (e: Event) => { + const event = e as CustomEvent; + const lobbies = event.detail || []; + + // Check for new lobbies + lobbies.forEach((lobby) => { + if (!this.seenLobbies.has(lobby.gameID) && lobby.gameConfig) { + this.seenLobbies.add(lobby.gameID); + + // Check if this lobby matches user preferences + if (this.matchesPreferences(lobby.gameConfig)) { + this.playNotificationSound(); + } + } + }); + + // Clean up old lobbies no longer in the list + const currentIds = new Set(lobbies.map((l) => l.gameID)); + for (const id of this.seenLobbies) { + if (!currentIds.has(id)) { + this.seenLobbies.delete(id); + } + } + }; + + private matchesPreferences(config: GameConfig): boolean { + if (!this.settings) return false; + + // Check FFA + if (this.settings.ffaEnabled && config.gameMode === GameMode.FFA) { + return true; + } + + // Check Team + if (this.settings.teamEnabled && config.gameMode === GameMode.Team) { + const maxPlayers = config.maxPlayers ?? 0; + const playerTeams = config.playerTeams; + + if (maxPlayers === 0) return false; + + // Map fixed modes to players per team + const playersPerTeamMap: Record = { + Duos: 2, + Trios: 3, + Quads: 4, + "Humans Vs Nations": 1, + }; + + // Calculate players per team + // If playerTeams is a string (Duos/Trios/Quads), use the map + // If it's a number, it's the number of teams, so calculate players per team + const playersPerTeam = + typeof playerTeams === "string" + ? (playersPerTeamMap[playerTeams] ?? 0) + : typeof playerTeams === "number" + ? Math.floor(maxPlayers / playerTeams) + : 0; + + if (playersPerTeam === 0) return false; + + if (playersPerTeam < this.settings.minTeamCount) return false; + if (playersPerTeam > this.settings.maxTeamCount) return false; + + return true; + } + + return false; + } + + private playNotificationSound() { + if (!this.settings?.soundEnabled) { + return; + } + + this.playBeepSound(); + } + + private getAudioContext(): AudioContext | null { + if (this.audioContext) { + return this.audioContext; + } + + try { + this.audioContext = new (window.AudioContext || + (window as any).webkitAudioContext)(); + return this.audioContext; + } catch (error) { + console.error("Failed to create AudioContext:", error); + return null; + } + } + + private playBeepSound() { + try { + const audioContext = this.getAudioContext(); + if (!audioContext) return; + + const oscillator = audioContext.createOscillator(); + const gainNode = audioContext.createGain(); + + oscillator.connect(gainNode); + gainNode.connect(audioContext.destination); + + oscillator.frequency.value = 800; + oscillator.type = "sine"; + + gainNode.gain.setValueAtTime(0.3, audioContext.currentTime); + gainNode.gain.exponentialRampToValueAtTime( + 0.01, + audioContext.currentTime + 0.3, + ); + + oscillator.start(audioContext.currentTime); + oscillator.stop(audioContext.currentTime + 0.3); + } catch (error) { + console.error("Failed to play beep sound:", error); + } + } + + public destroy() { + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + window.removeEventListener( + "notification-settings-changed", + this.handleSettingsChanged, + ); + window.removeEventListener("lobbies-updated", this.handleLobbiesUpdated); + } +} diff --git a/src/client/LobbyNotificationModal.ts b/src/client/LobbyNotificationModal.ts new file mode 100644 index 0000000000..f30a6836ce --- /dev/null +++ b/src/client/LobbyNotificationModal.ts @@ -0,0 +1,282 @@ +import { LitElement, html } from "lit"; +import { customElement, query, state } from "lit/decorators.js"; +import { translateText } from "./Utils"; + +export interface LobbyNotificationCriteria { + gameMode: "FFA" | "Team"; + minPlayers?: number; + maxPlayers?: number; + teamCounts?: Array; +} + +@customElement("lobby-notification-modal") +export class LobbyNotificationModal extends LitElement { + @query("o-modal") private modalEl!: HTMLElement & { + open: () => void; + close: () => void; + }; + + @state() private ffaEnabled = false; + @state() private teamEnabled = false; + @state() private soundEnabled = true; + @state() private minTeamCount = 2; + @state() private maxTeamCount = 50; + + createRenderRoot() { + return this; + } + + connectedCallback() { + super.connectedCallback(); + this.loadSettings(); + window.addEventListener("keydown", this.handleKeyDown); + } + + disconnectedCallback() { + window.removeEventListener("keydown", this.handleKeyDown); + super.disconnectedCallback(); + } + + private handleKeyDown = (e: KeyboardEvent) => { + if (e.code === "Escape") { + e.preventDefault(); + this.close(); + } + }; + + private loadSettings() { + try { + const saved = localStorage.getItem("lobbyNotificationSettings"); + if (saved) { + const settings = JSON.parse(saved); + this.ffaEnabled = settings.ffaEnabled ?? false; + this.teamEnabled = settings.teamEnabled ?? false; + this.soundEnabled = settings.soundEnabled ?? true; + this.minTeamCount = settings.minTeamCount ?? 2; + this.maxTeamCount = settings.maxTeamCount ?? 50; + } + } catch (error) { + console.error("Failed to load notification settings:", error); + } + } + + private saveSettings() { + try { + const settings = { + ffaEnabled: this.ffaEnabled, + teamEnabled: this.teamEnabled, + soundEnabled: this.soundEnabled, + minTeamCount: this.minTeamCount, + maxTeamCount: this.maxTeamCount, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + + // Dispatch event to notify the manager + window.dispatchEvent( + new CustomEvent("notification-settings-changed", { detail: settings }), + ); + } catch (error) { + console.error("Failed to save notification settings:", error); + } + } + + public getCriteria(): LobbyNotificationCriteria[] { + const criteria: LobbyNotificationCriteria[] = []; + + if (this.ffaEnabled) { + criteria.push({ + gameMode: "FFA", + }); + } + + if (this.teamEnabled) { + criteria.push({ + gameMode: "Team", + teamCounts: [this.minTeamCount, this.maxTeamCount], + }); + } + + return criteria; + } + + public isEnabled(): boolean { + return this.ffaEnabled || this.teamEnabled; + } + + public isSoundEnabled(): boolean { + return this.soundEnabled; + } + + public open() { + this.modalEl?.open(); + } + + public close() { + this.modalEl?.close(); + } + + private handleFFAChange(e: Event) { + this.ffaEnabled = (e.target as HTMLInputElement).checked; + this.saveSettings(); + } + + private handleTeamChange(e: Event) { + this.teamEnabled = (e.target as HTMLInputElement).checked; + this.saveSettings(); + } + + private handleSoundChange(e: Event) { + this.soundEnabled = (e.target as HTMLInputElement).checked; + this.saveSettings(); + } + + private handleSliderChange() { + this.saveSettings(); + } + + render() { + return html` + +
+ +
+ +
+ + +
+ + + ${this.teamEnabled + ? html` +
+
+
+ ${translateText( + "lobby_notification_modal.team_count_range", + )} +
+
+
+ + { + this.minTeamCount = parseInt( + (e.target as HTMLInputElement).value, + ); + if (this.minTeamCount > this.maxTeamCount) { + this.maxTeamCount = this.minTeamCount; + } + this.requestUpdate(); + }} + @change=${this.handleSliderChange} + class="flex-1" + /> + ${this.minTeamCount} +
+
+ + { + this.maxTeamCount = parseInt( + (e.target as HTMLInputElement).value, + ); + if (this.maxTeamCount < this.minTeamCount) { + this.minTeamCount = this.maxTeamCount; + } + this.requestUpdate(); + }} + @change=${this.handleSliderChange} + class="flex-1" + /> + ${this.maxTeamCount} +
+
+
+
+
+ ` + : ""} +
+ + +
+ +
+ + + ${!this.isEnabled() + ? html`
+ ${translateText( + "lobby_notification_modal.enable_hint", + )} +
` + : ""} + +
+ `; + } +} diff --git a/src/client/Main.ts b/src/client/Main.ts index 58dffff725..36dee9bb17 100644 --- a/src/client/Main.ts +++ b/src/client/Main.ts @@ -27,6 +27,11 @@ import { JoinPrivateLobbyModal } from "./JoinPrivateLobbyModal"; import "./LangSelector"; import { LangSelector } from "./LangSelector"; import { LanguageModal } from "./LanguageModal"; +import "./LobbyNotificationButton"; +import { LobbyNotificationButton } from "./LobbyNotificationButton"; +import { LobbyNotificationManager } from "./LobbyNotificationManager"; +import "./LobbyNotificationModal"; +import { LobbyNotificationModal } from "./LobbyNotificationModal"; import "./Matchmaking"; import { MatchmakingModal } from "./Matchmaking"; import "./NewsModal"; @@ -109,6 +114,19 @@ class Client { private usernameInput: UsernameInput | null = null; private flagInput: FlagInput | null = null; private darkModeButton: DarkModeButton | null = null; + private lobbyNotificationButton: LobbyNotificationButton | null = null; + private lobbyNotificationModal: LobbyNotificationModal | null = null; + private lobbyNotificationManager: LobbyNotificationManager | null = null; + + // Event listener handlers (stored for cleanup) + private handleOpenNotificationModal = () => { + this.lobbyNotificationModal?.open(); + }; + + private handleBeforeUnload = async () => { + console.log("Browser is closing"); + await this.cleanup(); + }; private joinModal: JoinPrivateLobbyModal; private publicLobby: PublicLobby; @@ -165,6 +183,27 @@ class Client { console.warn("Dark mode button element not found"); } + this.lobbyNotificationButton = document.querySelector( + "lobby-notification-button", + ) as LobbyNotificationButton; + if (!this.lobbyNotificationButton) { + console.warn("Lobby notification button element not found"); + } + + this.lobbyNotificationModal = document.querySelector( + "lobby-notification-modal", + ) as LobbyNotificationModal; + if (!this.lobbyNotificationModal) { + console.warn("Lobby notification modal element not found"); + } + + this.lobbyNotificationManager = new LobbyNotificationManager(); + + window.addEventListener( + "open-notification-modal", + this.handleOpenNotificationModal, + ); + this.usernameInput = document.querySelector( "username-input", ) as UsernameInput; @@ -174,13 +213,7 @@ class Client { this.publicLobby = document.querySelector("public-lobby") as PublicLobby; - window.addEventListener("beforeunload", async () => { - console.log("Browser is closing"); - if (this.gameStop !== null) { - this.gameStop(); - await crazyGamesSDK.gameplayStop(); - } - }); + window.addEventListener("beforeunload", this.handleBeforeUnload); const gutterAds = document.querySelector("gutter-ads"); if (!(gutterAds instanceof GutterAds)) @@ -501,6 +534,28 @@ class Client { } } + private async cleanup(): Promise { + // Remove event listeners + window.removeEventListener( + "open-notification-modal", + this.handleOpenNotificationModal, + ); + window.removeEventListener("beforeunload", this.handleBeforeUnload); + + // Destroy the LobbyNotificationManager + if (this.lobbyNotificationManager) { + this.lobbyNotificationManager.destroy(); + this.lobbyNotificationManager = null; + } + + // Stop any ongoing game + if (this.gameStop !== null) { + this.gameStop(); + this.gameStop = null; + await crazyGamesSDK.gameplayStop(); + } + } + private async handleJoinLobby(event: CustomEvent) { const lobby = event.detail; console.log(`joining lobby ${lobby.gameID}`); diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index 90c6cf6825..b03de77884 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -48,6 +48,14 @@ export class PublicLobby extends LitElement { private handleLobbiesUpdate(lobbies: GameInfo[]) { this.lobbies = lobbies; + + // Emit event for LobbyNotificationManager to consume + window.dispatchEvent( + new CustomEvent("lobbies-updated", { + detail: this.lobbies, + }), + ); + this.lobbies.forEach((l) => { if (!this.lobbyIDToStart.has(l.gameID)) { const msUntilStart = l.msUntilStart ?? 0; diff --git a/tests/client/LobbyNotificationManager.test.ts b/tests/client/LobbyNotificationManager.test.ts new file mode 100644 index 0000000000..a82a357dec --- /dev/null +++ b/tests/client/LobbyNotificationManager.test.ts @@ -0,0 +1,1085 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { LobbyNotificationManager } from "../../src/client/LobbyNotificationManager"; +import { GameConfig, GameInfo } from "../../src/core/Schemas"; +import { + Difficulty, + GameMapSize, + GameMapType, + GameMode, + GameType, +} from "../../src/core/game/Game"; + +describe("LobbyNotificationManager", () => { + let manager: LobbyNotificationManager; + let localStorageMock: Record; + let mockAudioContext: any; + + beforeEach(() => { + // Mock localStorage + localStorageMock = {}; + Object.defineProperty(window, "localStorage", { + value: { + getItem: (key: string) => localStorageMock[key] || null, + setItem: (key: string, value: string) => { + localStorageMock[key] = value; + }, + removeItem: (key: string) => { + delete localStorageMock[key]; + }, + clear: () => { + localStorageMock = {}; + }, + }, + writable: true, + }); + + // Mock AudioContext + mockAudioContext = { + createOscillator: vi.fn().mockReturnValue({ + connect: vi.fn().mockReturnThis(), + frequency: { value: 0 }, + type: "sine", + start: vi.fn(), + stop: vi.fn(), + }), + createGain: vi.fn().mockReturnValue({ + connect: vi.fn().mockReturnThis(), + gain: { + setValueAtTime: vi.fn(), + exponentialRampToValueAtTime: vi.fn(), + }, + }), + destination: {}, + currentTime: 0, + close: vi.fn(), + }; + + const audioContextFactory = function AudioContextMock(this: unknown) { + return mockAudioContext; + }; + + (window as any).AudioContext = vi.fn(audioContextFactory); + (window as any).webkitAudioContext = vi.fn(audioContextFactory); + + vi.clearAllMocks(); + manager = new LobbyNotificationManager(); + }); + + afterEach(() => { + manager.destroy(); + }); + + describe("Constructor and Initialization", () => { + test("should initialize with no settings if localStorage is empty", () => { + const newManager = new LobbyNotificationManager(); + expect(newManager).toBeDefined(); + newManager.destroy(); + }); + + test("should load settings from localStorage on initialization", () => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + + const newManager = new LobbyNotificationManager(); + expect(newManager).toBeDefined(); + newManager.destroy(); + }); + + test("should handle corrupted localStorage data gracefully", () => { + localStorage.setItem("lobbyNotificationSettings", "invalid json"); + + const newManager = new LobbyNotificationManager(); + expect(newManager).toBeDefined(); + + newManager.destroy(); + }); + }); + + describe("Settings Persistence", () => { + test("should update settings when notification-settings-changed event is dispatched", () => { + const settings = { + ffaEnabled: true, + teamEnabled: true, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + + const event = new CustomEvent("notification-settings-changed", { + detail: settings, + }); + + window.dispatchEvent(event); + + // Verify settings were applied by testing behavior + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const lobbyEvent = new CustomEvent("lobbies-updated", { + detail: [{ gameID: "test-lobby", gameConfig, numClients: 5 }], + }); + + vi.clearAllMocks(); + window.dispatchEvent(lobbyEvent); + + // Should trigger notification since FFA is enabled in settings + expect((window as any).AudioContext).toHaveBeenCalled(); + }); + + test("should persist settings to localStorage", () => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + const stored = localStorage.getItem("lobbyNotificationSettings"); + const parsed = JSON.parse(stored ?? "{}"); + + expect(parsed.minTeamCount).toBe(2); + expect(parsed.ffaEnabled).toBe(true); + }); + }); + + describe("FFA Lobby Matching Logic", () => { + beforeEach(() => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager = new LobbyNotificationManager(); + }); + + test("should match any FFA lobby when enabled", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-1", + gameConfig, + numClients: 5, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + expect((window as any).AudioContext).toHaveBeenCalled(); + }); + + test("should not match when FFA is disabled", () => { + const settings = { + ffaEnabled: false, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + + const settingsEvent = new CustomEvent("notification-settings-changed", { + detail: settings, + }); + window.dispatchEvent(settingsEvent); + + vi.clearAllMocks(); + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-2", + gameConfig, + numClients: 5, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + expect((window as any).AudioContext).not.toHaveBeenCalled(); + }); + }); + + describe("Team Lobby Matching Logic - Players Per Team", () => { + beforeEach(() => { + const settings = { + ffaEnabled: false, + teamEnabled: true, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager = new LobbyNotificationManager(); + }); + + test("should match Duos (2 players per team)", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 100, + playerTeams: "Duos", + }; + + const gameInfo: GameInfo = { + gameID: "lobby-duos", + gameConfig, + numClients: 50, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + expect((window as any).AudioContext).toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + }); + + test("should match Trios (3 players per team)", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 90, + playerTeams: "Trios", + }; + + const gameInfo: GameInfo = { + gameID: "lobby-trios", + gameConfig, + numClients: 30, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + expect((window as any).AudioContext).toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + }); + + test("should match Quads (4 players per team)", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 100, + playerTeams: "Quads", + }; + + const gameInfo: GameInfo = { + gameID: "lobby-quads", + gameConfig, + numClients: 25, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + expect((window as any).AudioContext).toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + }); + + test("should not match teams with players per team below min (50 players, 25 teams = 2 per team)", () => { + const settings = { + ffaEnabled: false, + teamEnabled: true, + soundEnabled: true, + minTeamCount: 3, + maxTeamCount: 10, + }; + const settingsEvent = new CustomEvent("notification-settings-changed", { + detail: settings, + }); + window.dispatchEvent(settingsEvent); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 50, + playerTeams: 25, // 50/25 = 2 players per team + }; + + const gameInfo: GameInfo = { + gameID: "lobby-too-small", + gameConfig, + numClients: 50, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + // Should NOT have triggered notification + expect((window as any).AudioContext).not.toHaveBeenCalled(); + }); + + test("should not match teams with players per team above max (50 players, 2 teams = 25 per team)", () => { + const settings = { + ffaEnabled: false, + teamEnabled: true, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 10, + }; + const settingsEvent = new CustomEvent("notification-settings-changed", { + detail: settings, + }); + window.dispatchEvent(settingsEvent); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 50, + playerTeams: 2, // 50/2 = 25 players per team + }; + + const gameInfo: GameInfo = { + gameID: "lobby-too-big", + gameConfig, + numClients: 50, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + // Should NOT have triggered notification + expect((window as any).AudioContext).not.toHaveBeenCalled(); + }); + + test("should match teams with calculated players per team (100 players, 25 teams = 4 per team)", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 100, + playerTeams: 25, // 100/25 = 4 players per team + }; + + const gameInfo: GameInfo = { + gameID: "lobby-calculated", + gameConfig, + numClients: 100, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + expect((window as any).AudioContext).toHaveBeenCalled(); + expect(mockAudioContext.createOscillator).toHaveBeenCalled(); + }); + + test("should not match when Team is disabled", () => { + const settings = { + ffaEnabled: false, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + + const settingsEvent = new CustomEvent("notification-settings-changed", { + detail: settings, + }); + window.dispatchEvent(settingsEvent); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: true, + donateTroops: true, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 20, + playerTeams: "Duos", + }; + + const gameInfo: GameInfo = { + gameID: "lobby-disabled", + gameConfig, + numClients: 10, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + + // Should NOT have triggered notification when Team mode is disabled + expect((window as any).AudioContext).not.toHaveBeenCalled(); + }); + }); + + describe("Sound Notification Triggering", () => { + test("should play sound when sound is enabled and lobby matches", () => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager.destroy(); + manager = new LobbyNotificationManager(); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-sound", + gameConfig, + numClients: 5, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + expect((window as any).AudioContext).toHaveBeenCalled(); + }); + + test("should not play sound when sound is disabled", () => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: false, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager.destroy(); + manager = new LobbyNotificationManager(); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-nosound", + gameConfig, + numClients: 5, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + window.dispatchEvent(event); + expect((window as any).AudioContext).not.toHaveBeenCalled(); + }); + + test("should handle AudioContext creation failure gracefully", () => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager.destroy(); + + const failingAudioContext = function FailingAudioContext(this: unknown) { + throw new Error("AudioContext not supported"); + }; + + (window as any).AudioContext = vi.fn(failingAudioContext); + + manager = new LobbyNotificationManager(); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-error", + gameConfig, + numClients: 5, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + expect(() => window.dispatchEvent(event)).not.toThrow(); + }); + }); + + describe("Single Notification Per Lobby", () => { + beforeEach(() => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager = new LobbyNotificationManager(); + }); + + test("should only trigger notification once for the same lobby", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-duplicate", + gameConfig, + numClients: 5, + }; + + vi.clearAllMocks(); + const event1 = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + window.dispatchEvent(event1); + + const callCount1 = (window as any).AudioContext.mock.calls.length; + + const event2 = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + window.dispatchEvent(event2); + + const callCount2 = (window as any).AudioContext.mock.calls.length; + + expect(callCount2).toBe(callCount1); + }); + + test("should trigger notification when new lobby is added", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + vi.clearAllMocks(); + + // First lobby + const gameInfo1: GameInfo = { + gameID: "lobby-new-1", + gameConfig, + numClients: 5, + }; + const event1 = new CustomEvent("lobbies-updated", { + detail: [gameInfo1], + }); + window.dispatchEvent(event1); + + // AudioContext created once + expect((window as any).AudioContext.mock.calls.length).toBe(1); + expect(mockAudioContext.createOscillator.mock.calls.length).toBe(1); + + // Add second lobby + const gameInfo2: GameInfo = { + gameID: "lobby-new-2", + gameConfig, + numClients: 5, + }; + + // Send both lobbies (realistic behavior) + const event2 = new CustomEvent("lobbies-updated", { + detail: [gameInfo1, gameInfo2], + }); + window.dispatchEvent(event2); + + // AudioContext still only created once (reused), but oscillator called twice + expect((window as any).AudioContext.mock.calls.length).toBe(1); + expect(mockAudioContext.createOscillator.mock.calls.length).toBe(2); + }); + + test("should clear seen lobbies when they are removed from the list", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-cleared", + gameConfig, + numClients: 5, + }; + + vi.clearAllMocks(); + + // Add lobby first time + const event1 = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + window.dispatchEvent(event1); + + expect((window as any).AudioContext.mock.calls.length).toBe(1); + expect(mockAudioContext.createOscillator.mock.calls.length).toBe(1); + + // Keep lobby but add a second one + const gameInfo2: GameInfo = { + gameID: "lobby-2", + gameConfig, + numClients: 5, + }; + + const event2 = new CustomEvent("lobbies-updated", { + detail: [gameInfo, gameInfo2], + }); + window.dispatchEvent(event2); + + // AudioContext still only created once, but oscillator called twice (once for each unique lobby) + expect((window as any).AudioContext.mock.calls.length).toBe(1); + expect(mockAudioContext.createOscillator.mock.calls.length).toBe(2); + + // Remove both lobbies + const event3 = new CustomEvent("lobbies-updated", { + detail: [], + }); + window.dispatchEvent(event3); + + // Re-add first lobby - should trigger notification again since it was cleared + const event4 = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + window.dispatchEvent(event4); + + // Oscillator called 3 times total + expect(mockAudioContext.createOscillator.mock.calls.length).toBe(3); + }); + }); + + describe("Cleanup and Destruction", () => { + test("should remove event listeners on destroy", () => { + const removeEventListenerSpy = vi.spyOn(window, "removeEventListener"); + manager.destroy(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "lobbies-updated", + expect.any(Function), + ); + expect(removeEventListenerSpy).toHaveBeenCalledWith( + "notification-settings-changed", + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); + }); + + test("should close AudioContext on destroy", () => { + const settings = { + ffaEnabled: true, + teamEnabled: false, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager = new LobbyNotificationManager(); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-cleanup", + gameConfig, + numClients: 5, + }; + + vi.clearAllMocks(); + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + window.dispatchEvent(event); + + // Verify notification was triggered and get AudioContext instance + expect((window as any).AudioContext.mock.calls.length).toBe(1); + + manager.destroy(); + + // Verify close() was called on the AudioContext + expect(mockAudioContext.close).toHaveBeenCalled(); + }); + }); + + describe("Edge Cases and Error Handling", () => { + beforeEach(() => { + const settings = { + ffaEnabled: true, + teamEnabled: true, + soundEnabled: true, + minTeamCount: 2, + maxTeamCount: 4, + }; + localStorage.setItem( + "lobbyNotificationSettings", + JSON.stringify(settings), + ); + manager = new LobbyNotificationManager(); + }); + + test("should handle lobbies-updated event with undefined gameConfig", () => { + const gameInfo: GameInfo = { + gameID: "lobby-no-config", + gameConfig: undefined as any, + numClients: 0, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + expect(() => window.dispatchEvent(event)).not.toThrow(); + }); + + test("should handle lobbies-updated event with undefined detail", () => { + const event = new CustomEvent("lobbies-updated", { + detail: undefined, + }); + + expect(() => window.dispatchEvent(event)).not.toThrow(); + }); + + test("should handle lobbies-updated event with null detail", () => { + const event = new CustomEvent("lobbies-updated", { + detail: null, + }); + + expect(() => window.dispatchEvent(event)).not.toThrow(); + }); + + test("should handle missing settings gracefully", () => { + manager.destroy(); + localStorage.clear(); + manager = new LobbyNotificationManager(); + + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.FFA, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + maxPlayers: 10, + }; + + const gameInfo: GameInfo = { + gameID: "lobby-no-settings", + gameConfig, + numClients: 5, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + expect(() => window.dispatchEvent(event)).not.toThrow(); + }); + + test("should handle GameConfig with missing optional maxPlayers", () => { + const gameConfig: GameConfig = { + gameMap: GameMapType.World, + difficulty: Difficulty.Hard, + donateGold: false, + donateTroops: false, + gameType: GameType.Private, + gameMode: GameMode.Team, + gameMapSize: GameMapSize.Compact, + disableNations: false, + bots: 0, + infiniteGold: false, + infiniteTroops: false, + instantBuild: false, + randomSpawn: false, + playerTeams: "Duos", + }; + + const gameInfo: GameInfo = { + gameID: "lobby-no-maxplayers", + gameConfig, + numClients: 5, + }; + + const event = new CustomEvent("lobbies-updated", { + detail: [gameInfo], + }); + + expect(() => window.dispatchEvent(event)).not.toThrow(); + }); + }); +});