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")