diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 34bf9e2d4..65d3fc62a 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -28,7 +28,7 @@ jobs: libxkbcommon-x11-0 \ x11-utils \ libyaml-dev \ - libegl1-mesa \ + libegl1 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 87f476807..a5752fd5c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: libxkbcommon-x11-0 \ x11-utils \ libyaml-dev \ - libegl1-mesa \ + libegl1 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e67f9adc0..6c4f0b1fa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -10,7 +10,7 @@ repos: hooks: - id: add-trailing-comma - repo: https://github.com/PyCQA/flake8 - rev: 7.1.0 + rev: 7.1.1 hooks: - id: flake8 - repo: https://github.com/PyCQA/isort diff --git a/res/client/client.css b/res/client/client.css index 5318bce7a..040e9c053 100644 --- a/res/client/client.css +++ b/res/client/client.css @@ -530,6 +530,17 @@ QTableWidget::item::hover border-radius: 3px; } +QTableWidget#filtersTableWidget::item:hover +{ + background: none; +} + +QTableWidget#filtersTableWidget::item:selected +{ + color: white; + background-color: #306030; +} + QTableWidget#nameHistoryTableWidget::item::hover { background: none; @@ -747,7 +758,7 @@ QScrollBar::sub-page background-color: #1f1f1f; } -QCheckBox#hideGamesWithPw +QCheckBox#hideGamesWithPw,#hideGamesWithMods { color:white; background-color: #111111; @@ -781,6 +792,10 @@ QLabel#VSLabel margin: 0px; } +QLabel#gameFiltersManagerDescription +{ + color: gold; +} QGroupBox { @@ -995,6 +1010,12 @@ QPushButton#showAllButton border-radius: 2px; } +QPushButton#manageGameFiltersButton:hover +{ + color: white; + background-color: #808080; +} + QSpinBox, QDoubleSpinBox { color:orange; diff --git a/res/games/filtercreator.ui b/res/games/filtercreator.ui new file mode 100644 index 000000000..da3590a59 --- /dev/null +++ b/res/games/filtercreator.ui @@ -0,0 +1,86 @@ + + + FilterCreator + + + + 0 + 0 + 383 + 300 + + + + Filter Creator + + + + + + + + + + + + Qt::Orientation::Vertical + + + + 20 + 40 + + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + FilterCreator + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + FilterCreator + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/res/games/filtermanager.ui b/res/games/filtermanager.ui new file mode 100644 index 000000000..0e1e5afb4 --- /dev/null +++ b/res/games/filtermanager.ui @@ -0,0 +1,145 @@ + + + Dialog + + + + 0 + 0 + 484 + 394 + + + + Manage Game Filters + + + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + + + + + Add + + + + + + + Remove + + + + + + + + + QAbstractItemView::EditTrigger::NoEditTriggers + + + QAbstractItemView::SelectionMode::SingleSelection + + + QAbstractItemView::SelectionBehavior::SelectRows + + + true + + + false + + + + id + + + + + Type + + + + + Constraint + + + + + Value + + + + + + + + + 10 + + + + Set rules upon which games will be EXCLUDED from game list. Case insensitive + + + + + + + + + + Qt::Orientation::Horizontal + + + QDialogButtonBox::StandardButton::Cancel|QDialogButtonBox::StandardButton::Ok + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 248 + 254 + + + 157 + 274 + + + + + buttonBox + rejected() + Dialog + reject() + + + 316 + 260 + + + 286 + 274 + + + + + diff --git a/res/games/games.ui b/res/games/games.ui index fe9ae044e..c001adc3c 100644 --- a/res/games/games.ui +++ b/res/games/games.ui @@ -395,6 +395,26 @@ + + + + Games shown: + + + + + + + + 0 + 0 + + + + Manage Filters + + + @@ -445,6 +465,13 @@ + + + + Hide Modded Games + + + diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py index c7c167740..adaec8f46 100644 --- a/src/api/ApiBase.py +++ b/src/api/ApiBase.py @@ -1,6 +1,5 @@ import json import logging -from typing import Any from typing import Callable from PyQt6 import QtWidgets @@ -26,6 +25,9 @@ class ApiBase(QObject): oauth: OAuth2Flow = OAuth2FlowInstance + def __do_nothing(*args, **kwargs) -> None: + pass + def __init__(self, route: str = "") -> None: QObject.__init__(self) self.route = route @@ -33,7 +35,8 @@ def __init__(self, route: str = "") -> None: self.manager = QNetworkAccessManager() self.manager.finished.connect(self.onRequestFinished) self._running = False - self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {} + self.handlers: dict[QNetworkReply | None, Callable] = {} + self.error_handlers: dict[QNetworkReply | None, Callable] = {} @classmethod def set_oauth(cls, oauth: OAuth2Flow) -> None: @@ -57,13 +60,23 @@ def _get_host_url(self) -> QUrl: return QUrl(Settings.get(self.host_config_key)) # query arguments like filter=login==Rhyza - def get_by_query(self, query_dict: dict, response_handler: Callable[[dict], Any]) -> None: + def get_by_query( + self, + query_dict: dict, + response_handler: Callable, + error_handler: Callable = __do_nothing, + ) -> None: url = self.build_query_url(query_dict) - self.get(url, response_handler) - - def get_by_endpoint(self, endpoint: str, response_handler: Callable[[dict], Any]) -> None: + self.get(url, response_handler, error_handler) + + def get_by_endpoint( + self, + endpoint: str, + response_handler: Callable, + error_handler: Callable = __do_nothing, + ) -> None: url = self._get_host_url().resolved(QUrl(endpoint)) - self.get(url, response_handler) + self.get(url, response_handler, error_handler) @staticmethod def prepare_request(url: QUrl | None) -> QNetworkRequest: @@ -74,11 +87,17 @@ def prepare_request(url: QUrl | None) -> QNetworkRequest: request.setAttribute(QNetworkRequest.Attribute.Http2AllowedAttribute, False) return request - def get(self, url: QUrl, response_handler: Callable[[dict], Any]) -> None: + def get( + self, + url: QUrl, + response_handler: Callable, + error_handler: Callable = __do_nothing, + ) -> None: self._running = True logger.debug("Sending API request with URL: {}".format(url.toString())) reply = self.manager.get(self.prepare_request(url)) self.handlers[reply] = response_handler + self.error_handlers[reply] = error_handler def parse_message(self, message: dict) -> dict: return message @@ -86,16 +105,23 @@ def parse_message(self, message: dict) -> dict: def onRequestFinished(self, reply: QNetworkReply) -> None: self._running = False if reply.error() != QNetworkReply.NetworkError.NoError: - logger.error("API request error: {}".format(reply.error())) + logger.error(f"API request error: {reply.error()}") + self.error_handlers[reply](reply) else: message_bytes = reply.readAll().data() message = json.loads(message_bytes.decode('utf-8')) result = self.parse_message(message) self.handlers[reply](result) self.handlers.pop(reply) + self.error_handlers.pop(reply) reply.deleteLater() def waitForCompletion(self): waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents while self._running: QtWidgets.QApplication.processEvents(waitFlag) + + def abort(self) -> None: + for reply in self.handlers.copy(): + if reply is not None: + reply.abort() diff --git a/src/api/models/Achievement.py b/src/api/models/Achievement.py index 7181e8ecd..3de125674 100644 --- a/src/api/models/Achievement.py +++ b/src/api/models/Achievement.py @@ -1,17 +1,18 @@ from __future__ import annotations +from enum import Enum + from pydantic import Field from src.api.models.AbstractEntity import AbstractEntity -from src.util import StringValuedEnum -class State(StringValuedEnum): +class State(Enum): REVEALED = "REVEALED" UNLOCKED = "UNLOCKED" -class ProgressType(StringValuedEnum): +class ProgressType(Enum): STANDARD = "STANDARD" INCREMENTAL = "INCREMENTAL" @@ -34,8 +35,8 @@ class Achievement(AbstractEntity): @property def init_state(self) -> State: - return State.from_string(self.initial_state) + return State(self.initial_state) @property def progress_type(self) -> ProgressType: - return ProgressType.from_string(self.typ) + return ProgressType(self.typ) diff --git a/src/api/models/MapType.py b/src/api/models/MapType.py index 2262ea7f4..467dfa08c 100644 --- a/src/api/models/MapType.py +++ b/src/api/models/MapType.py @@ -10,8 +10,6 @@ class MapType(Enum): @staticmethod def from_string(map_type: str) -> MapType: - for mtype in list(MapType): - if mtype.value == map_type: - return mtype - else: - return MapType.OTHER + if map_type in MapType: + return MapType(map_type) + return MapType.OTHER diff --git a/src/api/models/ModType.py b/src/api/models/ModType.py index 823e3b478..a8e2893df 100644 --- a/src/api/models/ModType.py +++ b/src/api/models/ModType.py @@ -10,7 +10,6 @@ class ModType(Enum): @staticmethod def from_string(string: str) -> ModType: - for modtype in list(ModType): - if modtype.value == string: - return modtype + if string in ModType: + return ModType(string) return ModType.OTHER diff --git a/src/api/models/PlayerAchievement.py b/src/api/models/PlayerAchievement.py index 578b2fa38..6e2861ff7 100644 --- a/src/api/models/PlayerAchievement.py +++ b/src/api/models/PlayerAchievement.py @@ -15,4 +15,4 @@ class PlayerAchievement(AbstractEntity): @property def current_state(self) -> State: - return State.from_string(self.state) + return State(self.state) diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py index 35fbf9fcc..63ec0a53a 100644 --- a/src/chat/ircconnection.py +++ b/src/chat/ircconnection.py @@ -5,12 +5,14 @@ import sys from irc.client import Event -from irc.client import IRCError from irc.client import ServerConnection +from irc.client import ServerConnectionError from irc.client import SimpleIRCClient from irc.client import is_channel from PyQt6.QtCore import QObject +from PyQt6.QtCore import QTimer from PyQt6.QtCore import pyqtSignal +from PyQt6.QtNetwork import QNetworkReply from src import config from src import util @@ -80,15 +82,41 @@ class IrcSignals(QObject): connected = pyqtSignal() disconnected = pyqtSignal() - def __init__(self): + +class Reconnector(QObject): + def __init__(self, connection: IrcConnection) -> None: QObject.__init__(self) + self.connection = connection + self.timer = QTimer() + self.timer.setSingleShot(True) + self.timer.timeout.connect(self.reconnect) + self.failures = 0 + + def on_connect(self) -> None: + logger.debug("Chat reactor is connected!") + self.failures = 0 + self.timer.stop() + + def reconnect(self) -> None: + self.connection.begin_connection_process() + + def on_connect_failure(self, reply: QNetworkReply) -> None: + self.failures += 1 + self.on_disconnect() + + def on_disconnect(self) -> None: + if self.failures < 3: + logger.info("Chat: reconnecting immediately") + self.reconnect() + else: + t = self.failures * 10_000 + self.timer.start(t) + logger.info(f"Scheduling chat reconnect in {t / 1000}") -class IrcConnection(IrcSignals, SimpleIRCClient): +class IrcConnection(SimpleIRCClient, IrcSignals): reactor_class = ReactorForSocketAdapter - token_received = pyqtSignal(str) - def __init__(self, host: int, port: int) -> None: IrcSignals.__init__(self) SimpleIRCClient.__init__(self) @@ -96,7 +124,6 @@ def __init__(self, host: int, port: int) -> None: self.host = host self.port = port self.api_accessor = UserApiAccessor() - self.token_received.connect(self.on_token_received) self.connect_factory = ConnectionFactory() self._password = None @@ -105,6 +132,9 @@ def __init__(self, host: int, port: int) -> None: self._nickserv_registered = False self._connected = False + self.reconnector = Reconnector(self) + self.reactor.socket_error.connect(self.reconnector.reconnect) + @classmethod def build(cls, settings: config.Settings, **kwargs) -> IrcConnection: port = settings.get("chat/port", 443, int) @@ -125,14 +155,18 @@ def set_nick_and_username(self, nick: str, username: str) -> None: self._username = username def begin_connection_process(self) -> None: - self.api_accessor.get_by_endpoint("/irc/ergochat/token", self.handle_irc_token) + self.api_accessor.get_by_endpoint( + "/irc/ergochat/token", + self.handle_irc_token, + self.reconnector.on_connect_failure, + ) def handle_irc_token(self, data: dict) -> None: irc_token = data["value"] - self.token_received.emit(irc_token) - - def on_token_received(self, token: str) -> None: - self.connect_(self._nick, self._username, f"token:{token}") + if self.connect_(self._nick, self._username, f"token:{irc_token}"): + self.reconnector.on_connect() + else: + self.reconnector.reconnect() def connect_(self, nick: str, username: str, password: str) -> bool: logger.info(f"Connecting to IRC at: {self.host}:{self.port}") @@ -151,9 +185,8 @@ def connect_(self, nick: str, username: str, password: str) -> bool: sasl_login=username, password=password, ) - self.connection.socket.message_received.connect(self.reactor.process_once) return True - except IRCError: + except ServerConnectionError: logger.debug("Unable to connect to IRC server.") logger.error("IRC Exception", exc_info=sys.exc_info()) return False @@ -423,6 +456,8 @@ def _handle_nickserv_message(self, notice): def on_disconnect(self, c: ServerConnection, e: Event) -> None: self._connected = False self.disconnected.emit() + message = e.arguments[0] + logger.info(f"Disconnected from chat: {message}") def on_privmsg(self, c, e): chatter = self._event_to_chatter(e) diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py index 3a4422025..ffacabe84 100644 --- a/src/chat/socketadapter.py +++ b/src/chat/socketadapter.py @@ -4,6 +4,7 @@ import time from irc.client import Reactor +from irc.client import ServerConnectionError from PyQt6.QtCore import QEventLoop from PyQt6.QtCore import QObject from PyQt6.QtCore import QUrl @@ -19,17 +20,32 @@ class WebSocketToSocket(QObject): """ Allows to use QWebSocket as a 'socket' """ message_received = pyqtSignal() + error_occurred = pyqtSignal() def __init__(self) -> None: super().__init__() self.socket = QWebSocket() self.socket.binaryMessageReceived.connect(self.on_bin_message_received) self.socket.errorOccurred.connect(self.on_socket_error) + self.socket.stateChanged.connect(self.on_socket_state_changed) self.buffer = b"" + self._connect_loop = QEventLoop() + self.socket.connected.connect(self._connect_loop.exit) + self.socket.errorOccurred.connect(self._connect_loop.exit) + + self._close_intended = False + def on_socket_error(self, error: QAbstractSocket.SocketError) -> None: logger.error(f"SocketAdapter error: {error}. Details: {self.socket.errorString()}") + def on_socket_state_changed(self, state: QAbstractSocket.SocketState) -> None: + logger.debug(f"SocketAdapter state changed: {state}") + # socket state can change without errors, that's why we emit `error_occurred` signal + # here and not in the `on_socket_error` method + if state == QAbstractSocket.SocketState.UnconnectedState and not self._close_intended: + self.error_occurred.emit() + def on_bin_message_received(self, message: bytes) -> None: # according to https://ircv3.net/specs/extensions/websocket # messages MUST NOT include trailing \r\n, but our non-websocket @@ -69,17 +85,26 @@ def _prepare_request(self, server_address: tuple[str, int]) -> QNetworkRequest: def connect(self, server_address: tuple[str, int]) -> None: self.socket.open(self._prepare_request(server_address)) - # FIXME: maybe there are too many usages of this loop trick - loop = QEventLoop() - self.socket.connected.connect(loop.exit) - self.socket.errorOccurred.connect(loop.exit) - loop.exec() + # UnknownSocketError is the default and here means "No Error" + if self.socket.error() != QAbstractSocket.SocketError.UnknownSocketError: + raise ServerConnectionError(self.socket.errorString()) + + if self.socket.state != QAbstractSocket.SocketState.ConnectedState: + self._connect_loop.exec() def close(self) -> None: + self._close_intended = True self.socket.close() -class ReactorForSocketAdapter(Reactor): +class ReactorForSocketAdapter(Reactor, QObject): + socket_error = pyqtSignal() + + def __init__(self) -> None: + QObject.__init__(self) + Reactor.__init__(self) + self._on_connect = self.on_connect + def process_once(self, timeout: float = 0.01) -> None: if self.sockets: self.process_data(self.sockets) @@ -87,9 +112,16 @@ def process_once(self, timeout: float = 0.01) -> None: time.sleep(timeout) self.process_timeout() + def on_connect(self, socket: WebSocketToSocket) -> None: + socket.message_received.connect(self.process_once) + socket.error_occurred.connect(self.on_socket_error) + + def on_socket_error(self) -> None: + self.socket_error.emit() + class ConnectionFactory: - def connect(self, server_address: tuple[str, int]) -> None: + def connect(self, server_address: tuple[str, int]) -> WebSocketToSocket: sock = WebSocketToSocket() sock.connect(server_address) return sock diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py index 290336880..393e10c3e 100644 --- a/src/client/_clientwindow.py +++ b/src/client/_clientwindow.py @@ -1,6 +1,7 @@ import logging import time from functools import partial +from typing import Any from PyQt6 import QtCore from PyQt6 import QtGui @@ -86,6 +87,8 @@ FormClass, BaseClass = util.THEME.loadUiType("client/client.ui") +ServerMessage = dict[str, Any] + class ClientWindow(FormClass, BaseClass): """ @@ -270,6 +273,7 @@ def __init__(self, *args, **kwargs): self.lobby_dispatch["match_found"] = self.handle_match_found_message self.lobby_dispatch["match_cancelled"] = self.handle_match_cancelled self.lobby_dispatch["search_info"] = self.handle_search_info + self.lobby_dispatch["search_violation"] = self.handle_search_violation self.lobby_info.social.connect(self.handle_social) # Process used to run Forged Alliance (managed in module fa) @@ -873,8 +877,8 @@ def add_warning_button(faction): self._update_tools.mandatory_update_aborted.connect(self.close) self._update_tools.checker.check() - def _connect_chat(self, me): - if not self.use_chat: + def _connect_chat(self, me: Player) -> None: + if not self.use_chat or self._chatMVC.connection.is_connected(): return self._chatMVC.connection.set_nick_and_username(me.login, f"{me.login}@FAF") self._chatMVC.connection.begin_connection_process() @@ -1613,23 +1617,20 @@ def finished_fa(self, exit_code): self.game_exit.emit() @QtCore.pyqtSlot(QtCore.QProcess.ProcessError) - def error_fa(self, error_code): + def error_fa(self, error: QtCore.QProcess.ProcessError) -> None: """ Slot hooked up to fa.instance when the process has failed to start. """ logger.error("FA has died with error: " + fa.instance.errorString()) - if error_code == 0: + if error == QtCore.QProcess.ProcessError.FailedToStart: logger.error("FA has failed to start") QtWidgets.QMessageBox.critical( self, "Error from FA", "FA has failed to start.", ) - elif error_code == 1: + elif error == QtCore.QProcess.ProcessError.Crashed: logger.error("FA has crashed or killed after starting") else: - text = ( - "FA has failed to start with error code: {}" - .format(error_code) - ) + text = f"FA has failed to start with error code: {error}" logger.error(text) QtWidgets.QMessageBox.critical(self, "Error from FA", text) self.game_exit.emit() @@ -1677,16 +1678,11 @@ def manage_power(self): if self.mod_menu is None: self.mod_menu = self.menu.addMenu("Administration") - action_lobby_kick = QtWidgets.QAction( - "Close player's FAF Client...", self.mod_menu, - ) + action_lobby_kick = QtGui.QAction("Close player's FAF Client...", self.mod_menu) action_lobby_kick.triggered.connect(self._on_lobby_kick_triggered) self.mod_menu.addAction(action_lobby_kick) - action_close_fa = QtWidgets.QAction( - "Close Player's Game...", - self.mod_menu, - ) + action_close_fa = QtGui.QAction("Close Player's Game...", self.mod_menu) action_close_fa.triggered.connect(self._close_game_dialog) self.mod_menu.addAction(action_close_fa) @@ -1777,11 +1773,16 @@ def handle_match_found_message(self, message): self.games.handleMatchFound(message) self.lobby_connection.send(dict(command="match_ready")) - def handle_match_cancelled(self, message): - logger.info("Received match_cancelled via JSON {}".format(message)) + def handle_match_cancelled(self, message: ServerMessage) -> None: + logger.info(f"Received match_cancelled via JSON {message}") + + if self.game_session is None or message["game_id"] != self.game_session.game_uid: + return + self.labelAutomatchInfo.setText("") self.labelAutomatchInfo.hide() - self.games.handleMatchCancelled(message) + fa.instance.kill_if_running() + QtWidgets.QMessageBox.information(self, "Cancelled", "Automatch was cancelled by server") def host_game( self, @@ -1947,17 +1948,15 @@ def handle_social(self, message): self.power_tools.power = message["power"] self.manage_power() - def handle_player_info(self, message): + def handle_player_info(self, message: ServerMessage) -> None: players = message["players"] - # Fix id being a Python keyword for player in players: - player["id_"] = player["id"] - del player["id"] + # Fix id being a Python keyword + player["id_"] = player.pop("id") - for player in players: id_ = int(player["id_"]) - logger.debug('Received update about player {}'.format(id_)) + logger.debug(f"Received update about player {id_}") if id_ in self.players: self.players[id_].update(**player) else: @@ -2066,3 +2065,9 @@ def set_faction(self, faction): def handle_search_info(self, message): logger.info("Handling search_info via JSON: {}".format(message)) self.games.handleMatchmakerSearchInfo(message) + + def handle_search_violation(self, message: ServerMessage) -> None: + # server handles violations and sends notice with each of them + # in addition to this message (which contains count and time) + # and currently there's no apparent reason to handle it + pass diff --git a/src/client/connection.py b/src/client/connection.py index e935d0bba..494e198f9 100644 --- a/src/client/connection.py +++ b/src/client/connection.py @@ -9,6 +9,7 @@ from PyQt6 import QtNetwork from PyQt6.QtCore import QByteArray from PyQt6.QtCore import QUrl +from PyQt6.QtNetwork import QNetworkReply from PyQt6.QtWebSockets import QWebSocket from src import fa @@ -46,7 +47,7 @@ def __init__(self, connection: ServerConnection) -> None: self._keepalive = False self._keepalive_timer = QtCore.QTimer(self) self._keepalive_timer.timeout.connect(self._ping_connection) - self.keepalive_interval = 60 * 1000 + self.keepalive_interval = 10 * 1000 self._waiting_for_message = False @property @@ -159,7 +160,6 @@ class ServerConnection(QtCore.QObject): connected = QtCore.pyqtSignal() disconnected = QtCore.pyqtSignal() message_received = QtCore.pyqtSignal() - access_url_ready = QtCore.pyqtSignal(QtCore.QUrl) def __init__(self, host, port, dispatch): QtCore.QObject.__init__(self) @@ -178,7 +178,6 @@ def __init__(self, host, port, dispatch): self._dispatch = dispatch self.api_accessor = UserApiAccessor() - self.access_url_ready.connect(self.open_websocket) def on_socket_state_change(self, state): states = QtNetwork.QAbstractSocket.SocketState @@ -237,20 +236,23 @@ def setPortFromConfig(self): def do_connect(self): self._disconnect_requested = False self.state = ConnectionState.CONNECTING - self.api_accessor.get_by_endpoint("/lobby/access", self.handle_lobby_access_api_response) + self.api_accessor.get_by_endpoint( + "/lobby/access", + self.handle_lobby_access_api_response, + self.on_lobby_access_api_error, + ) - def extract_url_from_api_response(self, data: dict) -> None: + def extract_url_from_api_response(self, data: dict) -> QUrl: # FIXME: remove this workaround when bug is resolved # see https://bugreports.qt.io/browse/QTBUG-120492 url = data["accessUrl"].replace("?verify", "/?verify") return QUrl(url) + def on_lobby_access_api_error(self, reply: QNetworkReply) -> None: + self.state = ConnectionState.DISCONNECTED + def handle_lobby_access_api_response(self, data: dict) -> None: url = self.extract_url_from_api_response(data) - self.access_url_ready.emit(url) - - @QtCore.pyqtSlot(QtCore.QUrl) - def open_websocket(self, url: QUrl) -> None: logger.debug(f"Opening WebSocket url: {url}") self.socket.open(url) diff --git a/src/config/__init__.py b/src/config/__init__.py index 6fd8adca5..7ddec8b0b 100644 --- a/src/config/__init__.py +++ b/src/config/__init__.py @@ -4,6 +4,8 @@ import os import sys import traceback +from collections.abc import Generator +from contextlib import contextmanager from logging.handlers import MemoryHandler from logging.handlers import RotatingFileHandler @@ -41,6 +43,15 @@ class Settings: selected configuration module if the key isn't found. """ + @contextmanager + @staticmethod + def group(name: str) -> Generator[QtCore.QSettings, None, None]: + try: + _settings.beginGroup(name) + yield _settings + finally: + _settings.endGroup() + @staticmethod def get(key, default=None, type=str): # Get from a local dict cache before hitting QSettings diff --git a/src/contextmenu/playercontextmenu.py b/src/contextmenu/playercontextmenu.py index 77824cc61..1441f3895 100644 --- a/src/contextmenu/playercontextmenu.py +++ b/src/contextmenu/playercontextmenu.py @@ -172,11 +172,8 @@ def party_actions( if online_player is None: return - if online_player.id in self._client_window.games.party.memberIds: - if ( - self._me.player.id - == self._client_window.games.party.owner_id - ): + if online_player.id in self._client_window.games.party.member_ids: + if self._me.player.id == self._client_window.games.party.owner_id: yield PlayerMenuItem.KICK_FROM_PARTY elif online_player.currentGame is not None: return diff --git a/src/coop/coopmodel.py b/src/coop/coopmodel.py index c03d8e9b7..15a65c1f7 100644 --- a/src/coop/coopmodel.py +++ b/src/coop/coopmodel.py @@ -1,4 +1,4 @@ -from src.games.gamemodel import GameSortModel +from src.games.filters.sortfiltermodel import GameSortModel from src.model.game import GameState diff --git a/src/fa/game_process.py b/src/fa/game_process.py index f078063c9..99c0f732c 100644 --- a/src/fa/game_process.py +++ b/src/fa/game_process.py @@ -173,10 +173,12 @@ def close(self): progress.close() - if self.running(): - self.kill() - + self.kill_if_running() self.close() + def kill_if_running(self) -> None: + if self.running(): + self.kill() + instance = GameProcess() diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py index 0dbdd7b13..900e5061c 100644 --- a/src/games/_gameswidget.py +++ b/src/games/_gameswidget.py @@ -2,11 +2,13 @@ import logging from typing import TYPE_CHECKING +from typing import Self from PyQt6 import QtWidgets from PyQt6.QtCore import Qt from PyQt6.QtCore import pyqtSignal from PyQt6.QtCore import pyqtSlot +from PyQt6.QtGui import QAction from PyQt6.QtGui import QColor from PyQt6.QtGui import QCursor @@ -14,10 +16,10 @@ from src import util from src.api.featured_mod_api import FeaturedModApiConnector from src.client.user import User -from src.config import Settings from src.games.automatchframe import MatchmakerQueue +from src.games.filters.controller import GamesSortFilterController +from src.games.filters.sortfiltermodel import CustomGameFilterModel from src.games.gameitem import GameViewBuilder -from src.games.gamemodel import CustomGameFilterModel from src.games.gamemodel import GameModel from src.games.hostgamewidget import GameLauncher from src.games.moditem import ModItem @@ -33,58 +35,32 @@ class Party: - def __init__(self, owner_id=-1, owner=None): + def __init__(self, owner_id: int = -1, owner: PartyMember | None = None) -> None: self.owner_id = owner_id self.members = [owner] if owner else [] @property - def memberCount(self): - return len(self.memberList) + def member_count(self) -> int: + return len(self.members) - @property - def memberList(self): - return self.members - - def addMember(self, member): - self.memberList.append(member) + def add_member(self, member: PartyMember) -> None: + self.members.append(member) @property - def memberIds(self): - uids = [] - if len(self.members) > 0: - for member in self.members: - uids.append(member.id_) - return uids - - def __eq__(self, other): - if ( - sorted(self.memberIds) == sorted(other.memberIds) - and self.owner_id == other.owner_id - ): - return True - else: - return False + def member_ids(self) -> list[int]: + return [member.id_ for member in self.members] + + def __eq__(self, other: Self) -> bool: + return set(self.member_ids) == set(other.member_ids) and self.owner_id == other.owner_id class PartyMember: - def __init__(self, id_=-1, factions=None): + def __init__(self, id_: int = -1, factions: list[str] | None = None) -> None: self.id_ = id_ - self.factions = ["uef", "cybran", "aeon", "seraphim"] + self.factions = factions class GamesWidget(FormClass, BaseClass): - - hide_private_games = Settings.persisted_property( - "play/hidePrivateGames", - default_value=False, - type=bool, - ) - sort_games_index = Settings.persisted_property( - "play/sortGames", - default_value=0, - type=int, - ) - matchmaker_search_info = pyqtSignal(dict) match_found_message = pyqtSignal(dict) stop_search_ranked_game = pyqtSignal() @@ -104,13 +80,21 @@ def __init__( self._me = me self.client = client # type - ClientWindow self.mods = {} - self._game_model = CustomGameFilterModel(self.client.user_relations, game_model) + self._game_filter_model = CustomGameFilterModel(self.client.user_relations, game_model) + self._game_filter_controller = GamesSortFilterController( + self._game_filter_model, + self.gamesShownCountLabel, + self.hideGamesWithPw, + self.hideGamesWithMods, + self.manageGameFiltersButton, + self.sortGamesComboBox, + ) self._game_launcher = game_launcher self.apiConnector = FeaturedModApiConnector() self.apiConnector.data_ready.connect(self.process_mod_info) - self.gameview = gameview_builder(self._game_model, self.gameList) + self.gameview = gameview_builder(self._game_filter_model, self.gameList) self.gameview.game_double_clicked.connect(self.gameDoubleClicked) self.matchFoundQueueName = "" @@ -122,29 +106,6 @@ def __init__( self.client.viewing_replay.connect(self.stopSearch) self.client.authorized.connect(self.onAuthorized) - self.sortGamesComboBox.addItems([ - 'By Players', - 'By avg. Player Rating', - 'By Map', - 'By Host', - 'By Age', - ]) - self.sortGamesComboBox.currentIndexChanged.connect( - self.sortGamesComboChanged, - ) - try: - CustomGameFilterModel.SortType(self.sort_games_index) - safe_sort_index = self.sort_games_index - except ValueError: - safe_sort_index = 0 - # This only triggers the signal if the index actually changes, - # so let's initialize it ourselves - self.sortGamesComboBox.setCurrentIndex(safe_sort_index) - self.sortGamesComboChanged(safe_sort_index) - - self.hideGamesWithPw.stateChanged.connect(self.togglePrivateGames) - self.hideGamesWithPw.setChecked(self.hide_private_games) - self.modList.itemDoubleClicked.connect(self.hostGameClicked) self.teamList.itemPressed.connect(self.teamListItemClicked) @@ -208,11 +169,6 @@ def process_mod_info(self, message: dict) -> None: self.client.replays.modList.addItem(mod) - @pyqtSlot(int) - def togglePrivateGames(self, state): - self.hide_private_games = state - self._game_model.hide_private_games = state - def stopSearch(self): self.searching = {"ladder1v1": False} self.client.labelAutomatchInfo.setText("") @@ -230,7 +186,7 @@ def gameDoubleClicked(self, game): if ( self.party is not None - and self.party.memberCount > 1 + and self.party.member_count > 1 and not self.leave_party() ): return @@ -266,17 +222,13 @@ def hostGameClicked(self, item): if ( self.party is not None - and self.party.memberCount > 1 + and self.party.member_count > 1 and not self.leave_party() ): return self.stopSearch() self._game_launcher.host_game(item.name, item.mod) - def sortGamesComboChanged(self, index): - self.sort_games_index = index - self._game_model.sort_type = CustomGameFilterModel.SortType(index) - def teamListItemClicked(self, item): if QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.LeftButton: # for no good reason doesn't always work as expected @@ -290,7 +242,7 @@ def teamListItemClicked(self, item): playerLogin = item.data(0) playerId = self.client.players[playerLogin].id menu = QtWidgets.QMenu(self) - actionKick = QtWidgets.QAction("Kick from party", menu) + actionKick = QAction("Kick from party", menu) actionKick.triggered.connect( lambda: self.kickPlayerFromParty(playerId), ) @@ -298,9 +250,7 @@ def teamListItemClicked(self, item): menu.popup(QCursor.pos()) def updateParty(self, message): - players_ids = [] - for member in message["members"]: - players_ids.append(member["player"]) + players_ids = [member["player"] for member in message["members"]] old_owner = self.client.players[self.party.owner_id] new_owner = self.client.players[message["owner"]] @@ -318,17 +268,15 @@ def updateParty(self, message): new_party.owner_id = new_owner.id for member in message["members"]: players_id = member["player"] - new_party.addMember( - PartyMember(id_=players_id, factions=member["factions"]), - ) + new_party.add_member(PartyMember(id_=players_id, factions=member["factions"])) else: new_party.owner_id = self._me.id - new_party.addMember(PartyMember(id_=self._me.id)) + new_party.add_member(PartyMember(id_=self._me.id)) if self.party != new_party: self.stopSearch() self.party = new_party - if self.party.memberCount > 1: + if self.party.member_count > 1: self.client._chatMVC.connection.join( "#{}{}".format(new_owner.login, PARTY_CHANNEL_SUFFIX), ) @@ -343,15 +291,15 @@ def showPartyInfo(self): def hidePartyInfo(self): self.partyInfo.hide() - def updatePartyInfoFrame(self): - if self.party.memberCount > 1: + def updatePartyInfoFrame(self) -> None: + if self.party.member_count > 1: self.showPartyInfo() else: self.hidePartyInfo() - def updateTeamList(self): + def updateTeamList(self) -> None: self.teamList.clear() - for member_id in self.party.memberIds: + for member_id in self.party.member_ids: if member_id != self._me.id: item = QtWidgets.QListWidgetItem( self.client.players[member_id].login, @@ -410,13 +358,6 @@ def handleMatchFound(self, message): self.matchFoundQueueName = message.get("queue_name", "") self.match_found_message.emit(message) - def handleMatchCancelled(self, message): - # the match cancelled message from the server can appear way too late, - # so any notifications or actions may be confusing if the user found a - # match but then aborted it and found a new one or joined/hosted a - # custom game - ... - def isInGame(self, player_id): if self.client.players[player_id].currentGame is None: return False diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py index 7fe1a56c8..84a552ec0 100644 --- a/src/games/automatchframe.py +++ b/src/games/automatchframe.py @@ -148,7 +148,7 @@ def updateLabelMatchingIn(self): def startSearchRanked(self): if ( - self.games.party.memberCount > self.teamSize + self.games.party.member_count > self.teamSize or self.games.party.owner_id != self.client.me.id ): return @@ -196,7 +196,7 @@ def stopSearchRanked(self): def handlePartyUpdate(self): if ( - self.games.party.memberCount > self.teamSize + self.games.party.member_count > self.teamSize or self.games.party.owner_id != self.client.me.id ): self.rankedPlay.setEnabled(False) diff --git a/src/games/filters/controller.py b/src/games/filters/controller.py new file mode 100644 index 000000000..42a80903a --- /dev/null +++ b/src/games/filters/controller.py @@ -0,0 +1,88 @@ +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import QCheckBox +from PyQt6.QtWidgets import QComboBox +from PyQt6.QtWidgets import QLabel +from PyQt6.QtWidgets import QPushButton + +from src.config import Settings +from src.games.filters.sortfiltermodel import CustomGameFilterModel + + +class GamesSortFilterController: + hide_private_games = Settings.persisted_property( + "play/hidePrivateGames", + default_value=False, + type=bool, + ) + hide_modded_games = Settings.persisted_property( + "play/hideModdedGames", + default_value=False, + type=bool, + ) + sort_games_index = Settings.persisted_property( + "play/sortGames", + default_value=0, + type=int, + ) + + def __init__( + self, + game_filter_model: CustomGameFilterModel, + games_shown: QLabel, + hide_private: QCheckBox, + hide_modded: QCheckBox, + filter_button: QPushButton, + sort_combobox: QComboBox, + ) -> None: + self.gamesShownCountLabel = games_shown + self.hidePrivateGamesCheckBox = hide_private + self.hideModdedGamesCheckBox = hide_modded + self.sortGamesComboBox = sort_combobox + + self.game_filter_model = game_filter_model + + self.hidePrivateGamesCheckBox.checkStateChanged.connect(self.toggle_private_games) + self.hidePrivateGamesCheckBox.setChecked(self.hide_private_games) + self.hideModdedGamesCheckBox.checkStateChanged.connect(self.toggle_modded_games) + self.hideModdedGamesCheckBox.setChecked(self.hide_modded_games) + + self.game_model = self.game_filter_model.sourceModel() + self.game_model.dataChanged.connect(self.on_games_count_changed) + + self.manageGameFiltersButton = filter_button + self.manageGameFiltersButton.clicked.connect(self.game_filter_model.manage_filters) + + self.sortGamesComboBox.addItems([ + "By Players", + "By avg. Player Rating", + "By Map", + "By Host", + "By Age", + ]) + self.sortGamesComboBox.currentIndexChanged.connect(self.on_sort_games_combo_changed) + + if self.sort_games_index in self.game_filter_model.SortType: + safe_sort_index = self.sort_games_index + else: + safe_sort_index = 0 + + self.sortGamesComboBox.setCurrentIndex(safe_sort_index) + + def on_games_count_changed(self) -> None: + shown = self.game_filter_model.rowCount() + total = self.game_filter_model.total_games() + self.gamesShownCountLabel.setText(f"Games shown: {shown}/{total}") + + def toggle_private_games(self, state: Qt.CheckState) -> None: + self.hide_private_games = state == Qt.CheckState.Checked + self.game_filter_model.hide_private_games = state == Qt.CheckState.Checked + self.on_games_count_changed() + + def toggle_modded_games(self, state: Qt.CheckState) -> None: + self.hide_modded_games = state == Qt.CheckState.Checked + self.game_filter_model.hide_modded_games = state == Qt.CheckState.Checked + self.on_games_count_changed() + + def on_sort_games_combo_changed(self, index: int): + self.sort_games_index = index + self.game_filter_model.sort_type = self.game_filter_model.SortType(index) diff --git a/src/games/filters/filter.py b/src/games/filters/filter.py new file mode 100644 index 000000000..a42bde946 --- /dev/null +++ b/src/games/filters/filter.py @@ -0,0 +1,56 @@ +from __future__ import annotations + +import operator +from typing import NamedTuple + +from src.model.game import Game + +FILTER_OPTIONS = { + "Map name": str, + "Host name": str, + "Game title": str, + "Average rating": int, + "Featured mod": str, +} + +FILTER_OPERATIONS = { + "contains": operator.contains, + "starts with": str.startswith, + "ends with": str.endswith, + "equals": operator.eq, + "not equals": operator.ne, + ">": operator.gt, + "<": operator.lt, + ">=": operator.ge, + "<=": operator.le, +} + + +class GameFilter(NamedTuple): + uid: int + name: str + constraint: str + value: str + + def serialize(self) -> str: + return f"{self.name},{self.constraint},{self.value}" + + def rejects(self, game: Game) -> bool: + op = FILTER_OPERATIONS[self.constraint] + if self.name == "Map name": + return op(game.mapdisplayname.casefold(), self.value.casefold()) + elif self.name == "Host name": + return op(game.host.casefold(), self.value.casefold()) + elif self.name == "Game title": + return op(game.title.casefold(), self.value.casefold()) + elif self.name == "Featured mod": + return op(game.featured_mod.casefold(), self.value.casefold()) + elif self.name == "Average rating": + try: + return op(game.average_rating, int(self.value)) + except (TypeError, ValueError): + pass + return True + + def accepts(self, game: Game) -> bool: + return not self.rejects(game) diff --git a/src/games/filters/manager.py b/src/games/filters/manager.py new file mode 100644 index 000000000..7df2e8fbc --- /dev/null +++ b/src/games/filters/manager.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QShowEvent +from PyQt6.QtWidgets import QTableWidgetItem + +from src import util +from src.config import Settings +from src.games.filters.filter import FILTER_OPERATIONS +from src.games.filters.filter import FILTER_OPTIONS +from src.games.filters.filter import GameFilter + +ManagerForm, ManagerBase = util.THEME.loadUiType("games/filtermanager.ui") + + +class GameFilterManager(ManagerForm, ManagerBase): + def __init__(self) -> None: + ManagerBase.__init__(self) + self.setupUi(self) + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + self.addButton.clicked.connect(self.open_creator) + self.removeButton.clicked.connect(self.remove_filter) + self.filtersTableWidget.setColumnHidden(0, True) # hide id column + + self.filters: list[GameFilter] = [] + self.load_filters() + + def load_filters(self) -> None: + with Settings.group("fa.games.filters") as group: + for uid in sorted(map(int, group.childKeys())): + game_filter = GameFilter(uid, *map(str.strip, group.value(str(uid)).split(",", 2))) + self.filters.append(game_filter) + self.append_to_table(game_filter) + + def remove_filter(self) -> None: + if len(self.filtersTableWidget.selectedItems()) == 0: + return + + row = self.filtersTableWidget.currentRow() + id_item = self.filtersTableWidget.item(row, 0) + with Settings.group("fa.games.filters") as group: + group.remove(id_item.text()) + self.filtersTableWidget.removeRow(row) + self.filters.pop(row) + + def append_to_table(self, game_filter: GameFilter) -> None: + rows = self.filtersTableWidget.rowCount() + cols = self.filtersTableWidget.columnCount() + self.filtersTableWidget.setRowCount(rows + 1) + + for col, content in zip(range(cols), game_filter): + item = QTableWidgetItem(str(content)) + self.filtersTableWidget.setItem(rows, col, item) + + def open_creator(self) -> None: + last_uid = max(self.filters).uid if len(self.filters) > 0 else 0 + creator = FilterCreator(last_uid + 1) + creator.exec() + if creator.result() == self.DialogCode.Accepted.value: + self.save(creator.result_filter()) + + def save(self, game_filter: GameFilter | None) -> None: + if game_filter is None: + return + + with Settings.group("fa.games.filters") as group: + group.setValue(str(game_filter.uid), game_filter.serialize()) + + self.filters.append(game_filter) + self.append_to_table(game_filter) + + +CreatorForm, CreatorBase = util.THEME.loadUiType("games/filtercreator.ui") + + +class FilterCreator(CreatorForm, CreatorBase): + def __init__(self, filter_id: int) -> None: + CreatorBase.__init__(self) + self.setupUi(self) + self.setStyleSheet(util.THEME.readstylesheet("client/client.css")) + + self.filterTypeComboBox.addItems(FILTER_OPTIONS) + self.constraintComboBox.addItems(FILTER_OPERATIONS) + + self.filters: list[GameFilterManager] = [] + self.accepted.connect(self.on_accept) + self.filter_id = filter_id + self._result_filter: GameFilter | None = None + + def on_accept(self) -> None: + text = self.lineEdit.text() + if text != "": + game_filter = GameFilter( + self.filter_id, + self.filterTypeComboBox.currentText(), + self.constraintComboBox.currentText(), + text, + ) + self._result_filter = game_filter + + def result_filter(self) -> GameFilter | None: + return self._result_filter + + def showEvent(self, event: QShowEvent) -> None: + CreatorBase.showEvent(self, event) + self.lineEdit.setFocus(Qt.FocusReason.MouseFocusReason) diff --git a/src/games/filters/sortfiltermodel.py b/src/games/filters/sortfiltermodel.py new file mode 100644 index 000000000..c2eba3b0c --- /dev/null +++ b/src/games/filters/sortfiltermodel.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from enum import Enum + +from PyQt6.QtCore import QSortFilterProxyModel +from PyQt6.QtCore import Qt + +from src.client.user import UserRelations +from src.games.filters.manager import GameFilterManager +from src.games.gamemodel import GameModel +from src.games.moditem import mod_invisible +from src.model.game import Game +from src.model.game import GameState + + +class GameSortModel(QSortFilterProxyModel): + class SortType(Enum): + PLAYER_NUMBER = 0 + AVERAGE_RATING = 1 + MAPNAME = 2 + HOSTNAME = 3 + AGE = 4 + + def __init__(self, relations: UserRelations, model: GameModel) -> None: + QSortFilterProxyModel.__init__(self) + self._sort_type = self.SortType.AGE + self.user_relations = relations + self.setSourceModel(model) + self.sort(0) + + def lessThan(self, leftIndex, rightIndex): + left = self.sourceModel().data(leftIndex, Qt.ItemDataRole.DisplayRole).game + right = self.sourceModel().data(rightIndex, Qt.ItemDataRole.DisplayRole).game + + comp_list = [self._lt_friend, self._lt_type, self._lt_fallback] + + for lt in comp_list: + if lt(left, right): + return True + elif lt(right, left): + return False + return False + + def _lt_friend(self, left: Game, right: Game) -> bool: + hostl = -1 if left.host_player is None else left.host_player.id + hostr = -1 if right.host_player is None else right.host_player.id + return ( + self.user_relations.model.is_friend(hostl) + and not self.user_relations.model.is_friend(hostr) + ) + + def _lt_type(self, left, right): + stype = self._sort_type + stypes = self.SortType + + if stype == stypes.PLAYER_NUMBER: + return len(left.players) > len(right.players) + elif stype == stypes.AVERAGE_RATING: + return left.average_rating > right.average_rating + elif stype == stypes.MAPNAME: + return left.mapdisplayname.lower() < right.mapdisplayname.lower() + elif stype == stypes.HOSTNAME: + return left.host.lower() < right.host.lower() + elif stype == stypes.AGE: + return left.uid < right.uid + + def _lt_fallback(self, left, right): + return left.uid < right.uid + + @property + def sort_type(self): + return self._sort_type + + @sort_type.setter + def sort_type(self, stype): + self._sort_type = stype + self.invalidate() + + def filterAcceptsRow(self, row, parent): + index = self.sourceModel().index(row, 0, parent) + if not index.isValid(): + return False + game = index.data().game + + return self.filter_accepts_game(game) + + def filter_accepts_game(self, game): + return True + + def total_games(self) -> int: + return sum( + game.state == GameState.OPEN and game.featured_mod != "coop" + for game in self.sourceModel().games() + ) + + +class CustomGameFilterModel(GameSortModel): + def __init__(self, relations: UserRelations, model: GameModel) -> None: + GameSortModel.__init__(self, relations, model) + self._hide_private_games = False + self._hide_modded_games = False + self.filter_manager = GameFilterManager() + + def filter_accepts_game(self, game: Game) -> bool: + if game.state != GameState.OPEN: + return False + if game.featured_mod in mod_invisible or game.featured_mod == "coop": + return False + if self.hide_private_games and game.password_protected: + return False + if self.hide_modded_games and game.sim_mods: + return False + for game_filter in self.filter_manager.filters: + if game_filter.rejects(game): + return False + + return True + + @property + def hide_private_games(self): + return self._hide_private_games + + @hide_private_games.setter + def hide_private_games(self, priv): + self._hide_private_games = priv + self.invalidateFilter() + + @property + def hide_modded_games(self) -> bool: + return self._hide_modded_games + + @hide_modded_games.setter + def hide_modded_games(self, modded: bool) -> None: + self._hide_modded_games = modded + self.invalidateFilter() + + def manage_filters(self) -> None: + self.filter_manager.exec() + self.invalidateFilter() diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py index 4e92a12a4..7ebf32216 100644 --- a/src/games/gamemodel.py +++ b/src/games/gamemodel.py @@ -1,14 +1,9 @@ -from enum import Enum - -from PyQt6.QtCore import QSortFilterProxyModel -from PyQt6.QtCore import Qt +from collections.abc import ValuesView from src.client.user import User from src.client.user import UserRelations from src.downloadManager import MapPreviewDownloader -from src.games.moditem import mod_invisible from src.model.game import Game -from src.model.game import GameState from src.model.gameset import Gameset from src.qt.models.qtlistmodel import QtListModel @@ -42,102 +37,6 @@ def remove_game(self, game): def clear_games(self): self._clear_items() - -class GameSortModel(QSortFilterProxyModel): - class SortType(Enum): - PLAYER_NUMBER = 0 - AVERAGE_RATING = 1 - MAPNAME = 2 - HOSTNAME = 3 - AGE = 4 - - def __init__(self, relations: UserRelations, model: GameModel) -> None: - QSortFilterProxyModel.__init__(self) - self._sort_type = self.SortType.AGE - self.user_relations = relations - self.setSourceModel(model) - self.sort(0) - - def lessThan(self, leftIndex, rightIndex): - left = self.sourceModel().data(leftIndex, Qt.ItemDataRole.DisplayRole).game - right = self.sourceModel().data(rightIndex, Qt.ItemDataRole.DisplayRole).game - - comp_list = [self._lt_friend, self._lt_type, self._lt_fallback] - - for lt in comp_list: - if lt(left, right): - return True - elif lt(right, left): - return False - return False - - def _lt_friend(self, left: Game, right: Game) -> bool: - hostl = -1 if left.host_player is None else left.host_player.id - hostr = -1 if right.host_player is None else right.host_player.id - return ( - self.user_relations.model.is_friend(hostl) - and not self.user_relations.model.is_friend(hostr) - ) - - def _lt_type(self, left, right): - stype = self._sort_type - stypes = self.SortType - - if stype == stypes.PLAYER_NUMBER: - return len(left.players) > len(right.players) - elif stype == stypes.AVERAGE_RATING: - return left.average_rating > right.average_rating - elif stype == stypes.MAPNAME: - return left.mapdisplayname.lower() < right.mapdisplayname.lower() - elif stype == stypes.HOSTNAME: - return left.host.lower() < right.host.lower() - elif stype == stypes.AGE: - return left.uid < right.uid - - def _lt_fallback(self, left, right): - return left.uid < right.uid - - @property - def sort_type(self): - return self._sort_type - - @sort_type.setter - def sort_type(self, stype): - self._sort_type = stype - self.invalidate() - - def filterAcceptsRow(self, row, parent): - index = self.sourceModel().index(row, 0, parent) - if not index.isValid(): - return False - game = index.data().game - - return self.filter_accepts_game(game) - - def filter_accepts_game(self, game): - return True - - -class CustomGameFilterModel(GameSortModel): - def __init__(self, relations: UserRelations, model: GameModel) -> None: - GameSortModel.__init__(self, relations, model) - self._hide_private_games = False - - def filter_accepts_game(self, game): - if game.state != GameState.OPEN: - return False - if game.featured_mod in mod_invisible: - return False - if self.hide_private_games and game.password_protected: - return False - - return True - - @property - def hide_private_games(self): - return self._hide_private_games - - @hide_private_games.setter - def hide_private_games(self, priv): - self._hide_private_games = priv - self.invalidateFilter() + def games(self) -> ValuesView[Game]: + assert self._gameset is not None + return self._gameset.values() diff --git a/src/playercard/achievements.py b/src/playercard/achievements.py index dfcab1740..c2b910f87 100644 --- a/src/playercard/achievements.py +++ b/src/playercard/achievements.py @@ -10,9 +10,9 @@ from PyQt6.QtGui import QPixmap from PyQt6.QtWidgets import QGridLayout from PyQt6.QtWidgets import QLabel -from PyQt6.QtWidgets import QLayout from PyQt6.QtWidgets import QProgressBar from PyQt6.QtWidgets import QSizePolicy +from PyQt6.QtWidgets import QVBoxLayout from PyQt6.QtWidgets import QWidget from src.api.models.Achievement import Achievement @@ -80,6 +80,7 @@ def icon(self, icon_name: str = "") -> QPixmap: def set_icon(self, pixmap: QPixmap) -> None: self.iconLabel.setPixmap(pixmap) + self.iconLabel.setEnabled(self.player_achievement.current_state == State.UNLOCKED) def on_icon_downloaded(self, _: str, pixmap: QPixmap) -> None: self.set_icon(pixmap) @@ -89,7 +90,7 @@ def download_icon_if_needed(self, url: str) -> None: class AchievementsHandler: - def __init__(self, layout: QLayout, player_id: str) -> None: + def __init__(self, layout: QVBoxLayout, player_id: str) -> None: self.player_id = player_id self.layout = layout self.player_achievements_api = PlayerAchievementApiAccessor() @@ -156,9 +157,9 @@ def group_achievements( self, player_achievements: Iterator[PlayerAchievement], ) -> AchievementGroup: - unlocked, locked, included_ids = [], [], [] + unlocked, locked, progressed_any_percent = [], [], [] for player_achievement in player_achievements: - included_ids.append(player_achievement.achievement.xd) + progressed_any_percent.append(player_achievement.achievement.xd) if player_achievement.current_state == State.UNLOCKED: unlocked.append(player_achievement) else: @@ -166,7 +167,7 @@ def group_achievements( locked.extend(( self.mock_player_achievement(entry) for entry in self.all_achievements - if entry.xd not in included_ids + if entry.xd not in progressed_any_percent )) return AchievementGroup(locked, unlocked) diff --git a/src/playercard/playerinfodialog.py b/src/playercard/playerinfodialog.py index 9386964b8..353bc092c 100644 --- a/src/playercard/playerinfodialog.py +++ b/src/playercard/playerinfodialog.py @@ -1,5 +1,6 @@ from __future__ import annotations +from PyQt6.QtGui import QCloseEvent from PyQt6.QtWidgets import QTableWidgetItem from src import util @@ -95,3 +96,7 @@ def add_avatars(self, avatar_assignments: list[AvatarAssignment] | None) -> None def process_player_events(self, events: list[PlayerEvent]) -> None: for chartview in self.stats_charts.player_events_charts(events): self.statsChartsLayout.addWidget(chartview) + + def closeEvent(self, event: QCloseEvent) -> None: + self.tab_widget_ctrl.close() + BaseClass.closeEvent(self, event) diff --git a/src/playercard/ratingtabwidget.py b/src/playercard/ratingtabwidget.py index 795b6411e..4311667ee 100644 --- a/src/playercard/ratingtabwidget.py +++ b/src/playercard/ratingtabwidget.py @@ -91,6 +91,10 @@ def __init__( self.workers = [] def __del__(self) -> None: + self.close() + + def close(self) -> None: + self.ratings_history_api.abort() try: self.clear_threads() except RuntimeError: @@ -142,6 +146,10 @@ def __init__(self, player_id: str, tab_widget: QTabWidget) -> None: def run(self) -> None: self.leaderboards_api.requestData() + def close(self) -> None: + for tab in self.tabs.values(): + tab.close() + def populate_leaderboards(self, message: dict[str, list[Leaderboard]]) -> None: for index, leaderboard in enumerate(message["values"]): widget = pg.PlotWidget() diff --git a/src/qt/models/qtlistmodel.py b/src/qt/models/qtlistmodel.py index e12742441..34346394d 100644 --- a/src/qt/models/qtlistmodel.py +++ b/src/qt/models/qtlistmodel.py @@ -10,7 +10,7 @@ def __init__(self, item_builder): self._itemlist = [] # For queries self._item_builder = item_builder - def rowCount(self, parent): + def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: if parent.isValid(): return 0 return len(self._itemlist) diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py index c5fdcc014..46bee22c1 100644 --- a/src/replays/_replayswidget.py +++ b/src/replays/_replayswidget.py @@ -223,8 +223,8 @@ def liveTreePressed(self, item): menu = QtWidgets.QMenu(self.liveTree) # Actions for Games and Replays - actionReplay = QtWidgets.QAction("Replay in FA", menu) - actionLink = QtWidgets.QAction("Copy Link", menu) + actionReplay = QtGui.QAction("Replay in FA", menu) + actionLink = QtGui.QAction("Copy Link", menu) # Adding to menu menu.addAction(actionReplay) @@ -257,7 +257,7 @@ def liveTreeDoubleClicked(self, item): if ( self.client.games.party - and self.client.games.party.memberCount > 1 + and self.client.games.party.member_count > 1 ): if not self.client.games.leave_party(): return @@ -515,8 +515,8 @@ def myTreePressed(self, item): menu = QtWidgets.QMenu(self.myTree) # Actions for Games and Replays - actionReplay = QtWidgets.QAction("Replay", menu) - actionExplorer = QtWidgets.QAction("Show in Explorer", menu) + actionReplay = QtGui.QAction("Replay", menu) + actionExplorer = QtGui.QAction("Show in Explorer", menu) # Adding to menu menu.addAction(actionReplay) @@ -918,7 +918,7 @@ def online_tree_clicked(self, item: ReplayItem | QTreeWidgetItem) -> None: def onlineTreeDoubleClicked(self, item): if ( self.client.games.party - and self.client.games.party.memberCount > 1 + and self.client.games.party.member_count > 1 ): if not self.client.games.leave_party(): return diff --git a/src/util/__init__.py b/src/util/__init__.py index 4f58ff856..eef9a315d 100644 --- a/src/util/__init__.py +++ b/src/util/__init__.py @@ -7,8 +7,6 @@ import shutil import subprocess import sys -from enum import Enum -from typing import Self from PyQt6 import QtWidgets from PyQt6.QtCore import QDateTime @@ -529,13 +527,3 @@ def capitalize(string: str) -> str: Capitalize the first letter only, leave the rest as it is """ return f"{string[0].upper()}{string[1:]}" - - -class StringValuedEnum(Enum): - - @classmethod - def from_string(cls, string: str) -> Self: - for member in iter(cls): - if member.value == string: - return member - raise ValueError("Unsupported value")