-
-
diff --git a/res/games/gameitem.qthtml b/res/games/gameitem.qthtml
new file mode 100644
index 000000000..5e45b210a
--- /dev/null
+++ b/res/games/gameitem.qthtml
@@ -0,0 +1,130 @@
+{# Jinja template for the game tooltip #}
+{%- macro playermacro(player, me, iconpath, teamalign) %}
+ {% if player.login is defined %}
+ {% set player_login = player.login %}
+ {% set player_country = player.country | lower %}
+ {% set player_country_path = iconpath~player_country~".png" %}
+ {% set player_global_rating = player.global_estimate %}
+ {% else %}
+ {% set player_login = player %}
+ {% set player_country_path = "" %}
+ {% set player_global_rating = "???" %}
+ {% endif %}
+
+ {% if me.login is defined %}
+ {% set me_login = me.login %}
+ {% else %}
+ {% set me_login = me %}
+ {% endif %}
+
+ {# This is needed to prevent a new line created on every "-" #}
+ {% if "-" in player_login %}
+ {% set player_login = player_login | replace("-", "‑") %}
+ {% endif %}
+ {% if "-" in me_login %}
+ {% set me_login = me_login | replace("-", "‑") %}
+ {% endif %}
+
+
+ {% if player.clan == me.clan and player.clan is not none and player.clan is defined and player != me %}
+ {% set player_login = ""~player_login~"" %}
+ {% elif player_login == me_login %}
+ {% set player_login = ""~player_login~"" %}
+ {% endif %}
+
+ {% set width = 0 %}
+
This probably means you need "
- "to fix the file permissions in C:\\ProgramData. Proceed at your own risk.")
- box.setStandardButtons(QtWidgets.QMessageBox.Ignore | QtWidgets.QMessageBox.Close)
- box.setIcon(QtWidgets.QMessageBox.Critical)
+ box = QMessageBox()
+ box.setText(
+ "FAF should not be run as an administrator!
This "
+ "probably means you need to fix the file permissions in "
+ "C:\\ProgramData. Proceed at your own risk.",
+ )
+ box.setStandardButtons(QMessageBox.StandardButton.Ignore | QMessageBox.StandardButton.Close)
+ box.setIcon(QMessageBox.Icon.Critical)
box.setWindowTitle("FAF privilege error")
- if box.exec_() == QtWidgets.QMessageBox.Ignore:
+ if box.exec() == QMessageBox.StandardButton.Ignore:
Settings.set("client/ignore_admin", True)
-def runFAF():
+def run_faf():
# Load theme from settings (one of the first things to be done)
util.THEME.loadTheme()
@@ -101,29 +105,45 @@ def runFAF():
faf_client = client.instance
faf_client.setup()
faf_client.show()
- faf_client.doConnect()
+ faf_client.try_to_auto_login()
# Main update loop
- QtWidgets.QApplication.exec_()
+ QApplication.exec()
+
+
+def set_style(app: QApplication) -> None:
+ styles = QStyleFactory.keys()
+ preferred_style = Settings.get("theme/style", "windowsvista")
+ if preferred_style in styles:
+ app.setStyle(QStyleFactory.create(preferred_style))
if __name__ == '__main__':
import logging
+
import config
- QtWidgets.QApplication.setAttribute(Qt.AA_ShareOpenGLContexts)
- app = QtWidgets.QApplication(trailing_args)
+ QApplication.setAttribute(Qt.ApplicationAttribute.AA_ShareOpenGLContexts)
+ app = QApplication(["FAF Python Client"] + trailing_args)
+ set_style(app)
if sys.platform == 'win32':
- import platform
import ctypes
+ import platform
if platform.release() != "XP": # legacy special :-)
if config.admin.isUserAdmin():
- AdminUserErrorDialog()
-
- if getattr(ctypes.windll.shell32, "SetCurrentProcessExplicitAppUserModelID", None) is not None:
+ admin_user_error_dialog()
+
+ attribute = getattr(
+ ctypes.windll.shell32,
+ "SetCurrentProcessExplicitAppUserModelID",
+ None,
+ )
+ if attribute is not None:
myappid = 'com.faforever.lobby'
- ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
+ myappid,
+ )
logger = logging.getLogger(__name__)
logger.info(">>> --------------------------- Application Launch")
@@ -136,16 +156,20 @@ def runFAF():
if len(trailing_args) == 0:
# Do the magic
- sys.path += ['.']
- runFAF()
+ sys.path.extend(['.'])
+ run_faf()
else:
# Try to interpret the argument as a replay.
- if trailing_args[0].lower().endswith(".fafreplay") or trailing_args[0].lower().endswith(".scfareplay"):
+ if (
+ trailing_args[0].lower().endswith(".fafreplay")
+ or trailing_args[0].lower().endswith(".scfareplay")
+ ):
import fa
fa.replay(trailing_args[0], True) # Launch as detached process
# End of show
app.closeAllWindows()
+ app.deleteLater()
app.quit()
# End the application, perform some housekeeping
diff --git a/src/api/ApiAccessors.py b/src/api/ApiAccessors.py
new file mode 100644
index 000000000..bc17ed6c5
--- /dev/null
+++ b/src/api/ApiAccessors.py
@@ -0,0 +1,103 @@
+import logging
+
+from PyQt6.QtCore import pyqtSignal
+
+from api.ApiBase import ApiBase
+
+logger = logging.getLogger(__name__)
+
+
+class ApiAccessor(ApiBase):
+ def __init__(self, route: str = "") -> None:
+ super().__init__(route)
+ self.host_config_key = "api"
+
+
+class UserApiAccessor(ApiBase):
+ def __init__(self, route: str = "") -> None:
+ super().__init__(route)
+ self.host_config_key = "user_api"
+
+
+class DataApiAccessor(ApiAccessor):
+ data_ready = pyqtSignal(dict)
+
+ def parse_message(self, message: dict) -> dict:
+ included = self.parseIncluded(message)
+ result = {}
+ result["data"] = self.parseData(message, included)
+ result["meta"] = self.parseMeta(message)
+ return result
+
+ def parseIncluded(self, message: dict) -> dict:
+ result: dict = {}
+ relationships = []
+ if "included" in message:
+ for inc_item in message["included"]:
+ if not inc_item["type"] in result:
+ result[inc_item["type"]] = {}
+ if "attributes" in inc_item:
+ type_ = inc_item["type"]
+ id_ = inc_item["id"]
+ result[type_][id_] = inc_item["attributes"]
+ if "relationships" in inc_item:
+ for key, value in inc_item["relationships"].items():
+ relationships.append((
+ inc_item["type"], inc_item["id"], key, value,
+ ))
+ message.pop('included')
+ # resolve relationships
+ for r in relationships:
+ result[r[0]][r[1]][r[2]] = self.parseData(r[3], result)
+ return result
+
+ def parseData(self, message: dict, included: dict) -> dict | list:
+ if "data" in message:
+ if isinstance(message["data"], (list)):
+ result = []
+ for data in message["data"]:
+ result.append(self.parseSingleData(data, included))
+ return result
+ elif isinstance(message["data"], (dict)):
+ return self.parseSingleData(message["data"], included)
+ else:
+ logger.error("error in response", message)
+ if "included" in message:
+ logger.error("unexpected 'included' in message", message)
+ return {}
+
+ def parseSingleData(self, data: dict, included: dict) -> dict:
+ result = {}
+ try:
+ if (
+ data["type"] in included
+ and data["id"] in included[data["type"]]
+ ):
+ result = included[data["type"]][data["id"]]
+ result["id"] = data["id"]
+ if "type" not in result:
+ result["type"] = data["type"]
+ if "attributes" in data:
+ for key, value in data["attributes"].items():
+ result[key] = value
+ if "relationships" in data:
+ for key, value in data["relationships"].items():
+ result[key] = self.parseData(value, included)
+ except Exception as e:
+ logger.error(f"Erorr parsing {data}: {e}")
+ return result
+
+ def parseMeta(self, message: dict) -> dict:
+ if "meta" in message:
+ return message["meta"]
+ return {}
+
+ def requestData(self, query_dict: dict | None = None) -> None:
+ query_dict = query_dict or {}
+ self.get_by_query(query_dict, self.handle_response)
+
+ def prepare_data(self, message: dict) -> dict:
+ return message
+
+ def handle_response(self, message: dict) -> None:
+ self.data_ready.emit(self.prepare_data(message))
diff --git a/src/api/ApiBase.py b/src/api/ApiBase.py
new file mode 100644
index 000000000..d856bdf25
--- /dev/null
+++ b/src/api/ApiBase.py
@@ -0,0 +1,101 @@
+import json
+import logging
+from typing import Any
+from typing import Callable
+
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QByteArray
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import QUrlQuery
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
+
+from config import Settings
+from oauth.oauth_flow import OAuth2Flow
+from oauth.oauth_flow import OAuth2FlowInstance
+
+logger = logging.getLogger(__name__)
+
+DO_NOT_ENCODE = QByteArray()
+DO_NOT_ENCODE.append(b":/?&=.,")
+
+
+class ApiBase(QObject):
+ oauth: OAuth2Flow = OAuth2FlowInstance
+
+ def __init__(self, route: str = "") -> None:
+ QObject.__init__(self)
+ self.route = route
+ self.host_config_key = ""
+ self.manager = QNetworkAccessManager()
+ self.manager.finished.connect(self.onRequestFinished)
+ self._running = False
+ self.handlers: dict[QNetworkReply | None, Callable[[dict], Any]] = {}
+
+ @classmethod
+ def set_oauth(cls, oauth: OAuth2Flow) -> None:
+ cls.oauth = oauth
+
+ def build_query_url(self, query_dict: dict) -> QUrl:
+ query = QUrlQuery()
+ for key, value in query_dict.items():
+ query.addQueryItem(key, str(value))
+ stringQuery = query.toString(QUrl.ComponentFormattingOption.FullyDecoded)
+ percentEncodedByteArrayQuery = QUrl.toPercentEncoding(
+ stringQuery,
+ exclude=DO_NOT_ENCODE,
+ )
+ percentEncodedStrQuery = percentEncodedByteArrayQuery.data().decode()
+ url = self._get_host_url().resolved(QUrl(self.route))
+ url.setQuery(percentEncodedStrQuery)
+ return url
+
+ 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:
+ 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:
+ url = self._get_host_url().resolved(QUrl(endpoint))
+ self.get(url, response_handler)
+
+ @staticmethod
+ def prepare_request(url: QUrl | None) -> QNetworkRequest:
+ request = QNetworkRequest(url) if url else QNetworkRequest()
+ # last 2 args are unused, but for some reason they are required
+ ApiBase.oauth.prepareRequest(request, QByteArray(), QByteArray())
+ # FIXME: remove when https://bugreports.qt.io/browse/QTBUG-123891 is deployed
+ request.setAttribute(QNetworkRequest.Attribute.Http2AllowedAttribute, False)
+ return request
+
+ def get(self, url: QUrl, response_handler: Callable[[dict], Any]) -> 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
+
+ def parse_message(self, message: dict) -> dict:
+ return message
+
+ def onRequestFinished(self, reply: QNetworkReply) -> None:
+ self._running = False
+ if reply.error() != QNetworkReply.NetworkError.NoError:
+ logger.error("API request error: {}".format(reply.error()))
+ 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)
+ reply.deleteLater()
+
+ def waitForCompletion(self):
+ waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
+ while self._running:
+ QtWidgets.QApplication.processEvents(waitFlag)
diff --git a/src/api/__init__.py b/src/api/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/api/coop_api.py b/src/api/coop_api.py
new file mode 100644
index 000000000..d1e1cf6c6
--- /dev/null
+++ b/src/api/coop_api.py
@@ -0,0 +1,58 @@
+from api.ApiAccessors import DataApiAccessor
+from api.models.CoopResult import CoopResult
+from api.models.CoopScenario import CoopScenario
+from api.parsers.CoopResultParser import CoopResultParser
+from api.parsers.CoopScenarioParser import CoopScenarioParser
+
+
+class CoopApiAccessor(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__("/data/coopScenario")
+
+ def request_coop_scenarios(self) -> None:
+ self.requestData({"include": "maps"})
+
+ def prepare_data(self, message: dict) -> dict[str, list[CoopScenario]]:
+ return {"values": CoopScenarioParser.parse_many(message["data"])}
+
+
+class CoopResultApiAccessor(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__("/data/coopResult")
+
+ def prepare_query_dict(self, mission: int) -> dict:
+ return {
+ "filter": f"mission=={mission}",
+ "include": "game,game.playerStats.player",
+ "sort": "duration",
+ "page[size]": 1000,
+ }
+
+ def extend_filter(self, query_options: dict, filteroption: str) -> dict:
+ cur_filters = query_options.get("filter", "")
+ query_options["filter"] = ";".join((cur_filters, filteroption)).removeprefix(";")
+ return query_options
+
+ def request_coop_results(self, mission: int, player_count: int) -> None:
+ default_query = self.prepare_query_dict(mission)
+ query = self.extend_filter(default_query, f"playerCount=={player_count}")
+ self.requestData(query)
+
+ def request_coop_results_general(self, mission: int) -> None:
+ self.requestData(self.prepare_query_dict(mission))
+
+ def filter_unique_teams(self, results: list[CoopResult]) -> list[CoopResult]:
+ unique_results = []
+ unique_teams = set()
+ for result in results:
+ player_ids = [player_stat.player.xd for player_stat in result.game.player_stats]
+ players_tuple = tuple(sorted(player_ids))
+ if players_tuple not in unique_teams:
+ unique_results.append(result)
+ unique_teams.add(players_tuple)
+ return unique_results
+
+ def prepare_data(self, message: dict) -> dict[str, list[CoopResult]]:
+ parsed = CoopResultParser.parse_many(message["data"])
+ distinct = self.filter_unique_teams(parsed)
+ return {"values": distinct}
diff --git a/src/api/featured_mod_api.py b/src/api/featured_mod_api.py
new file mode 100644
index 000000000..9d1f67b7c
--- /dev/null
+++ b/src/api/featured_mod_api.py
@@ -0,0 +1,43 @@
+import logging
+
+from api.ApiAccessors import DataApiAccessor
+from api.models.FeaturedMod import FeaturedMod
+from api.models.FeaturedModFile import FeaturedModFile
+from api.parsers.FeaturedModFileParser import FeaturedModFileParser
+from api.parsers.FeaturedModParser import FeaturedModParser
+
+logger = logging.getLogger(__name__)
+
+
+class FeaturedModApiConnector(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__("/data/featuredMod")
+
+ def prepare_data(self, message: dict) -> dict[str, list[FeaturedMod]]:
+ return {"values": FeaturedModParser.parse_many(message["data"])}
+
+ def handle_featured_mod(self, message: dict) -> None:
+ self.featured_mod = FeaturedModParser.parse(message["data"][0])
+
+ def request_fmod_by_name(self, technical_name: str) -> None:
+ queryDict = {"filter": f"technicalName=={technical_name}"}
+ self.get_by_query(queryDict, self.handle_featured_mod)
+
+ def request_and_get_fmod_by_name(self, technicalName) -> FeaturedMod:
+ self.request_fmod_by_name(technicalName)
+ self.waitForCompletion()
+ return self.featured_mod
+
+
+class FeaturedModFilesApiConnector(DataApiAccessor):
+ def __init__(self, mod_id: str, version: str) -> None:
+ super().__init__(f"/featuredMods/{mod_id}/files/{version}")
+ self.featured_mod_files = []
+
+ def handle_response(self, message: dict) -> None:
+ self.featured_mod_files = FeaturedModFileParser.parse_many(message["data"])
+
+ def get_files(self) -> list[FeaturedModFile]:
+ self.requestData()
+ self.waitForCompletion()
+ return self.featured_mod_files
diff --git a/src/api/matchmaker_queue_api.py b/src/api/matchmaker_queue_api.py
new file mode 100644
index 000000000..576a85a31
--- /dev/null
+++ b/src/api/matchmaker_queue_api.py
@@ -0,0 +1,26 @@
+import logging
+
+from api.ApiAccessors import DataApiAccessor
+
+logger = logging.getLogger(__name__)
+
+
+class MatchmakerQueueApiConnector(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__('/data/matchmakerQueue')
+
+ def prepare_data(self, message: dict) -> None:
+ prepared_data = {
+ "command": "matchmaker_queue_info",
+ "values": [],
+ "meta": message["meta"],
+ }
+ for queue in message["data"]:
+ preparedQueue = {
+ "technicalName": queue["technicalName"],
+ "ratingType": queue["leaderboard"]["technicalName"],
+ "id": queue["id"],
+ "leaderboardId": queue["leaderboard"]["id"],
+ }
+ prepared_data["values"].append(preparedQueue)
+ return prepared_data
diff --git a/src/api/models/AbstractEntity.py b/src/api/models/AbstractEntity.py
new file mode 100644
index 000000000..c49e15a4b
--- /dev/null
+++ b/src/api/models/AbstractEntity.py
@@ -0,0 +1,9 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+
+
+class AbstractEntity(ConfiguredModel):
+ xd: str = Field(alias="id")
+ create_time: str = Field(alias="createTime")
+ update_time: str = Field(alias="updateTime")
diff --git a/src/api/models/ConfiguredModel.py b/src/api/models/ConfiguredModel.py
new file mode 100644
index 000000000..4c4e4e1c5
--- /dev/null
+++ b/src/api/models/ConfiguredModel.py
@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+from pydantic import ConfigDict
+
+
+class ConfiguredModel(BaseModel):
+ model_config = ConfigDict(populate_by_name=True)
diff --git a/src/api/models/CoopMission.py b/src/api/models/CoopMission.py
new file mode 100644
index 000000000..09a64acdc
--- /dev/null
+++ b/src/api/models/CoopMission.py
@@ -0,0 +1,16 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+
+
+class CoopMission(ConfiguredModel):
+ xd: int = Field(alias="id")
+ category: str
+ description: str
+ download_url: str = Field(alias="downloadUrl")
+ folder_name: str = Field(alias="folderName")
+ name: str
+ order: int
+ thumbnail_url_large: str = Field(alias="thumbnailUrlLarge")
+ thumbnail_url_small: str = Field(alias="thumbnailUrlSmall")
+ version: int
diff --git a/src/api/models/CoopResult.py b/src/api/models/CoopResult.py
new file mode 100644
index 000000000..8e4d34e02
--- /dev/null
+++ b/src/api/models/CoopResult.py
@@ -0,0 +1,14 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+from api.models.Game import Game
+
+
+class CoopResult(ConfiguredModel):
+ xd: str = Field(alias="id")
+ duration: int
+ mission: int
+ player_count: int = Field(alias="playerCount")
+ secondary_objectives: bool = Field(alias="secondaryObjectives")
+
+ game: Game | None = Field(None)
diff --git a/src/api/models/CoopScenario.py b/src/api/models/CoopScenario.py
new file mode 100644
index 000000000..5d30ca789
--- /dev/null
+++ b/src/api/models/CoopScenario.py
@@ -0,0 +1,13 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+from api.models.CoopMission import CoopMission
+
+
+class CoopScenario(ConfiguredModel):
+ xd: int = Field(alias="id")
+ name: str
+ order: int
+ description: str | None
+ faction: str
+ maps: list[CoopMission]
diff --git a/src/api/models/FeaturedMod.py b/src/api/models/FeaturedMod.py
new file mode 100644
index 000000000..cff4af044
--- /dev/null
+++ b/src/api/models/FeaturedMod.py
@@ -0,0 +1,12 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+
+
+class FeaturedMod(ConfiguredModel):
+ xd: str = Field(alias="id")
+ name: str = Field(alias="technicalName")
+ fullname: str = Field(alias="displayName")
+ visible: bool
+ order: int = Field(0)
+ description: str = Field("No description provided")
diff --git a/src/api/models/FeaturedModFile.py b/src/api/models/FeaturedModFile.py
new file mode 100644
index 000000000..154f40b31
--- /dev/null
+++ b/src/api/models/FeaturedModFile.py
@@ -0,0 +1,15 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+
+
+class FeaturedModFile(ConfiguredModel):
+ xd: str = Field(alias="id")
+ version: int
+ group: str
+ name: str
+ md5: str
+ url: str
+ cacheable_url: str = Field(alias="cacheableUrl")
+ hmac_token: str = Field(alias="hmacToken")
+ hmac_parameter: str = Field(alias="hmacParameter")
diff --git a/src/api/models/Game.py b/src/api/models/Game.py
new file mode 100644
index 000000000..00dde8dd1
--- /dev/null
+++ b/src/api/models/Game.py
@@ -0,0 +1,18 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+from api.models.Player import Player
+from api.models.PlayerStats import PlayerStats
+
+
+class Game(ConfiguredModel):
+ end_time: str = Field(alias="endTime")
+ xd: str = Field(alias="id")
+ name: str
+ replay_available: bool = Field(alias="replayAvailable")
+ replay_ticks: int | None = Field(alias="replayTicks")
+ replay_url: str = Field(alias="replayUrl")
+ start_time: str = Field(alias="startTime")
+
+ host: Player | None = Field(None)
+ player_stats: list[PlayerStats] | None = Field(None, alias="playerStats")
diff --git a/src/api/models/GeneratedMapParams.py b/src/api/models/GeneratedMapParams.py
new file mode 100644
index 000000000..d77eb15e2
--- /dev/null
+++ b/src/api/models/GeneratedMapParams.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+from api.models.Map import Map
+from api.models.MapType import MapType
+from api.models.MapVersion import MapVersion
+
+
+class GeneratedMapParams(ConfiguredModel):
+ name: str = Field(alias="type")
+ spawns: int
+ size: int
+ gen_version: str = Field(alias="version")
+
+ def to_map(self) -> Map:
+ uid = f"neroxis_map_generator_{self.gen_version}_{self.name}_{self.spawns}_{self.size}"
+ version = MapVersion(
+ xd=uid,
+ create_time="",
+ update_time="",
+ folder_name=uid,
+ games_played=0,
+ description="Randomly Generated Map",
+ max_players=self.spawns,
+ height=self.size,
+ width=self.size,
+ version=self.gen_version,
+ hidden=False,
+ ranked=True,
+ download_url="",
+ thumbnail_url_small="",
+ thumbnail_url_large="",
+ )
+ return Map(
+ xd=uid,
+ create_time="",
+ update_time="",
+ display_name=self.name,
+ author=None,
+ recommended=False,
+ reviews_summary=None,
+ games_played=0,
+ map_type=MapType.SKIRMISH.value,
+ version=version,
+ )
diff --git a/src/api/models/Map.py b/src/api/models/Map.py
new file mode 100644
index 000000000..97c983011
--- /dev/null
+++ b/src/api/models/Map.py
@@ -0,0 +1,36 @@
+from pydantic import Field
+from pydantic import field_validator
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.MapType import MapType
+from api.models.MapVersion import MapVersion
+from api.models.Player import Player
+from api.models.ReviewsSummary import ReviewsSummary
+
+
+class Map(AbstractEntity):
+ display_name: str = Field(alias="displayName")
+ recommended: int
+ author: Player | None = Field(None)
+ reviews_summary: ReviewsSummary | None = Field(None, alias="reviewsSummary")
+ games_played: int = Field(alias="gamesPlayed")
+ map_type: str = Field(alias="mapType")
+ version: MapVersion | None = Field(None)
+
+ @property
+ def maptype(self) -> MapType:
+ return MapType.from_string(self.map_type)
+
+ @field_validator("reviews_summary", mode="before")
+ @classmethod
+ def validate_reviews_summary(cls, value: dict) -> ReviewsSummary | None:
+ if not value:
+ return None
+ return ReviewsSummary(**value)
+
+ @field_validator("author", mode="before")
+ @classmethod
+ def validate_author(cls, value: dict) -> Player | None:
+ if not value:
+ return None
+ return Player(**value)
diff --git a/src/api/models/MapPoolAssignment.py b/src/api/models/MapPoolAssignment.py
new file mode 100644
index 000000000..fa38d754c
--- /dev/null
+++ b/src/api/models/MapPoolAssignment.py
@@ -0,0 +1,28 @@
+from __future__ import annotations
+
+from pydantic import Field
+from pydantic import field_validator
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.GeneratedMapParams import GeneratedMapParams
+from api.models.MapVersion import MapVersion
+
+
+class MapPoolAssignment(AbstractEntity):
+ map_params: GeneratedMapParams | None = Field(None, alias="mapParams")
+ map_version: MapVersion | None = Field(None, alias="mapVersion")
+ weight: int
+
+ @field_validator("map_params", mode="before")
+ @classmethod
+ def validate_map_params(cls, value: dict) -> GeneratedMapParams | None:
+ if not value:
+ return None
+ return GeneratedMapParams(**value)
+
+ @field_validator("map_version", mode="before")
+ @classmethod
+ def validate_map_version(cls, value: dict) -> MapVersion | None:
+ if not value:
+ return None
+ return MapVersion(**value)
diff --git a/src/api/models/MapType.py b/src/api/models/MapType.py
new file mode 100644
index 000000000..2262ea7f4
--- /dev/null
+++ b/src/api/models/MapType.py
@@ -0,0 +1,17 @@
+from __future__ import annotations
+
+from enum import Enum
+
+
+class MapType(Enum):
+ SKIRMISH = "skirmish"
+ COOP = "campaign_coop"
+ OTHER = ""
+
+ @staticmethod
+ def from_string(map_type: str) -> MapType:
+ for mtype in list(MapType):
+ if mtype.value == map_type:
+ return mtype
+ else:
+ return MapType.OTHER
diff --git a/src/api/models/MapVersion.py b/src/api/models/MapVersion.py
new file mode 100644
index 000000000..08521a849
--- /dev/null
+++ b/src/api/models/MapVersion.py
@@ -0,0 +1,49 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from pydantic import Field
+
+from api.models.AbstractEntity import AbstractEntity
+
+
+@dataclass
+class MapSize:
+ height_px: int
+ width_px: int
+
+ @property
+ def width_km(self) -> int:
+ return self.width_px / 51.2
+
+ @property
+ def height_km(self) -> int:
+ return self.height_px / 51.2
+
+ def __lt__(self, other: MapSize) -> bool:
+ return self.height_px * self.width_px < other.height_px * other.width_px
+
+ def __ge__(self, other: MapSize) -> bool:
+ return not self.__lt__(other)
+
+ def __str__(self) -> str:
+ return f"{self.width_km} x {self.height_km} km"
+
+
+class MapVersion(AbstractEntity):
+ folder_name: str = Field(alias="folderName")
+ games_played: int = Field(alias="gamesPlayed")
+ description: str
+ max_players: int = Field(alias="maxPlayers")
+ height: int
+ width: int
+ version: int | str
+ hidden: bool
+ ranked: bool
+ download_url: str = Field(alias="downloadUrl")
+ thumbnail_url_small: str = Field(alias="thumbnailUrlSmall")
+ thumbnail_url_large: str = Field(alias="thumbnailUrlLarge")
+
+ @property
+ def size(self) -> MapSize:
+ return MapSize(self.height, self.width)
diff --git a/src/api/models/Mod.py b/src/api/models/Mod.py
new file mode 100644
index 000000000..c2d113174
--- /dev/null
+++ b/src/api/models/Mod.py
@@ -0,0 +1,30 @@
+from pydantic import Field
+from pydantic import field_validator
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.ModVersion import ModVersion
+from api.models.Player import Player
+from api.models.ReviewsSummary import ReviewsSummary
+
+
+class Mod(AbstractEntity):
+ display_name: str = Field(alias="displayName")
+ recommended: bool
+ author: str
+ reviews_summary: ReviewsSummary | None = Field(None, alias="reviewsSummary")
+ uploader: Player | None = Field(None)
+ version: ModVersion = Field(alias="latestVersion")
+
+ @field_validator("reviews_summary", mode="before")
+ @classmethod
+ def validate_reviews_summary(cls, value: dict) -> ReviewsSummary | None:
+ if not value:
+ return None
+ return ReviewsSummary(**value)
+
+ @field_validator("uploader", mode="before")
+ @classmethod
+ def validate_uploader(cls, value: dict) -> Player | None:
+ if not value:
+ return None
+ return Player(**value)
diff --git a/src/api/models/ModType.py b/src/api/models/ModType.py
new file mode 100644
index 000000000..823e3b478
--- /dev/null
+++ b/src/api/models/ModType.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from enum import Enum
+
+
+class ModType(Enum):
+ UI = "UI"
+ SIM = "SIM"
+ OTHER = ""
+
+ @staticmethod
+ def from_string(string: str) -> ModType:
+ for modtype in list(ModType):
+ if modtype.value == string:
+ return modtype
+ return ModType.OTHER
diff --git a/src/api/models/ModVersion.py b/src/api/models/ModVersion.py
new file mode 100644
index 000000000..cbce8d237
--- /dev/null
+++ b/src/api/models/ModVersion.py
@@ -0,0 +1,20 @@
+from pydantic import Field
+
+from api.models.AbstractEntity import AbstractEntity
+from api.models.ModType import ModType
+
+
+class ModVersion(AbstractEntity):
+ description: str
+ download_url: str = Field(alias="downloadUrl")
+ filename: str
+ hidden: bool
+ ranked: bool
+ thumbnail_url: str = Field(alias="thumbnailUrl")
+ typ: str = Field(alias="type")
+ version: int
+ uid: str
+
+ @property
+ def modtype(self) -> ModType:
+ return ModType.from_string(self.typ)
diff --git a/src/api/models/Player.py b/src/api/models/Player.py
new file mode 100644
index 000000000..18001ed06
--- /dev/null
+++ b/src/api/models/Player.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+from pydantic import Field
+
+from api.models.AbstractEntity import AbstractEntity
+
+
+class Player(AbstractEntity):
+ login: str
+ user_agent: str | None = Field(alias="userAgent")
diff --git a/src/api/models/PlayerStats.py b/src/api/models/PlayerStats.py
new file mode 100644
index 000000000..ea4ec724f
--- /dev/null
+++ b/src/api/models/PlayerStats.py
@@ -0,0 +1,8 @@
+from pydantic import Field
+
+from api.models.ConfiguredModel import ConfiguredModel
+from api.models.Player import Player
+
+
+class PlayerStats(ConfiguredModel):
+ player: Player | None = Field(None)
diff --git a/src/api/models/ReviewsSummary.py b/src/api/models/ReviewsSummary.py
new file mode 100644
index 000000000..dd7268b6b
--- /dev/null
+++ b/src/api/models/ReviewsSummary.py
@@ -0,0 +1,18 @@
+from pydantic import Field
+from pydantic import field_validator
+
+from api.models.ConfiguredModel import ConfiguredModel
+
+
+class ReviewsSummary(ConfiguredModel):
+ positive: float
+ negative: float
+ score: float
+ average_score: float = Field(alias="averageScore")
+ num_reviews: int = Field(alias="reviews")
+ lower_bound: float = Field(alias="lowerBound")
+
+ @field_validator("*", mode="before")
+ @classmethod
+ def avoid_none(cls, value: float | int | None) -> float | int:
+ return value or 0
diff --git a/src/api/models/__init__.py b/src/api/models/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/api/parsers/CoopResultParser.py b/src/api/parsers/CoopResultParser.py
new file mode 100644
index 000000000..c9786db25
--- /dev/null
+++ b/src/api/parsers/CoopResultParser.py
@@ -0,0 +1,11 @@
+from api.models.CoopResult import CoopResult
+
+
+class CoopResultParser:
+ @staticmethod
+ def parse(api_result: dict) -> CoopResult:
+ return CoopResult(**api_result)
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[CoopResult]:
+ return [CoopResultParser.parse(entry) for entry in api_result]
diff --git a/src/api/parsers/CoopScenarioParser.py b/src/api/parsers/CoopScenarioParser.py
new file mode 100644
index 000000000..8f269ce1f
--- /dev/null
+++ b/src/api/parsers/CoopScenarioParser.py
@@ -0,0 +1,11 @@
+from api.models.CoopScenario import CoopScenario
+
+
+class CoopScenarioParser:
+ @staticmethod
+ def parse(api_result: dict) -> CoopScenario:
+ return CoopScenario(**api_result)
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[CoopScenario]:
+ return [CoopScenarioParser.parse(entry) for entry in api_result]
diff --git a/src/api/parsers/FeaturedModFileParser.py b/src/api/parsers/FeaturedModFileParser.py
new file mode 100644
index 000000000..ef55a1395
--- /dev/null
+++ b/src/api/parsers/FeaturedModFileParser.py
@@ -0,0 +1,11 @@
+from api.models.FeaturedModFile import FeaturedModFile
+
+
+class FeaturedModFileParser:
+ @staticmethod
+ def parse(api_result: dict) -> FeaturedModFile:
+ return FeaturedModFile(**api_result)
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[FeaturedModFile]:
+ return [FeaturedModFileParser.parse(file_info) for file_info in api_result]
diff --git a/src/api/parsers/FeaturedModParser.py b/src/api/parsers/FeaturedModParser.py
new file mode 100644
index 000000000..da95bdac8
--- /dev/null
+++ b/src/api/parsers/FeaturedModParser.py
@@ -0,0 +1,12 @@
+from api.models.FeaturedMod import FeaturedMod
+
+
+class FeaturedModParser:
+
+ @staticmethod
+ def parse(data: dict) -> FeaturedMod:
+ return FeaturedMod(**data)
+
+ @staticmethod
+ def parse_many(data: list[dict]) -> list[FeaturedMod]:
+ return [FeaturedModParser.parse(info) for info in data]
diff --git a/src/api/parsers/GeneratedMapParamsParser.py b/src/api/parsers/GeneratedMapParamsParser.py
new file mode 100644
index 000000000..43b50c2b4
--- /dev/null
+++ b/src/api/parsers/GeneratedMapParamsParser.py
@@ -0,0 +1,13 @@
+from api.models.Map import Map
+from src.api.models.GeneratedMapParams import GeneratedMapParams
+
+
+class GeneratedMapParamsParser:
+
+ @staticmethod
+ def parse(params_info: dict) -> GeneratedMapParams:
+ return GeneratedMapParams(**params_info)
+
+ @staticmethod
+ def parse_to_map(params_info: dict) -> Map:
+ return GeneratedMapParamsParser.parse(params_info).to_map()
diff --git a/src/api/parsers/MapParser.py b/src/api/parsers/MapParser.py
new file mode 100644
index 000000000..f8c063b9c
--- /dev/null
+++ b/src/api/parsers/MapParser.py
@@ -0,0 +1,22 @@
+from api.models.Map import Map
+from api.models.MapVersion import MapVersion
+
+
+class MapParser:
+
+ @staticmethod
+ def parse(api_result: dict) -> Map:
+ return Map(**api_result)
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[Map]:
+ return [
+ MapParser.parse_version(info, info["latestVersion"])
+ for info in api_result
+ ]
+
+ @staticmethod
+ def parse_version(map_info: dict, version_info: dict) -> Map:
+ map_model = Map(**map_info)
+ map_model.version = MapVersion(**version_info)
+ return map_model
diff --git a/src/api/parsers/MapPoolAssignmentParser.py b/src/api/parsers/MapPoolAssignmentParser.py
new file mode 100644
index 000000000..8b2448bff
--- /dev/null
+++ b/src/api/parsers/MapPoolAssignmentParser.py
@@ -0,0 +1,29 @@
+from api.models.Map import Map
+from api.models.MapPoolAssignment import MapPoolAssignment
+from api.parsers.MapParser import MapParser
+
+
+class MapPoolAssignmentParser:
+
+ @staticmethod
+ def parse(assignment_info: dict) -> MapPoolAssignment:
+ return MapPoolAssignment(**assignment_info)
+
+ @staticmethod
+ def parse_many(assignment_info: list[dict]) -> list[MapPoolAssignment]:
+ return [MapPoolAssignmentParser.parse(info) for info in assignment_info]
+
+ @staticmethod
+ def parse_to_map(assignment_info: dict) -> Map:
+ pool = MapPoolAssignmentParser.parse(assignment_info)
+ if pool.map_params is not None:
+ return pool.map_params.to_map()
+ if pool.map_version is not None:
+ map_model = MapParser.parse(assignment_info["mapVersion"]["map"])
+ map_model.version = pool.map_version
+ return map_model
+ raise ValueError("MapPoolAssignment info does not contain mapVersion or mapParams")
+
+ @staticmethod
+ def parse_many_to_maps(assignment_info: list[dict]) -> list[Map]:
+ return [MapPoolAssignmentParser.parse_to_map(info) for info in assignment_info]
diff --git a/src/api/parsers/MapVersionParser.py b/src/api/parsers/MapVersionParser.py
new file mode 100644
index 000000000..f603950d6
--- /dev/null
+++ b/src/api/parsers/MapVersionParser.py
@@ -0,0 +1,8 @@
+from api.models.MapVersion import MapVersion
+
+
+class MapVersionParser:
+
+ @staticmethod
+ def parse(version_info: dict) -> MapVersion:
+ return MapVersion(**version_info)
diff --git a/src/api/parsers/ModParser.py b/src/api/parsers/ModParser.py
new file mode 100644
index 000000000..b97711728
--- /dev/null
+++ b/src/api/parsers/ModParser.py
@@ -0,0 +1,12 @@
+from api.models.Mod import Mod
+
+
+class ModParser:
+
+ @staticmethod
+ def parse(mod_info: dict) -> Mod:
+ return Mod(**mod_info)
+
+ @staticmethod
+ def parse_many(api_result: list[dict]) -> list[Mod]:
+ return [ModParser.parse(mod_info) for mod_info in api_result]
diff --git a/src/api/parsers/ModVersionParser.py b/src/api/parsers/ModVersionParser.py
new file mode 100644
index 000000000..d572046bd
--- /dev/null
+++ b/src/api/parsers/ModVersionParser.py
@@ -0,0 +1,8 @@
+from api.models.ModVersion import ModVersion
+
+
+class ModVersionParser:
+
+ @staticmethod
+ def parse(api_result: dict) -> ModVersion:
+ return ModVersion(**api_result)
diff --git a/src/api/parsers/PlayerParser.py b/src/api/parsers/PlayerParser.py
new file mode 100644
index 000000000..cead4158a
--- /dev/null
+++ b/src/api/parsers/PlayerParser.py
@@ -0,0 +1,11 @@
+from api.models.Player import Player
+
+
+class PlayerParser:
+
+ @staticmethod
+ def parse(player_info: dict) -> Player | None:
+ if not player_info:
+ return None
+
+ return Player(**player_info)
diff --git a/src/api/parsers/ReviewsSummaryParser.py b/src/api/parsers/ReviewsSummaryParser.py
new file mode 100644
index 000000000..cabb60c57
--- /dev/null
+++ b/src/api/parsers/ReviewsSummaryParser.py
@@ -0,0 +1,10 @@
+from api.models.ReviewsSummary import ReviewsSummary
+
+
+class ReviewsSummaryParser:
+
+ @staticmethod
+ def parse(reviews_info: dict) -> ReviewsSummary | None:
+ if not reviews_info:
+ return None
+ return ReviewsSummary(**reviews_info)
diff --git a/src/api/parsers/__init__.py b/src/api/parsers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/api/player_api.py b/src/api/player_api.py
new file mode 100644
index 000000000..5253c8854
--- /dev/null
+++ b/src/api/player_api.py
@@ -0,0 +1,28 @@
+import logging
+
+from PyQt6.QtCore import pyqtSignal
+
+from api.ApiAccessors import DataApiAccessor
+
+logger = logging.getLogger(__name__)
+
+
+class PlayerApiConnector(DataApiAccessor):
+ alias_info = pyqtSignal(dict)
+
+ def __init__(self) -> None:
+ super().__init__('/data/player')
+
+ def requestDataForAliasViewer(self, nameToFind: str) -> None:
+ queryDict = {
+ 'include': 'names',
+ 'filter': '(login=="{name}",names.name=="{name}")'.format(
+ name=nameToFind,
+ ),
+ 'fields[player]': 'login,names',
+ 'fields[nameRecord]': 'name,changeTime,player',
+ }
+ self.get_by_query(queryDict, self.handleDataForAliasViewer)
+
+ def handleDataForAliasViewer(self, message: dict) -> None:
+ self.alias_info.emit(message)
diff --git a/src/api/replaysapi.py b/src/api/replaysapi.py
new file mode 100644
index 000000000..a9a8e99b7
--- /dev/null
+++ b/src/api/replaysapi.py
@@ -0,0 +1,10 @@
+import logging
+
+from api.ApiAccessors import DataApiAccessor
+
+logger = logging.getLogger(__name__)
+
+
+class ReplaysApiConnector(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__('/data/game')
diff --git a/src/api/sim_mod_updater.py b/src/api/sim_mod_updater.py
new file mode 100644
index 000000000..9b5bacbd2
--- /dev/null
+++ b/src/api/sim_mod_updater.py
@@ -0,0 +1,20 @@
+import logging
+
+from api.ApiAccessors import DataApiAccessor
+
+logger = logging.getLogger(__name__)
+
+
+class SimModFiles(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__('/data/modVersion')
+ self.mod_url = ""
+
+ def get_url_from_message(self, message: dict) -> str:
+ self.mod_url = message["data"][0]["downloadUrl"]
+
+ def request_and_get_sim_mod_url_by_id(self, uid: str) -> str:
+ query_dict = {"filter": f"uid=={uid}"}
+ self.get_by_query(query_dict, self.get_url_from_message)
+ self.waitForCompletion()
+ return self.mod_url
diff --git a/src/api/stats_api.py b/src/api/stats_api.py
new file mode 100644
index 000000000..af4301ab1
--- /dev/null
+++ b/src/api/stats_api.py
@@ -0,0 +1,20 @@
+import logging
+
+from api.ApiAccessors import DataApiAccessor
+
+logger = logging.getLogger(__name__)
+
+
+class LeaderboardRatingApiConnector(DataApiAccessor):
+ def __init__(self, leaderboard_name: str) -> None:
+ super().__init__('/data/leaderboardRating')
+ self.leaderboard_name = leaderboard_name
+
+ def prepare_data(self, message: dict) -> None:
+ message["leaderboard"] = self.leaderboard_name
+ return message
+
+
+class LeaderboardApiConnector(DataApiAccessor):
+ def __init__(self) -> None:
+ super().__init__('/data/leaderboard')
diff --git a/src/api/vaults_api.py b/src/api/vaults_api.py
new file mode 100644
index 000000000..c1cf6504a
--- /dev/null
+++ b/src/api/vaults_api.py
@@ -0,0 +1,96 @@
+import logging
+from collections.abc import Sequence
+
+from api.ApiAccessors import DataApiAccessor
+from api.parsers.MapParser import MapParser
+from api.parsers.MapPoolAssignmentParser import MapPoolAssignmentParser
+from api.parsers.ModParser import ModParser
+
+logger = logging.getLogger(__name__)
+
+
+class VaultsApiConnector(DataApiAccessor):
+ def __init__(self, route: str) -> None:
+ super().__init__(route)
+ self._includes = ("latestVersion", "reviewsSummary")
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ self._add_default_includes(query_options)
+ self._apply_default_filters(query_options)
+ return query_options
+
+ def _copy_query_options(self, query_options: dict | None) -> dict:
+ query_options = query_options or {}
+ return query_options.copy()
+
+ def request_data(self, query_options: dict | None = None) -> None:
+ query = self._copy_query_options(query_options)
+ self._extend_query_options(query)
+ self.get_by_query(query, self.handle_response)
+
+ def _add_default_includes(self, query_options: dict) -> dict:
+ return self._extend_includes(query_options, self._includes)
+
+ def _extend_includes(self, query_options: dict, to_include: Sequence[str]) -> dict:
+ cur_includes = query_options.get("include", "")
+ to_include_str = ",".join((cur_includes, *to_include)).removeprefix(",")
+ query_options["include"] = to_include_str
+ return query_options
+
+ def _apply_default_filters(self, query_options: dict) -> dict:
+ cur_filters = query_options.get("filter", "")
+ additional_filter = "latestVersion.hidden=='false'"
+ query_options["filter"] = ";".join((cur_filters, additional_filter)).removeprefix(";")
+ return query_options
+
+
+class ModApiConnector(VaultsApiConnector):
+ def __init__(self) -> None:
+ super().__init__("/data/mod")
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ super()._extend_query_options(query_options)
+ self._extend_includes(query_options, ["uploader"])
+ return query_options
+
+ def prepare_data(self, message: dict) -> dict:
+ return {
+ "values": ModParser.parse_many(message["data"]),
+ "meta": message["meta"],
+ }
+
+
+class MapApiConnector(VaultsApiConnector):
+ def __init__(self) -> None:
+ super().__init__("/data/map")
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ super()._extend_query_options(query_options)
+ self._extend_includes(query_options, ["author"])
+
+ def prepare_data(self, message: dict) -> None:
+ return {
+ "values": MapParser.parse_many(message["data"]),
+ "meta": message["meta"],
+ }
+
+
+class MapPoolApiConnector(VaultsApiConnector):
+ def __init__(self) -> None:
+ super().__init__("/data/mapPoolAssignment")
+ self._includes = (
+ "mapVersion",
+ "mapVersion.map",
+ "mapVersion.map.author",
+ "mapVersion.map.reviewsSummary",
+ )
+
+ def _extend_query_options(self, query_options: dict) -> dict:
+ self._add_default_includes(query_options)
+ return query_options
+
+ def prepare_data(self, message: dict) -> None:
+ return {
+ "values": MapPoolAssignmentParser.parse_many_to_maps(message["data"]),
+ "meta": message["meta"],
+ }
diff --git a/src/chat/__init__.py b/src/chat/__init__.py
index 5dcf84b69..745895151 100644
--- a/src/chat/__init__.py
+++ b/src/chat/__init__.py
@@ -1,35 +1,29 @@
-
-
-# Initialize logging system
-import logging
-logger = logging.getLogger(__name__)
-
-IRC_ELEVATION = '%@~%+&'
-
-
-def user2name(user):
- return (user.split('!')[0]).strip(IRC_ELEVATION)
-
-
-def parse_irc_source(src):
- """
- :param src: IRC source argument
- :return: (username, id, elevation, hostname)
- """
- username, tail = src.split('!')
- if username[0] in IRC_ELEVATION:
- elevation, username = username[0], username[1:]
- else:
- elevation = None
- id, hostname = tail.split('@')
- try:
- id = int(id)
- except ValueError:
- id = -1
- return username, id, elevation, hostname
-
-
-from ._chatwidget import ChatWidget
-
-# CAVEAT: DO NOT REMOVE! These are promoted widgets and py2exe wouldn't include them otherwise
+# CAVEAT: DO NOT REMOVE! These are promoted widgets and py2exe wouldn't
+# include them otherwise
from chat.chatlineedit import ChatLineEdit
+from chat.chatterlistview import ChatterListView
+
+__all__ = (
+ "ChatLineEdit",
+ "ChatterListView",
+)
+
+
+class ChatMVC:
+ def __init__(
+ self, model, line_metadata_builder, connection, controller,
+ autojoiner, restorer, greeter, announcer, view,
+ ):
+ self.model = model
+ self.line_metadata_builder = line_metadata_builder
+ self.connection = connection
+ self.controller = controller
+ # Technically part of controller?
+ self.autojoiner = autojoiner
+ # Ditto, also don't confuse with the other Restorer
+ self.restorer = restorer
+ # Ditto
+ self.announcer = announcer
+ # Ditto
+ self.greeter = greeter
+ self.view = view
diff --git a/src/chat/_avatarWidget.py b/src/chat/_avatarWidget.py
index e7060644a..9e9e61dd1 100644
--- a/src/chat/_avatarWidget.py
+++ b/src/chat/_avatarWidget.py
@@ -1,207 +1,105 @@
-from PyQt5 import QtCore, QtWidgets, QtGui
-from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest
-
-import base64, zlib, os
-import util
-
-
-class PlayerAvatar(QtWidgets.QDialog):
- def __init__(self, users=[], idavatar=0, parent=None, *args, **kwargs):
- QtWidgets.QDialog.__init__(self, *args, **kwargs)
-
- self.parent = parent
- self.users = users
- self.checkBox = {}
- self.idavatar = idavatar
-
- self.setStyleSheet(self.parent.styleSheet())
-
- self.grid = QtWidgets.QGridLayout(self)
- self.userlist = None
-
- self.removeButton = QtWidgets.QPushButton("&Remove users")
- self.grid.addWidget(self.removeButton, 1, 0)
-
- self.removeButton.clicked.connect(self.remove_them)
-
- self.setWindowTitle("Users using this avatar")
- self.resize(480, 320)
-
- def process_list(self, users, idavatar):
- self.checkBox = {}
- self.users = users
- self.idavatar = idavatar
- self.userlist = self.create_user_selection()
- self.grid.addWidget(self.userlist, 0, 0)
-
- def remove_them(self):
- for user in self.checkBox :
- if self.checkBox[user].checkState() == 2:
- self.parent.lobby_connection.send(dict(command="admin", action="remove_avatar", iduser=user, idavatar=self.idavatar))
- self.close()
-
- def create_user_selection(self):
- groupBox = QtWidgets.QGroupBox("Select the users you want to remove this avatar :")
- vbox = QtWidgets.QVBoxLayout()
-
- for user in self.users:
- self.checkBox[user["iduser"]] = QtWidgets.QCheckBox(user["login"])
- vbox.addWidget(self.checkBox[user["iduser"]])
-
- vbox.addStretch(1)
- groupBox.setLayout(vbox)
-
- return groupBox
-
-
-class AvatarWidget(QtWidgets.QDialog):
- def __init__(self, parent, user, personal=False, *args, **kwargs):
-
- QtWidgets.QDialog.__init__(self, *args, **kwargs)
-
- self.user = user
- self.personal = personal
- self.parent = parent
-
- self.setStyleSheet(self.parent.styleSheet())
- self.setWindowTitle("Avatar manager")
-
- self.groupLayout = QtWidgets.QVBoxLayout(self)
- self.avatarList = QtWidgets.QListWidget()
-
- self.avatarList.setWrapping(1)
- self.avatarList.setSpacing(5)
- self.avatarList.setResizeMode(1)
-
- self.groupLayout.addWidget(self.avatarList)
-
- if not self.personal:
- self.addAvatarButton = QtWidgets.QPushButton("Add/Edit avatar")
- self.addAvatarButton.clicked.connect(self.add_avatar)
- self.groupLayout.addWidget(self.addAvatarButton)
-
- self.item = []
- self.parent.lobby_info.avatarList.connect(self.avatar_list)
- self.parent.lobby_info.playerAvatarList.connect(self.do_player_avatar_list)
-
- self.playerList = PlayerAvatar(parent=self.parent)
-
- self.nams = {}
- self.avatars = {}
-
- self.finished.connect(self.cleaning)
-
- def showEvent(self, event):
- self.parent.requestAvatars(self.personal)
-
- def add_avatar(self):
-
- options = QtWidgets.QFileDialog.Options()
- options |= QtWidgets.QFileDialog.DontUseNativeDialog
-
- fileName = QtWidgets.QFileDialog.getOpenFileName(self, "Select the PNG file", "", "png Files (*.png)", options)
- if fileName:
- # check the properties of that file
- pixmap = QtGui.QPixmap(fileName)
- if pixmap.height() == 20 and pixmap.width() == 40:
-
- text, ok = QtWidgets.QInputDialog.getText(self, "Avatar description",
- "Please enter the tooltip :", QtWidgets.QLineEdit.Normal, "")
-
- if ok and text != '':
-
- file = QtCore.QFile(fileName)
- file.open(QtCore.QIODevice.ReadOnly)
- fileDatas = base64.b64encode(zlib.compress(file.readAll()))
- file.close()
-
- self.parent.lobby_connection.send(dict(command="avatar", action="upload_avatar",
- name=os.path.basename(fileName), description=text,
- file=fileDatas))
-
- else:
- QtWidgets.QMessageBox.warning(self, "Bad image", "The image must be in png, format is 40x20 !")
-
- def finish_request(self, reply):
-
- if reply.url().toString() in self.avatars:
- img = QtGui.QImage()
- img.loadFromData(reply.readAll())
- pix = QtGui.QPixmap(img)
- self.avatars[reply.url().toString()].setIcon(QtGui.QIcon(pix))
- self.avatars[reply.url().toString()].setIconSize(pix.rect().size())
-
- util.addrespix(reply.url().toString(), QtGui.QPixmap(img))
-
- def clicked(self):
- self.doit(None)
- self.close()
-
- def create_connect(self, x):
- return lambda: self.doit(x)
-
- def doit(self, val):
- if self.personal:
- self.parent.lobby_connection.send(dict(command="avatar", action="select", avatar=val))
- self.close()
-
- else:
- if self.user is None:
- self.parent.lobby_connection.send(dict(command="admin", action="list_avatar_users", avatar=val))
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QSize
+from PyQt6.QtGui import QIcon
+from PyQt6.QtWidgets import QListWidgetItem
+from PyQt6.QtWidgets import QPushButton
+
+from downloadManager import DownloadRequest
+
+
+class AvatarWidget(QObject):
+ def __init__(
+ self, parent_widget, lobby_connection, lobby_info, avatar_dler, theme,
+ ):
+ QObject.__init__(self, parent_widget)
+
+ self._parent_widget = parent_widget
+ self._lobby_connection = lobby_connection
+ self._lobby_info = lobby_info
+ self._avatar_dler = avatar_dler
+
+ self.items = {}
+ self.requests = {}
+ self.buttons = {}
+
+ self.set_theme(theme)
+
+ self._lobby_info.avatarList.connect(self.set_avatar_list)
+ self.base.finished.connect(self.clean)
+
+ @classmethod
+ def builder(
+ cls, parent_widget, lobby_connection, lobby_info, avatar_dler,
+ theme, **kwargs,
+ ):
+ return lambda: cls(
+ parent_widget, lobby_connection, lobby_info, avatar_dler, theme,
+ )
+
+ def set_theme(self, theme):
+ formc, basec = theme.loadUiType("dialogs/avatar.ui")
+ self.form = formc()
+ self.base = basec(self._parent_widget)
+ self.form.setupUi(self.base)
+
+ @property
+ def avatar_list(self):
+ return self.form.avatarList
+
+ def show(self):
+ self._lobby_connection.send({
+ "command": "avatar",
+ "action": "list_avatar",
+ })
+ self.base.show()
+
+ def select_avatar(self, val):
+ self._lobby_connection.send({
+ "command": "avatar",
+ "action": "select",
+ "avatar": val,
+ })
+ self.base.close()
+
+ def set_avatar_list(self, avatars):
+ self.avatar_list.clear()
+
+ self._add_avatar_item(None)
+ for avatar in avatars:
+ self._add_avatar_item(avatar)
+ url = avatar["url"]
+ icon = self._avatar_dler.avatars.get(url, None)
+ if icon is not None:
+ self._set_avatar_icon(url, icon)
else:
- self.parent.lobby_connection.send(dict(command="admin", action="add_avatar", user=self.user, avatar=val))
- self.close()
-
- def do_player_avatar_list(self, message):
- self.playerList = PlayerAvatar(parent=self.parent)
- player_avatar_list = message["player_avatar_list"]
- idavatar = message["avatar_id"]
- self.playerList.process_list(player_avatar_list, idavatar)
- self.playerList.show()
-
- def avatar_list(self, avatar_list):
- self.avatarList.clear()
- button = QtWidgets.QPushButton()
- self.avatars["None"] = button
-
- item = QtWidgets.QListWidgetItem()
- item.setSizeHint(QtCore.QSize(40,20))
-
- self.item.append(item)
-
- self.avatarList.addItem(item)
- self.avatarList.setItemWidget(item, button)
-
- button.clicked.connect(self.clicked)
-
- for avatar in avatar_list:
-
- avatarPix = util.respix(avatar["url"])
- button = QtWidgets.QPushButton()
-
- button.clicked.connect(self.create_connect(avatar["url"]))
-
- item = QtWidgets.QListWidgetItem()
- item.setSizeHint(QtCore.QSize(40, 20))
- self.item.append(item)
+ req = DownloadRequest()
+ req.done.connect(self._handle_avatar_download)
+ self.requests[url] = req
+ self._avatar_dler.download_avatar(url, req)
+
+ def _add_avatar_item(self, avatar):
+ val = None if avatar is None else avatar["url"]
+ button = QPushButton()
+ button.clicked.connect(lambda: self.select_avatar(val))
+ self.buttons[val] = button
+ if avatar is not None:
+ button.setToolTip(avatar["tooltip"])
- self.avatarList.addItem(item)
+ item = QListWidgetItem()
+ item.setSizeHint(QSize(40, 20))
+ self.items[val] = item
- button.setToolTip(avatar["tooltip"])
- url = QtCore.QUrl(avatar["url"])
- self.avatars[avatar["url"]] = button
+ self.avatar_list.addItem(item)
+ self.avatar_list.setItemWidget(item, button)
- self.avatarList.setItemWidget(item, self.avatars[avatar["url"]])
+ def _set_avatar_icon(self, val, icon):
+ button = self.buttons[val]
+ button.setIcon(QIcon(icon))
+ button.setIconSize(icon.rect().size())
- if not avatarPix:
- self.nams[url] = QNetworkAccessManager(button)
- self.nams[url].finished.connect(self.finish_request)
- self.nams[url].get(QNetworkRequest(url))
- else:
- self.avatars[avatar["url"]].setIcon(QtGui.QIcon(avatarPix))
- self.avatars[avatar["url"]].setIconSize(avatarPix.rect().size())
+ def _handle_avatar_download(self, url, icon):
+ del self.requests[url]
+ self._set_avatar_icon(url, icon)
- def cleaning(self):
- if self != self.parent.avatarAdmin:
- self.parent.lobby_info.avatarList.disconnect(self.avatar_list)
- self.parent.lobby_info.playerAvatarList.disconnect(self.do_player_avatar_list)
+ def clean(self):
+ self.setParent(None) # let ourselves get GC'd
diff --git a/src/chat/_chatwidget.py b/src/chat/_chatwidget.py
deleted file mode 100644
index 29625c2c4..000000000
--- a/src/chat/_chatwidget.py
+++ /dev/null
@@ -1,552 +0,0 @@
-import logging
-
-logger = logging.getLogger(__name__)
-
-from PyQt5 import QtWidgets, QtCore, QtGui
-from PyQt5.QtNetwork import QNetworkAccessManager
-from PyQt5.QtCore import QSocketNotifier, QTimer
-
-from config import Settings, defaults
-import util
-
-import re
-import sys
-import chat
-from chat import user2name, parse_irc_source
-from chat.channel import Channel
-from chat.irclib import SimpleIRCClient, ServerConnectionError
-
-from model.ircuserset import IrcUserset
-from model.ircuser import IrcUser
-
-PONG_INTERVAL = 60000 # milliseconds between pongs
-
-FormClass, BaseClass = util.THEME.loadUiType("chat/chat.ui")
-
-
-class ChatWidget(FormClass, BaseClass, SimpleIRCClient):
-
- use_chat = Settings.persisted_property('chat/enabled', type=bool, default_value=True)
- irc_port = Settings.persisted_property('chat/port', type=int, default_value=6667)
- irc_host = Settings.persisted_property('chat/host', type=str, default_value='irc.' + defaults['host'])
- irc_tls = Settings.persisted_property('chat/tls', type=bool, default_value=False)
-
- auto_join_channels = Settings.persisted_property('chat/auto_join_channels', default_value=[])
-
- """
- This is the chat lobby module for the FAF client.
- It manages a list of channels and dispatches IRC events (lobby inherits from irclib's client class)
- """
-
- def __init__(self, client, playerset, me, *args, **kwargs):
- if not self.use_chat:
- logger.info("Disabling chat")
- return
-
- logger.debug("Lobby instantiating.")
- BaseClass.__init__(self, *args, **kwargs)
- SimpleIRCClient.__init__(self)
-
- self.setupUi(self)
-
- self.client = client
- self._me = me
- self._chatters = IrcUserset(playerset)
- self.channels = {}
-
- # avatar downloader
- self.nam = QNetworkAccessManager()
- self.nam.finished.connect(self.finish_download_avatar)
-
- # nickserv stuff
- self.identified = False
-
- # IRC parameters
- self.crucialChannels = ["#aeolus"]
- self.optionalChannels = []
-
- # We can't send command until the welcome message is received
- self.welcomed = False
-
- # Load colors and styles from theme
- self.a_style = util.THEME.readfile("chat/formatters/a_style.qss")
-
- # load UI perform some tweaks
- self.tabBar().setTabButton(0, 1, None)
-
- self.tabCloseRequested.connect(self.close_channel)
-
- # Hook with client's connection and autojoin mechanisms
- self.client.authorized.connect(self.connect)
- self.client.autoJoin.connect(self.auto_join)
- self.channelsAvailable = []
-
- self._notifier = None
- self._timer = QTimer()
- self._timer.timeout.connect(self.once)
-
- # disconnection checks
- self.canDisconnect = False
-
- def disconnect(self):
- self.canDisconnect = True
- try:
- self.irc_disconnect()
- except ServerConnectionError:
- pass
- if self._notifier:
- self._notifier.activated.disconnect(self.once)
- self._notifier = None
-
- @QtCore.pyqtSlot(object)
- def connect(self, me):
- try:
- logger.info("Connecting to IRC at: {}:{}. TLS: {}".format(self.irc_host, self.irc_port, self.irc_tls))
- self.irc_connect(self.irc_host,
- self.irc_port,
- me.login,
- ssl=self.irc_tls,
- ircname=me.login,
- username=me.id)
- self._notifier = QSocketNotifier(self.ircobj.connections[0]._get_socket().fileno(), QSocketNotifier.Read, self)
- self._notifier.activated.connect(self.once)
- self._timer.start(PONG_INTERVAL)
-
- except:
- logger.debug("Unable to connect to IRC server.")
- self.serverLogArea.appendPlainText("Unable to connect to the chat server, but you should still be able to host and join games.")
- logger.error("IRC Exception", exc_info=sys.exc_info())
-
- def finish_download_avatar(self, reply):
- """ this take care of updating the avatars of players once they are downloaded """
- img = QtGui.QImage()
- img.loadFromData(reply.readAll())
- url = reply.url().toString()
- if not util.respix(url):
- util.addrespix(url, QtGui.QPixmap(img))
-
- for chatter in util.curDownloadAvatar(url):
- # FIXME - hack to prevent touching chatter if it was removed
- channel = chatter.channel
- ircuser = chatter.user
- if ircuser in channel.chatters:
- chatter.update_avatar()
- util.delDownloadAvatar(url)
-
- def add_channel(self, name, channel, index = None):
- self.channels[name] = channel
- if index is None:
- self.addTab(self.channels[name], name)
- else:
- self.insertTab(index, self.channels[name], name)
-
- def sort_channels(self):
- for channel in self.channels.values():
- channel.sort_chatters()
-
- def update_channels(self):
- for channel in self.channels.values():
- channel.update_chatters()
-
- def close_channel(self, index):
- """
- Closes a channel tab.
- """
- channel = self.widget(index)
- for name in self.channels:
- if self.channels[name] is channel:
- if not self.channels[name].private and self.connection.is_connected(): # Channels must be parted (if still connected)
- self.connection.part([name], "tab closed")
- else:
- # Queries and disconnected channel windows can just be closed
- self.removeTab(index)
- del self.channels[name]
-
- break
-
- @QtCore.pyqtSlot(str)
- def announce(self, broadcast):
- """
- Notifies all crucial channels about the status of the client.
- """
- logger.debug("BROADCAST:" + broadcast)
- for channel in self.crucialChannels:
- self.send_msg(channel, broadcast)
-
- def set_topic(self, chan, topic):
- self.connection.topic(chan, topic)
-
- def send_msg(self, target, text):
- if self.connection.is_connected():
- self.connection.privmsg(target, text)
- return True
- else:
- logger.error("IRC connection lost.")
- for channel in self.crucialChannels:
- if channel in self.channels:
- self.channels[channel].print_raw("Server", "IRC is disconnected")
- return False
-
- def send_action(self, target, text):
- if self.connection.is_connected():
- self.connection.action(target, text)
- return True
- else:
- logger.error("IRC connection lost.")
- for channel in self.crucialChannels:
- if channel in self.channels:
- self.channels[channel].print_action("IRC", "was disconnected.")
- return False
-
- def open_query(self, chatter, activate=False):
- # Ignore ourselves.
- if chatter.name == self.client.login:
- return False
-
- if chatter.name not in self.channels:
- priv_chan = Channel(self, chatter.name, self._chatters, self._me, True)
- self.add_channel(chatter.name, priv_chan)
-
- # Add participants to private channel
- priv_chan.add_chatter(chatter)
-
- if self.client.me.login is not None:
- my_login = self.client.me.login
- if my_login in self._chatters:
- priv_chan.add_chatter(self._chatters[my_login])
-
- if activate:
- self.setCurrentWidget(priv_chan)
-
- return True
-
- @QtCore.pyqtSlot(list)
- def auto_join(self, channels):
- for channel in channels:
- if channel in self.channels:
- continue
- if (self.connection.is_connected()) and self.welcomed:
- # directly join
- self.connection.join(channel)
- else:
- # Note down channels for later.
- self.optionalChannels.append(channel)
-
- def join(self, channel):
- if channel not in self.channels:
- self.connection.join(channel)
-
- def log_event(self, e):
- self.serverLogArea.appendPlainText("[%s: %s->%s]" % (e.eventtype(), e.source(), e.target()) + "\n".join(e.arguments()))
-
- def should_ignore(self, chatter):
- # Don't ignore mods from any crucial channels
- if any(chatter.is_mod(c) for c in self.crucialChannels):
- return False
- if chatter.player is None:
- return self.client.me.isFoe(name=chatter.name)
- else:
- return self.client.me.isFoe(id_=chatter.player.id)
-
-# SimpleIRCClient Class Dispatcher Attributes follow here.
- def on_welcome(self, c, e):
- self.log_event(e)
- self.welcomed = True
-
- def nickserv_identify(self):
- if not self.identified:
- self.serverLogArea.appendPlainText("[Identify as : %s]" % self.client.login)
- self.connection.privmsg('NickServ', 'identify %s %s' % (self.client.login, util.md5text(self.client.password)))
-
- def on_identified(self):
- if self.connection.get_nickname() != self.client.login :
- self.serverLogArea.appendPlainText("[Retrieving our nickname : %s]" % (self.client.login))
- self.connection.privmsg('NickServ', 'recover %s %s' % (self.client.login, util.md5text(self.client.password)))
- # Perform any pending autojoins (client may have emitted autoJoin signals before we talked to the IRC server)
- self.auto_join(self.optionalChannels)
- self.auto_join(self.crucialChannels)
- self.auto_join(self.auto_join_channels)
- self._schedule_actions_at_player_available()
-
- def _schedule_actions_at_player_available(self):
- self._me.playerAvailable.connect(self._at_player_available)
- if self._me.player is not None:
- self._at_player_available()
-
- def _at_player_available(self):
- self._me.playerAvailable.disconnect(self._at_player_available)
- self._autojoin_newbie_channel()
-
- def _autojoin_newbie_channel(self):
- if not self.client.useNewbiesChannel:
- return
- game_number_threshold = 50
- if self.client.me.player.number_of_games <= game_number_threshold:
- self.auto_join(["#newbie"])
-
- def nickserv_register(self):
- if hasattr(self, '_nickserv_registered'):
- return
- self.connection.privmsg('NickServ', 'register %s %s' % (util.md5text(self.client.password), '{}@users.faforever.com'.format(self.client.me.login)))
- self._nickserv_registered = True
- self.auto_join(self.optionalChannels)
- self.auto_join(self.crucialChannels)
-
- def on_version(self, c, e):
- self.connection.privmsg(e.source(), "Forged Alliance Forever " + util.VERSION_STRING)
-
- def on_motd(self, c, e):
- self.log_event(e)
- self.nickserv_identify()
-
- def on_endofmotd(self, c, e):
- self.log_event(e)
-
- def on_namreply(self, c, e):
- self.log_event(e)
- channel = e.arguments()[1]
- listing = e.arguments()[2].split()
-
- for user in listing:
- name = user.strip(chat.IRC_ELEVATION)
- elevation = user[0] if user[0] in chat.IRC_ELEVATION else None
- hostname = ''
- self._add_chatter(name, hostname)
- self._add_chatter_channel(self._chatters[name], elevation,
- channel, False)
-
- logger.debug("Added " + str(len(listing)) + " Chatters")
-
- def _add_chatter(self, name, hostname):
- if name not in self._chatters:
- self._chatters[name] = IrcUser(name, hostname)
- else:
- self._chatters[name].update(hostname=hostname)
-
- def _remove_chatter(self, name):
- if name not in self._chatters:
- return
- del self._chatters[name]
- # Channels listen to 'chatter removed' signal on their own
-
- def _add_chatter_channel(self, chatter, elevation, channel, join):
- chatter.set_elevation(channel, elevation)
- self.channels[channel].add_chatter(chatter, join)
-
- def _remove_chatter_channel(self, chatter, channel, msg):
- chatter.set_elevation(channel, None)
- self.channels[channel].remove_chatter(msg)
-
- def on_whoisuser(self, c, e):
- self.log_event(e)
-
- def on_join(self, c, e):
- channel = e.target()
-
- # If we're joining, we need to open the channel for us first.
- if channel not in self.channels:
- newch = Channel(self, channel, self._chatters, self._me)
- if channel.lower() in self.crucialChannels:
- self.add_channel(channel, newch, 1) # CAVEAT: This is assumes a server tab exists.
- self.client.localBroadcast.connect(newch.print_raw)
- newch.print_announcement("Welcome to Forged Alliance Forever!", "red", "+3")
- wiki_link = Settings.get("WIKI_URL")
- wiki_msg = "Check out the wiki: {} for help with common issues.".format(wiki_link)
- newch.print_announcement(wiki_msg, "white", "+1")
- newch.print_announcement("", "black", "+1")
- newch.print_announcement("", "black", "+1")
- else:
- self.add_channel(channel, newch)
-
- if channel.lower() in self.crucialChannels: # Make the crucial channels not closeable, and make the last one the active one
- self.setCurrentWidget(self.channels[channel])
- self.tabBar().setTabButton(self.currentIndex(), QtWidgets.QTabBar.RightSide, None)
-
- name, _id, elevation, hostname = parse_irc_source(e.source())
- self._add_chatter(name, hostname)
- self._add_chatter_channel(self._chatters[name], elevation,
- channel, True)
-
- def on_part(self, c, e):
- channel = e.target()
- name = user2name(e.source())
- if name not in self._chatters:
- return
- chatter = self._chatters[name]
-
- if name == self.client.login: # We left ourselves.
- self.removeTab(self.indexOf(self.channels[channel]))
- del self.channels[channel]
- else: # Someone else left
- self._remove_chatter_channel(chatter, channel, "left.")
-
- def on_quit(self, c, e):
- name = user2name(e.source())
- self._remove_chatter(name)
-
- def on_nick(self, c, e):
- oldnick = user2name(e.source())
- newnick = e.target()
- if oldnick not in self._chatters:
- return
-
- self._chatters[oldnick].update(name=newnick)
- self.log_event(e)
-
- def on_mode(self, c, e):
- if e.target() not in self.channels:
- return
- if len(e.arguments()) < 2:
- return
- name = user2name(e.arguments()[1])
- if name not in self._chatters:
- return
- chatter = self._chatters[name]
-
- self.elevate_chatter(chatter, e.target(), e.arguments()[0])
-
- def elevate_chatter(self, chatter, channel, modes):
- add = re.compile(".*\+([a-z]+)")
- remove = re.compile(".*\-([a-z]+)")
-
- addmatch = re.search(add, modes)
- if addmatch:
- modes = addmatch.group(1)
- mode = None
- if "v" in modes:
- mode = "+"
- if "o" in modes:
- mode = "@"
- if "q" in modes:
- mode = "~"
- if mode is not None:
- chatter.set_elevation(channel, mode)
-
- removematch = re.search(remove, modes)
- if removematch:
- modes = removematch.group(1)
- el = chatter.elevation[channel]
- chatter_mode = {"@": "o", "~": "q", "+": "v"}[el]
- if chatter_mode in modes:
- chatter.set_elevation(channel, None)
-
- def on_umode(self, c, e):
- self.log_event(e)
-
- def on_notice(self, c, e):
- self.log_event(e)
-
- def on_topic(self, c, e):
- channel = e.target()
- if channel in self.channels:
- self.channels[channel].set_announce_text(" ".join(e.arguments()))
-
- def on_currenttopic(self, c, e):
- channel = e.arguments()[0]
- if channel in self.channels:
- self.channels[channel].set_announce_text(" ".join(e.arguments()[1:]))
-
- def on_topicinfo(self, c, e):
- self.log_event(e)
-
- def on_list(self, c, e):
- self.log_event(e)
-
- def on_bannedfromchan(self, c, e):
- self.log_event(e)
-
- def on_pubmsg(self, c, e):
- name, id, elevation, hostname = parse_irc_source(e.source())
- target = e.target()
- if name not in self._chatters or target not in self.channels:
- return
-
- if not self.should_ignore(self._chatters[name]):
- self.channels[target].print_msg(name, "\n".join(e.arguments()))
-
- def on_privnotice(self, c, e):
- source = user2name(e.source())
- notice = e.arguments()[0]
- prefix = notice.split(" ")[0]
- target = prefix.strip("[]")
-
- if source and source.lower() == 'nickserv':
- if notice.find("registered under your account") >= 0 or \
- notice.find("Password accepted") >= 0:
- if not self.identified:
- self.identified = True
- self.on_identified()
-
- elif notice.find("isn't registered") >= 0:
- self.nickserv_register()
-
- elif notice.find("RELEASE") >= 0:
- self.connection.privmsg('nickserv', 'release %s %s' % (self.client.login, util.md5text(self.client.password)))
-
- elif notice.find("hold on") >= 0:
- self.connection.nick(self.client.login)
-
- message = "\n".join(e.arguments()).lstrip(prefix)
- if target in self.channels:
- self.channels[target].print_msg(source, message)
- elif source == "Global":
- for channel in self.channels:
- if not channel in self.crucialChannels:
- continue
- self.channels[channel].print_announcement(message, "yellow", "+2")
- elif source == "AeonCommander":
- for channel in self.channels:
- if not channel in self.crucialChannels:
- continue
- self.channels[channel].print_msg(source, message)
- else:
- self.serverLogArea.appendPlainText("%s: %s" % (source, notice))
-
- def on_disconnect(self, c, e):
- if not self.canDisconnect:
- logger.warning("IRC disconnected - reconnecting.")
- self.serverLogArea.appendPlainText("IRC disconnected - reconnecting.")
- self.identified = False
- self._timer.stop()
- self.connect(self.client.me)
-
- def on_privmsg(self, c, e):
- name, id, elevation, hostname = parse_irc_source(e.source())
- if name not in self._chatters:
- return
- chatter = self._chatters[name]
-
- if self.should_ignore(chatter):
- return
-
- # Create a Query if it's not open yet, and post to it if it exists.
- if self.open_query(chatter):
- self.channels[name].print_msg(name, "\n".join(e.arguments()))
-
- def on_action(self, c, e):
- name, id, elevation, hostname = parse_irc_source(e.source())
- if name not in self._chatters:
- return
- chatter = self._chatters[name]
- target = e.target()
-
- if self.should_ignore(chatter):
- return
-
- # Create a Query if it's not an action intended for a channel
- if target not in self.channels:
- self.open_query(chatter)
- self.channels[name].print_action(name, "\n".join(e.arguments()))
- else:
- self.channels[target].print_action(name, "\n".join(e.arguments()))
-
- def on_nosuchnick(self, c, e):
- self.nickserv_register()
-
- def on_default(self, c, e):
- self.serverLogArea.appendPlainText("[%s: %s->%s]" % (e.eventtype(), e.source(), e.target()) + "\n".join(e.arguments()))
- if "Nickname is already in use." in "\n".join(e.arguments()):
- self.connection.nick(self.client.login + "_")
-
- def on_kick(self, c, e):
- pass
diff --git a/src/chat/channel.py b/src/chat/channel.py
deleted file mode 100644
index 1b51d95b3..000000000
--- a/src/chat/channel.py
+++ /dev/null
@@ -1,498 +0,0 @@
-from fa.replay import replay
-
-import util
-from PyQt5 import QtWidgets, QtCore, QtGui
-import time
-from chat import logger
-from chat.chatter import Chatter
-import re
-import json
-
-QUERY_BLINK_SPEED = 250
-CHAT_TEXT_LIMIT = 350
-CHAT_REMOVEBLOCK = 50
-
-FormClass, BaseClass = util.THEME.loadUiType("chat/channel.ui")
-
-
-class IRCPlayer():
- def __init__(self, name):
- self.name = name
- self.id = -1
- self.clan = None
-
-
-class Formatters(object):
- FORMATTER_ANNOUNCEMENT = str(util.THEME.readfile("chat/formatters/announcement.qthtml"))
- FORMATTER_MESSAGE = str(util.THEME.readfile("chat/formatters/message.qthtml"))
- FORMATTER_MESSAGE_AVATAR = str(util.THEME.readfile("chat/formatters/messageAvatar.qthtml"))
- FORMATTER_ACTION = str(util.THEME.readfile("chat/formatters/action.qthtml"))
- FORMATTER_ACTION_AVATAR = str(util.THEME.readfile("chat/formatters/actionAvatar.qthtml"))
- FORMATTER_RAW = str(util.THEME.readfile("chat/formatters/raw.qthtml"))
- NICKLIST_COLUMNS = json.loads(util.THEME.readfile("chat/formatters/nicklist_columns.json"))
-
- @classmethod
- def convert_to_no_avatar(cls, formatter):
- if formatter == cls.FORMATTER_MESSAGE_AVATAR:
- return cls.FORMATTER_MESSAGE
- if formatter == cls.FORMATTER_ACTION_AVATAR:
- return cls.FORMATTER_ACTION
- return formatter
-
-
-# Helper class to schedule single event loop calls.
-class ScheduledCall(QtCore.QObject):
- _call = QtCore.pyqtSignal()
-
- def __init__(self, fn):
- QtCore.QObject.__init__(self)
- self._fn = fn
- self._called = False
- self._call.connect(self._run_call, QtCore.Qt.QueuedConnection)
-
- def schedule_call(self):
- if self._called:
- return
- self._called = True
- self._call.emit()
-
- def _run_call(self):
- self._called = False
- self._fn()
-
-
-class Channel(FormClass, BaseClass):
- """
- This is an actual chat channel object, representing an IRC chat room and the users currently present.
- """
- def __init__(self, chat_widget, name, chatterset, me, private=False):
- BaseClass.__init__(self, chat_widget)
-
- self.setupUi(self)
-
- # Special HTML formatter used to layout the chat lines written by people
- self.chat_widget = chat_widget
- self.chatters = {}
- self.items = {}
- self._chatterset = chatterset
- self._me = me
- chatterset.userRemoved.connect(self._check_user_quit)
-
- self.last_timestamp = None
-
- # Query flasher
- self.blinker = QtCore.QTimer()
- self.blinker.timeout.connect(self.blink)
- self.blinked = False
-
- # Table width of each chatter's name cell...
- self.max_chatter_width = 100 # TODO: This might / should auto-adapt
-
- # count the number of line currently in the chat
- self.lines = 0
-
- # Perform special setup for public channels as opposed to private ones
- self.name = name
- self.private = private
-
- self.sort_call = ScheduledCall(self.sort_chatters)
-
- if not self.private:
- # Properly and snugly snap all the columns
- self.nickList.horizontalHeader().setSectionResizeMode(Chatter.RANK_COLUMN, QtWidgets.QHeaderView.Fixed)
- self.nickList.horizontalHeader().resizeSection(Chatter.RANK_COLUMN, Formatters.NICKLIST_COLUMNS['RANK'])
-
- self.nickList.horizontalHeader().setSectionResizeMode(Chatter.AVATAR_COLUMN, QtWidgets.QHeaderView.Fixed)
- self.nickList.horizontalHeader().resizeSection(Chatter.AVATAR_COLUMN, Formatters.NICKLIST_COLUMNS['AVATAR'])
-
- self.nickList.horizontalHeader().setSectionResizeMode(Chatter.STATUS_COLUMN, QtWidgets.QHeaderView.Fixed)
- self.nickList.horizontalHeader().resizeSection(Chatter.STATUS_COLUMN, Formatters.NICKLIST_COLUMNS['STATUS'])
-
- self.nickList.horizontalHeader().setSectionResizeMode(Chatter.MAP_COLUMN, QtWidgets.QHeaderView.Fixed)
- self.resize_map_column() # The map column can be toggled. Make sure it respects the settings
-
- self.nickList.horizontalHeader().setSectionResizeMode(Chatter.SORT_COLUMN, QtWidgets.QHeaderView.Stretch)
-
- self.nickList.itemDoubleClicked.connect(self.nick_double_clicked)
- self.nickList.itemPressed.connect(self.nick_pressed)
-
- self.nickFilter.textChanged.connect(self.filter_nicks)
-
- else:
- self.nickFrame.hide()
- self.announceLine.hide()
-
- self.chatArea.anchorClicked.connect(self.open_url)
- self.chatEdit.returnPressed.connect(self.send_line)
- self.chatEdit.set_chatters(self.chatters)
-
- def sort_chatters(self):
- self.nickList.sortItems(Chatter.SORT_COLUMN)
-
- def join_channel(self, index):
- """ join another channel """
- channel = self.channelsComboBox.itemText(index)
- if channel.startswith('#'):
- self.chat_widget.auto_join([channel])
-
- def keyReleaseEvent(self, keyevent):
- """
- Allow the ctrl-C event.
- """
- if keyevent.key() == 67:
- self.chatArea.copy()
-
- def resizeEvent(self, size):
- BaseClass.resizeEvent(self, size)
- self.set_text_width()
-
- def set_text_width(self):
- self.chatArea.setLineWrapColumnOrWidth(self.chatArea.size().width() - 20) # Hardcoded, but seems to be enough (tabstop was a bit large)
-
- def showEvent(self, event):
- self.stop_blink()
- self.set_text_width()
- return BaseClass.showEvent(self, event)
-
- @QtCore.pyqtSlot()
- def clearWindow(self):
- if self.isVisible():
- self.chatArea.setPlainText("")
- self.last_timestamp = 0
-
- @QtCore.pyqtSlot()
- def filter_nicks(self):
- for chatter in self.chatters.values():
- chatter.set_visible(chatter.is_filtered(self.nickFilter.text().lower()))
-
- def update_user_count(self):
- count = len(self.chatters)
- self.nickFilter.setPlaceholderText(str(count) + " users... (type to filter)")
-
- if self.nickFilter.text():
- self.filter_nicks()
-
- @QtCore.pyqtSlot()
- def blink(self):
- if self.blinked:
- self.blinked = False
- self.chat_widget.tabBar().setTabText(self.chat_widget.indexOf(self), self.name)
- else:
- self.blinked = True
- self.chat_widget.tabBar().setTabText(self.chat_widget.indexOf(self), "")
-
- @QtCore.pyqtSlot()
- def stop_blink(self):
- self.blinker.stop()
- self.chat_widget.tabBar().setTabText(self.chat_widget.indexOf(self), self.name)
-
- @QtCore.pyqtSlot()
- def start_blink(self):
- self.blinker.start(QUERY_BLINK_SPEED)
-
- @QtCore.pyqtSlot()
- def ping_window(self):
- QtWidgets.QApplication.alert(self.chat_widget.client)
-
- if not self.isVisible() or QtWidgets.QApplication.activeWindow() != self.chat_widget.client:
- if self.one_minute_or_older():
- if self.chat_widget.client.soundeffects:
- util.THEME.sound("chat/sfx/query.wav")
-
- if not self.isVisible():
- if not self.blinker.isActive() and not self == self.chat_widget.currentWidget():
- self.start_blink()
-
- @QtCore.pyqtSlot(QtCore.QUrl)
- def open_url(self, url):
- logger.debug("Clicked on URL: " + url.toString())
- if url.scheme() == "faflive":
- replay(url)
- elif url.scheme() == "fafgame":
- self.chat_widget.client.joinGameFromURL(url)
- else:
- QtGui.QDesktopServices.openUrl(url)
-
- def print_announcement(self, text, color, size, scroll_forced=True):
- # scroll if close to the last line of the log
- scroll_current = self.chatArea.verticalScrollBar().value()
- scroll_needed = scroll_forced or ((self.chatArea.verticalScrollBar().maximum() - scroll_current) < 20)
-
- cursor = self.chatArea.textCursor()
- cursor.movePosition(QtGui.QTextCursor.End)
- self.chatArea.setTextCursor(cursor)
-
- formatter = Formatters.FORMATTER_ANNOUNCEMENT
- line = formatter.format(size=size, color=color, text=util.irc_escape(text, self.chat_widget.a_style))
- self.chatArea.insertHtml(line)
-
- if scroll_needed:
- self.chatArea.verticalScrollBar().setValue(self.chatArea.verticalScrollBar().maximum())
- else:
- self.chatArea.verticalScrollBar().setValue(scroll_current)
-
- def print_line(self, chname, text, scroll_forced=False, formatter=Formatters.FORMATTER_MESSAGE):
- if self.lines > CHAT_TEXT_LIMIT:
- cursor = self.chatArea.textCursor()
- cursor.movePosition(QtGui.QTextCursor.Start)
- cursor.movePosition(QtGui.QTextCursor.Down, QtGui.QTextCursor.KeepAnchor, CHAT_REMOVEBLOCK)
- cursor.removeSelectedText()
- self.lines = self.lines - CHAT_REMOVEBLOCK
-
- chatter = self._chatterset.get(chname)
- if chatter is not None and chatter.player is not None:
- player = chatter.player
- else:
- player = IRCPlayer(chname)
-
- displayName = chname
- if player.clan is not None:
- displayName = "[%s]%s" % (player.clan, chname)
-
- sender_is_not_me = chatter.name != self._me.login
-
- # Play a ping sound and flash the title under certain circumstances
- mentioned = text.find(self.chat_widget.client.login) != -1
- is_quit_msg = formatter is Formatters.FORMATTER_RAW and text == "quit."
- private_msg = self.private and not is_quit_msg
- if (mentioned or private_msg) and sender_is_not_me:
- self.ping_window()
-
- avatar = None
- avatarTip = ""
- if chatter is not None and chatter in self.chatters:
- chatwidget = self.chatters[chatter]
- color = chatwidget.foreground().color().name()
- avatarTip = chatwidget.avatarTip or ""
- if chatter.player is not None:
- avatar = chatter.player.avatar
- if avatar is not None:
- avatar = avatar["url"]
- else:
- # Fallback and ask the client. We have no Idea who this is.
- color = self.chat_widget.client.player_colors.getUserColor(player.id)
-
- if mentioned and sender_is_not_me:
- color = self.chat_widget.client.player_colors.getColor("you")
-
- # scroll if close to the last line of the log
- scroll_current = self.chatArea.verticalScrollBar().value()
- scroll_needed = scroll_forced or ((self.chatArea.verticalScrollBar().maximum() - scroll_current) < 20)
-
- cursor = self.chatArea.textCursor()
- cursor.movePosition(QtGui.QTextCursor.End)
- self.chatArea.setTextCursor(cursor)
-
- chatter_has_avatar = False
- line = None
- if avatar is not None:
- pix = util.respix(avatar)
- if pix:
- self._add_avatar_resource_to_chat_area(avatar, pix)
- chatter_has_avatar = True
-
- if not chatter_has_avatar:
- formatter = Formatters.convert_to_no_avatar(formatter)
-
- line = formatter.format(time=self.timestamp(), avatar=avatar, avatarTip=avatarTip, name=displayName,
- color=color, width=self.max_chatter_width, text=util.irc_escape(text, self.chat_widget.a_style))
- self.chatArea.insertHtml(line)
- self.lines += 1
-
- if scroll_needed:
- self.chatArea.verticalScrollBar().setValue(self.chatArea.verticalScrollBar().maximum())
- else:
- self.chatArea.verticalScrollBar().setValue(scroll_current)
-
- def _add_avatar_resource_to_chat_area(self, avatar, pic):
- doc = self.chatArea.document()
- avatar_link = QtCore.QUrl(avatar)
- image_enum = QtGui.QTextDocument.ImageResource
- if not doc.resource(image_enum, avatar_link):
- doc.addResource(image_enum, avatar_link, pic)
-
- def _chname_has_avatar(self, chname):
- if chname not in self._chatterset:
- return False
- chatter = self._chatterset[chname]
-
- if chatter.player is None:
- return False
- if chatter.player.avatar is None:
- return False
- return True
-
- def print_msg(self, chname, text, scroll_forced=False):
- if self._chname_has_avatar(chname) and not self.private:
- fmt = Formatters.FORMATTER_MESSAGE_AVATAR
- else:
- fmt = Formatters.FORMATTER_MESSAGE
- self.print_line(chname, text, scroll_forced, fmt)
-
- def print_action(self, chname, text, scroll_forced=False, server_action=False):
- if server_action:
- fmt = Formatters.FORMATTER_RAW
- elif self._chname_has_avatar(chname) and not self.private:
- fmt = Formatters.FORMATTER_ACTION_AVATAR
- else:
- fmt = Formatters.FORMATTER_ACTION
- self.print_line(chname, text, scroll_forced, fmt)
-
- def print_raw(self, chname, text, scroll_forced=False):
- """
- Print an raw message in the chatArea of the channel
- """
- chatter = self._chatterset.get(chname)
- try:
- _id = chatter.player.id
- except AttributeError:
- _id = -1
-
- color = self.chat_widget.client.player_colors.getUserColor(_id)
-
- # Play a ping sound
- if self.private and chname != self.chat_widget.client.login:
- self.ping_window()
-
- # scroll if close to the last line of the log
- scroll_current = self.chatArea.verticalScrollBar().value()
- scroll_needed = scroll_forced or ((self.chatArea.verticalScrollBar().maximum() - scroll_current) < 20)
-
- cursor = self.chatArea.textCursor()
- cursor.movePosition(QtGui.QTextCursor.End)
- self.chatArea.setTextCursor(cursor)
-
- formatter = Formatters.FORMATTER_RAW
- line = formatter.format(time=self.timestamp(), name=chname, color=color, width=self.max_chatter_width, text=text)
- self.chatArea.insertHtml(line)
-
- if scroll_needed:
- self.chatArea.verticalScrollBar().setValue(self.chatArea.verticalScrollBar().maximum())
- else:
- self.chatArea.verticalScrollBar().setValue(scroll_current)
-
- def timestamp(self):
- """ returns a fresh timestamp string once every minute, and an empty string otherwise """
- timestamp = time.strftime("%H:%M")
- if self.last_timestamp != timestamp:
- self.last_timestamp = timestamp
- return timestamp
- else:
- return ""
-
- def one_minute_or_older(self):
- timestamp = time.strftime("%H:%M")
- return self.last_timestamp != timestamp
-
- @QtCore.pyqtSlot(QtWidgets.QTableWidgetItem)
- def nick_double_clicked(self, item):
- chatter = self.nickList.item(item.row(), Chatter.SORT_COLUMN) # Look up the associated chatter object
- chatter.double_clicked(item)
-
- @QtCore.pyqtSlot(QtWidgets.QTableWidgetItem)
- def nick_pressed(self, item):
- if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.RightButton:
- # Look up the associated chatter object
- chatter = self.nickList.item(item.row(), Chatter.SORT_COLUMN)
- chatter.pressed(item)
-
- def update_chatters(self):
- """
- Triggers all chatters to update their status. Called when toggling map icon display in settings
- """
- for _, chatter in self.chatters.items():
- chatter.update()
-
- self.resize_map_column()
-
- def resize_map_column(self):
- if util.settings.value("chat/chatmaps", False):
- self.nickList.horizontalHeader().resizeSection(Chatter.MAP_COLUMN, Formatters.NICKLIST_COLUMNS['MAP'])
- else:
- self.nickList.horizontalHeader().resizeSection(Chatter.MAP_COLUMN, 0)
-
- def add_chatter(self, chatter, join=False):
- """
- Adds an user to this chat channel, and assigns an appropriate icon depending on friendship and FAF player status
- """
- if chatter not in self.chatters:
- item = Chatter(self.nickList, chatter, self,
- self.chat_widget, self._me)
- self.chatters[chatter] = item
-
- self.chatters[chatter].update()
-
- self.update_user_count()
-
- if join and self.chat_widget.client.joinsparts:
- self.print_action(chatter.name, "joined the channel.", server_action=True)
-
- def remove_chatter(self, chatter, server_action=None):
- if chatter in self.chatters:
- self.nickList.removeRow(self.chatters[chatter].row())
- del self.chatters[chatter]
-
- if server_action and (self.chat_widget.client.joinsparts or self.private):
- self.print_action(chatter.name, server_action, server_action=True)
- self.stop_blink()
-
- self.update_user_count()
-
- def verify_sort_order(self, chatter):
- row = chatter.row()
- next_chatter = self.nickList.item(row + 1, Chatter.SORT_COLUMN)
- prev_chatter = self.nickList.item(row - 1, Chatter.SORT_COLUMN)
-
- if (next_chatter is not None and chatter > next_chatter or
- prev_chatter is not None and chatter < prev_chatter):
- self.sort_call.schedule_call()
-
- def set_announce_text(self, text):
- self.announceLine.clear()
- self.announceLine.setText("" + util.irc_escape(text) + "")
-
- @QtCore.pyqtSlot()
- def send_line(self, target=None):
- self.stop_blink()
-
- if not target:
- target = self.name # pubmsg in channel
-
- line = self.chatEdit.text()
- # Split into lines if newlines are present
- fragments = line.split("\n")
- for text in fragments:
- # Compound wacky Whitespace
- text = re.sub('\s', ' ', text)
- text = text.strip()
-
- # Reject empty messages
- if not text:
- continue
-
- # System commands
- if text.startswith("/"):
- if text.startswith("/join "):
- self.chat_widget.join(text[6:])
- elif text.startswith("/topic "):
- self.chat_widget.set_topic(self.name, text[7:])
- elif text.startswith("/msg "):
- blobs = text.split(" ")
- self.chat_widget.send_msg(blobs[1], " ".join(blobs[2:]))
- elif text.startswith("/me "):
- if self.chat_widget.send_action(target, text[4:]):
- self.print_action(self.chat_widget.client.login, text[4:], True)
- else:
- self.print_action("IRC", "action not supported", True)
- elif text.startswith("/seen "):
- if self.chat_widget.send_msg("nickserv", "info %s" % (text[6:])):
- self.print_action("IRC", "info requested on %s" % (text[6:]), True)
- else:
- self.print_action("IRC", "not connected", True)
- else:
- if self.chat_widget.send_msg(target, text):
- self.print_msg(self.chat_widget.client.login, text, True)
- self.chatEdit.clear()
-
- def _check_user_quit(self, chatter):
- self.remove_chatter(chatter, 'quit.')
diff --git a/src/chat/channel_autojoiner.py b/src/chat/channel_autojoiner.py
new file mode 100644
index 000000000..307d7dd1b
--- /dev/null
+++ b/src/chat/channel_autojoiner.py
@@ -0,0 +1,133 @@
+from chat.lang import DEFAULT_LANGUAGE_CHANNELS
+from util.lang import COUNTRY_TO_LANGUAGE
+
+
+class ChannelAutojoiner:
+ DEFAULT_LANGUAGE_CHANNELS = {
+ "#french": ["fr"],
+ "#russian": ["ru", "be"], # Be conservative here
+ "#german": ["de"],
+ }
+ # Flip around for easier use
+ DEFAULT_LANGUAGE_CHANNELS = {
+ code: channel
+ for channel, codes in DEFAULT_LANGUAGE_CHANNELS.items()
+ for code in codes
+ }
+
+ def __init__(
+ self, base_channels, model, controller, settings, lobby_info,
+ chat_config, lang_channel_checker, me,
+ ):
+ self.base_channels = base_channels
+ self._model = model
+ self._controller = controller
+ self._settings = settings
+ self._lobby_info = lobby_info
+ self._chat_config = chat_config
+ self._me = me
+ self._me.playerChanged.connect(self._autojoin_player_dependent)
+ self._lang_channel_checker = lang_channel_checker
+
+ self._lobby_info.social.connect(self._autojoin_lobby)
+ self._saved_lobby_channels = []
+
+ self._model.connect_event.connect(self._autojoin_all)
+ if self._model.connected:
+ self._autojoin_all()
+
+ @classmethod
+ def build(
+ cls, base_channels, model, controller, settings, lobby_info,
+ chat_config, me, **kwargs
+ ):
+ lang_channel_checker = LanguageChannelChecker(settings)
+ return cls(
+ base_channels, model, controller, settings, lobby_info,
+ chat_config, lang_channel_checker, me,
+ )
+
+ def _autojoin_all(self):
+ self._autojoin_base()
+ self._autojoin_saved_lobby()
+ self._autojoin_custom()
+ self._autojoin_player_dependent()
+
+ def _autojoin_player_dependent(self):
+ if not self._model.connected:
+ return
+ if self._me.player is None:
+ return
+ self._autojoin_newbie()
+ self._autojoin_lang()
+
+ def _join_all(self, channels):
+ for name in channels:
+ self._controller.join_public_channel(name)
+
+ def _autojoin_base(self):
+ self._join_all(self.base_channels)
+
+ def _autojoin_custom(self):
+ auto_channels = self._settings.get('chat/auto_join_channels', [])
+ # FIXME - sanity check since QSettings is iffy with lists
+ if not isinstance(auto_channels, list):
+ return
+ self._join_all(auto_channels)
+
+ def _autojoin_lobby(self, message):
+ channels = message.get("autojoin", None)
+ if channels is None:
+ return
+ if self._model.connected:
+ self._join_all(channels)
+ else:
+ self._saved_lobby_channels = channels
+
+ def _autojoin_saved_lobby(self):
+ self._join_all(self._saved_lobby_channels)
+ self._saved_lobby_channels = []
+
+ def _autojoin_newbie(self):
+ if not self._chat_config.newbies_channel:
+ return
+ threshold = self._chat_config.newbie_channel_game_threshold
+ if self._me.player.number_of_games > threshold:
+ return
+ self._join_all(["#newbie"])
+
+ def _autojoin_lang(self):
+ player = self._me.player
+ self._join_all(self._lang_channel_checker.get_channels(player))
+
+
+class LanguageChannelChecker:
+ def __init__(self, settings):
+ self._settings = settings
+
+ def get_channels(self, player):
+ if not self._settings.contains('client/lang_channels'):
+ self._set_default_language_channel(player)
+ chan = self._settings.get('client/lang_channels')
+ if chan is None:
+ return []
+ return [c for c in chan.split(';') if c]
+
+ def _set_default_language_channel(self, player):
+ from_os = self._channel_from_os_language()
+ from_ip = self._channel_from_geoip(player)
+ default = from_os or from_ip
+ if default is None:
+ return
+ self._settings.set('client/lang_channels', default)
+
+ def _channel_from_os_language(self):
+ lang = self._settings.get('client/language', None)
+ return DEFAULT_LANGUAGE_CHANNELS.get(lang, None)
+
+ def _channel_from_geoip(self, player):
+ if player is None:
+ return None
+ flag = player.country
+ lang = COUNTRY_TO_LANGUAGE.get(flag, None)
+ return DEFAULT_LANGUAGE_CHANNELS.get(lang, None)
diff --git a/src/chat/channel_tab.py b/src/chat/channel_tab.py
new file mode 100644
index 000000000..1208c4cf0
--- /dev/null
+++ b/src/chat/channel_tab.py
@@ -0,0 +1,94 @@
+from enum import IntEnum
+
+from PyQt6.QtCore import QTimer
+from PyQt6.QtMultimedia import QSoundEffect
+
+from chat.chat_widget import TabIcon
+
+
+class TabInfo(IntEnum):
+ IDLE = 0
+ NEW_MESSAGES = 1
+ IMPORTANT = 2
+
+
+class ChannelTab:
+ def __init__(self, cid, widget, theme, chat_config):
+ self._cid = cid
+ self._widget = widget
+ self._theme = theme
+ self._chat_config = chat_config
+ self._info = TabInfo.IDLE
+
+ self._timer = QTimer()
+ self._timer.setInterval(self._chat_config.channel_blink_interval)
+ self._timer.timeout.connect(self._switch_blink)
+ self._blink_phase = False
+ self._chat_config.updated.connect(self._config_updated)
+
+ self._ping_timer = QTimer()
+ self._ping_timer.setSingleShot(True)
+ self._ping_timer.setInterval(self._chat_config.channel_ping_timeout)
+
+ self._ping_sound = QSoundEffect()
+ self._ping_sound.setSource(self._theme.sound("chat/sfx/query.wav"))
+
+ def _config_updated(self, option):
+ c = self._chat_config
+ if option == "channel_blink_interval":
+ self._timer.setInterval(c.channel_blink_interval)
+ if option == "channel_ping_timeout":
+ self._ping_timer.setInterval(c.channel_ping_timeout)
+
+ @classmethod
+ def builder(cls, theme, chat_config, **kwargs):
+ def make(cid, widget):
+ return cls(cid, widget, theme, chat_config)
+ return make
+
+ @property
+ def info(self):
+ return self._info
+
+ @info.setter
+ def info(self, info):
+ if self._info == info:
+ return
+ if self._info > info and info != TabInfo.IDLE:
+ return
+ self._info = info
+
+ if info == TabInfo.IMPORTANT:
+ self._start_blinking()
+ return
+ self._stop_blinking()
+ if info == TabInfo.NEW_MESSAGES:
+ self._widget.set_tab_icon(self._cid, TabIcon.NEW_MESSAGE)
+ if info == TabInfo.IDLE:
+ self._widget.set_tab_icon(self._cid, TabIcon.IDLE)
+
+ def _start_blinking(self):
+ if not self._timer.isActive():
+ self._switch_blink(False)
+ self._timer.start()
+ self._ping()
+
+ def _ping(self) -> None:
+ self._widget.alert_tab()
+ if not self._chat_config.soundeffects:
+ return
+ if self._ping_timer.isActive():
+ return
+ self._ping_timer.start()
+ self._ping_sound.play()
+
+ def _stop_blinking(self):
+ self._timer.stop()
+ self._ping_timer.stop()
+
+ def _switch_blink(self, val=None):
+ if val is None:
+ val = not self._blink_phase
+ self._blink_phase = val
+ icon = TabIcon.BLINK_ACTIVE if val else TabIcon.BLINK_INACTIVE
+ self._widget.set_tab_icon(self._cid, icon)
diff --git a/src/chat/channel_view.py b/src/chat/channel_view.py
new file mode 100644
index 000000000..366bdeefe
--- /dev/null
+++ b/src/chat/channel_view.py
@@ -0,0 +1,436 @@
+import html
+import time
+
+import jinja2
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QDesktopServices
+
+from chat.channel_tab import TabInfo
+from chat.channel_widget import ChannelWidget
+from chat.chatter_menu import ChatterMenu
+from chat.chatter_model import ChatterEventFilter
+from chat.chatter_model import ChatterFormat
+from chat.chatter_model import ChatterItemDelegate
+from chat.chatter_model import ChatterLayout
+from chat.chatter_model import ChatterLayoutElements
+from chat.chatter_model import ChatterModel
+from chat.chatter_model import ChatterSortFilterModel
+from downloadManager import DownloadRequest
+from model.chat.channel import ChannelType
+from model.chat.chatline import ChatLineType
+from util import irc_escape
+from util.gameurl import GameUrl
+
+
+class ChannelView:
+ def __init__(
+ self, channel, controller, widget, channel_tab,
+ chatter_list_view, lines_view,
+ ):
+ self._channel = channel
+ self._controller = controller
+ self._chatter_list_view = chatter_list_view
+ self._lines_view = lines_view
+ self.widget = widget
+ self._channel_tab = channel_tab
+
+ self.widget.line_typed.connect(self._at_line_typed)
+ if self._channel.id_key.type == ChannelType.PRIVATE:
+ self.widget.show_chatter_list(False)
+
+ self._channel.added_chatter.connect(self._update_chatter_count)
+ self._channel.removed_chatter.connect(self._update_chatter_count)
+ self._update_chatter_count()
+
+ def _update_chatter_count(self):
+ text = "{} users (type to filter)".format(len(self._channel.chatters))
+ self.widget.set_nick_edit_label(text)
+
+ @classmethod
+ def build(cls, channel, controller, channel_tab, **kwargs):
+ chat_css_template = ChatLineCssTemplate.build(**kwargs)
+ widget = ChannelWidget.build(channel, chat_css_template, **kwargs)
+ lines_view = ChatAreaView.build(channel, widget, channel_tab, **kwargs)
+ chatter_list_view = ChattersView.build(
+ channel, widget, controller, **kwargs
+ )
+ return cls(
+ channel, controller, widget, channel_tab, chatter_list_view,
+ lines_view,
+ )
+
+ @classmethod
+ def builder(cls, controller, **kwargs):
+ def make(channel, channel_tab):
+ return cls.build(channel, controller, channel_tab, **kwargs)
+ return make
+
+ def _at_line_typed(self, line):
+ self._controller.send_message(self._channel.id_key, line)
+
+ def on_shown(self):
+ self._channel_tab.info = TabInfo.IDLE
+
+
+class ChatAreaView:
+ def __init__(
+ self, channel, widget, widget_tab, game_runner, avatar_adder,
+ formatter,
+ ):
+ self._channel = channel
+ self._widget = widget
+ self._widget_tab = widget_tab
+ self._game_runner = game_runner
+ self._channel.lines.added.connect(self._add_line)
+ self._channel.lines.removed.connect(self._remove_lines)
+ self._channel.updated.connect(self._at_channel_updated)
+ self._widget.url_clicked.connect(self._at_url_clicked)
+ self._widget.css_reloaded.connect(self._at_css_reloaded)
+ self._avatar_adder = avatar_adder
+ self._formatter = formatter
+
+ self._set_topic(self._channel.topic)
+
+ @classmethod
+ def build(cls, channel, widget, widget_tab, game_runner, **kwargs):
+ avatar_adder = ChatAvatarPixAdder.build(widget, **kwargs)
+ formatter = ChatLineFormatter.build(**kwargs)
+ return cls(
+ channel, widget, widget_tab, game_runner, avatar_adder, formatter,
+ )
+
+ def _add_line(self):
+ data = self._channel.lines[-1]
+ if data.meta.player.avatar.url:
+ self._avatar_adder.add_avatar(data.meta.player.avatar.url())
+ text = self._formatter.format(data)
+ self._widget.append_line(text)
+ self._set_tab_info(data)
+
+ def _remove_lines(self, number):
+ self._widget.remove_lines(number)
+
+ def _at_channel_updated(self, new, old):
+ if new.topic != old.topic:
+ self._set_topic(new.topic)
+
+ def _set_topic(self, topic):
+ self._widget.set_topic(self._format_topic(topic))
+
+ def _format_topic(self, topic):
+ # FIXME - use CSS for this
+ fmt = (
+ ""
+ "{}"
+ )
+ return fmt.format(irc_escape(topic))
+
+ def _at_url_clicked(self, url):
+ if not GameUrl.is_game_url(url):
+ QDesktopServices.openUrl(url)
+ return
+ try:
+ gurl = GameUrl.from_url(url)
+ except ValueError:
+ return
+ self._game_runner.run_game_from_url(gurl)
+
+ def _set_tab_info(self, data):
+ self._widget_tab.info = self._tab_info(data)
+
+ def _tab_info(self, data):
+ if not self._widget.hidden:
+ return TabInfo.IDLE
+ if self._line_is_important(data):
+ return TabInfo.IMPORTANT
+ return TabInfo.NEW_MESSAGES
+
+ def _line_is_important(self, data):
+ if data.line.type in [
+ ChatLineType.INFO, ChatLineType.ANNOUNCEMENT, ChatLineType.RAW,
+ ]:
+ return False
+ if self._channel.id_key.type == ChannelType.PRIVATE:
+ return True
+ if data.meta.mentions_me and data.meta.mentions_me():
+ return True
+ return False
+
+ def _at_css_reloaded(self):
+ self._widget.clear_chat()
+ for line in self._channel.lines:
+ text = self._formatter.format(line)
+ self._widget.append_line(text)
+
+
+class ChatAvatarPixAdder:
+ def __init__(self, widget, avatar_dler):
+ self._avatar_dler = avatar_dler
+ self._widget = widget
+ self._requests = {}
+
+ @classmethod
+ def build(cls, widget, avatar_dler, **kwargs):
+ return cls(widget, avatar_dler)
+
+ def add_avatar(self, url):
+ avatar_pix = self._avatar_dler.avatars.get(url, None)
+ if avatar_pix is not None:
+ self._add_avatar_resource(url, avatar_pix)
+ elif url not in self._requests:
+ req = DownloadRequest()
+ req.done.connect(self._add_avatar_resource)
+ self._requests[url] = req
+ self._avatar_dler.download_avatar(url, req)
+
+ def _add_avatar_resource(self, url, pix):
+ if url in self._requests:
+ del self._requests[url]
+ self._widget.add_avatar_resource(url, pix)
+
+
+class ChatLineCssTemplate(QObject):
+ changed = pyqtSignal()
+
+ def __init__(self, theme, player_colors):
+ QObject.__init__(self)
+ self._player_colors = player_colors
+ self._theme = theme
+ self._player_colors.changed.connect(self._reload_css)
+ self._theme.stylesheets_reloaded.connect(self._load_template)
+ self._load_template()
+
+ @classmethod
+ def build(cls, theme, player_colors, **kwargs):
+ return cls(theme, player_colors)
+
+ def _load_template(self):
+ self._env = jinja2.Environment()
+ template_str = self._theme.readfile("chat/channel.css")
+ self._template = self._env.from_string(template_str)
+ self._reload_css()
+
+ def _reload_css(self):
+ colors = self._player_colors.colors
+ if self._player_colors.colored_nicknames:
+ random_colors = self._player_colors.random_colors
+ else:
+ random_colors = None
+ self.css = self._template.render(
+ colors=colors,
+ random_colors=random_colors,
+ )
+ self.changed.emit()
+
+
+class ChatLineFormatter:
+ def __init__(self, theme, player_colors):
+ self._set_theme(theme)
+ self._player_colors = player_colors
+ self._last_timestamp = None
+
+ @classmethod
+ def build(cls, theme, player_colors, **kwargs):
+ return cls(theme, player_colors)
+
+ def _set_theme(self, theme):
+ self._chatline_template = theme.readfile("chat/chatline.qhtml")
+ self._avatar_template = theme.readfile("chat/chatline_avatar.qhtml")
+
+ def _line_tags(self, data):
+ line = data.line
+ meta = data.meta
+ if line.type == ChatLineType.NOTICE:
+ yield "notice"
+ if line.type == ChatLineType.ACTION:
+ yield "action"
+ if line.type == ChatLineType.INFO:
+ yield "info"
+ if line.type == ChatLineType.ANNOUNCEMENT:
+ yield "announcement"
+ return # Let announcements decorate themselves
+ if line.type == ChatLineType.RAW:
+ yield "raw"
+ return # Ditto
+ if meta.chatter:
+ yield "chatter"
+ if meta.chatter.is_mod and meta.chatter.is_mod():
+ yield "mod"
+ name = meta.chatter.name()
+ id_ = meta.player.id() if meta.player.id else None
+ yield (
+ "randomcolor-{}".format(
+ self._player_colors.get_random_color_index(id_, name),
+ )
+ )
+ if meta.player:
+ yield "player"
+ if meta.is_friend and meta.is_friend():
+ yield "friend"
+ if meta.is_foe and meta.is_foe():
+ yield "foe"
+ if meta.is_me and meta.is_me():
+ yield "me"
+ if meta.is_clannie and meta.is_clannie():
+ yield "clannie"
+ if meta.mentions_me and meta.mentions_me():
+ yield "mentions_me"
+ if meta.player.avatar and meta.player.avatar():
+ yield "avatar"
+
+ def format(self, data):
+ tags = " ".join(self._line_tags(data))
+ avatar = self._avatar(data)
+
+ if self._check_timestamp(data.line.time):
+ stamp = time.strftime('%H:%M', time.localtime(data.line.time))
+ else:
+ stamp = ""
+
+ text = data.line.text
+ if data.line.type not in [ChatLineType.ANNOUNCEMENT, ChatLineType.RAW]:
+ text = irc_escape(text)
+ if data.line.type == ChatLineType.RAW:
+ return text
+
+ return self._chatline_template.format(
+ time=stamp,
+ sender=self._sender_name(data),
+ text=text,
+ avatar=avatar,
+ tags=tags,
+ )
+
+ def _avatar(self, data):
+ if data.line.type in [
+ ChatLineType.INFO, ChatLineType.ANNOUNCEMENT, ChatLineType.RAW,
+ ]:
+ return ""
+ if not data.meta.player.avatar.url:
+ return ""
+ ava_meta = data.meta.player.avatar
+ avatar_url = ava_meta.url()
+ avatar_tip = ava_meta.tip() if ava_meta.tip else ""
+ return self._avatar_template.format(url=avatar_url, tip=avatar_tip)
+
+ def _sender_name(self, data):
+ if data.line.sender is None:
+ return ""
+ mtype = data.line.type
+ sender = ChatterFormat.name(data.line.sender, data.meta.player.clan())
+ sender = html.escape(sender)
+ if mtype in [ChatLineType.MESSAGE, ChatLineType.NOTICE]:
+ sender += ": "
+ return sender
+
+ def _check_timestamp(self, stamp):
+ local = time.localtime(stamp)
+ new_stamp = (
+ self._last_timestamp is None
+ or local.tm_hour != self._last_timestamp.tm_hour
+ or local.tm_min != self._last_timestamp.tm_min
+ )
+ if new_stamp:
+ self._last_timestamp = local
+ return new_stamp
+
+
+class ChattersViewParameters(QObject):
+ updated = pyqtSignal()
+
+ def __init__(self, me, player_colors):
+ QObject.__init__(self)
+ self._me = me
+ self._me.playerChanged.connect(self._updated)
+ self._me.clan_changed.connect(self._updated)
+ self._player_colors = player_colors
+ self._player_colors.changed.connect(self._updated)
+
+ def _updated(self):
+ self.updated.emit()
+
+ @classmethod
+ def build(cls, me, player_colors, **kwargs):
+ return cls(me, player_colors)
+
+
+class ChattersView:
+ def __init__(
+ self, widget, chatter_layout, delegate, model, controller,
+ event_filter, double_click_handler, view_parameters,
+ ):
+ self.chatter_layout = chatter_layout
+ self.delegate = delegate
+ self.model = model
+ self._controller = controller
+ self.event_filter = event_filter
+ self._double_click_handler = double_click_handler
+ self._view_parameters = view_parameters
+ self.widget = widget
+
+ widget.set_chatter_delegate(self.delegate)
+ widget.set_chatter_model(self.model)
+ widget.set_chatter_event_filter(self.event_filter)
+ widget.chatter_list_resized.connect(self._at_chatter_list_resized)
+ view_parameters.updated.connect(self._at_view_parameters_updated)
+ self.event_filter.double_clicked.connect(
+ self._double_click_handler.handle,
+ )
+
+ def _at_chatter_list_resized(self, size):
+ self.delegate.update_width(size)
+
+ def _at_view_parameters_updated(self):
+ self.model.invalidate_items()
+
+ @classmethod
+ def build(cls, channel, widget, controller, user_relations, **kwargs):
+ model = ChatterModel.build(
+ channel, relation_trackers=user_relations.trackers, **kwargs
+ )
+ sort_filter_model = ChatterSortFilterModel.build(
+ model, user_relations=user_relations.model, **kwargs
+ )
+
+ chatter_layout = ChatterLayout.build(**kwargs)
+ chatter_menu = ChatterMenu.build(**kwargs)
+ delegate = ChatterItemDelegate.build(chatter_layout, **kwargs)
+ event_filter = ChatterEventFilter.build(
+ chatter_layout, delegate, chatter_menu, **kwargs
+ )
+ double_click_handler = ChatterDoubleClickHandler.build(
+ controller, **kwargs
+ )
+ view_parameters = ChattersViewParameters.build(**kwargs)
+
+ return cls(
+ widget, chatter_layout, delegate, sort_filter_model,
+ controller, event_filter, double_click_handler, view_parameters,
+ )
+
+
+class ChatterDoubleClickHandler:
+ def __init__(self, controller, game_runner):
+ self._controller = controller
+ self._game_runner = game_runner
+
+ @classmethod
+ def build(cls, controller, game_runner, **kwargs):
+ return cls(controller, game_runner)
+
+ def handle(self, data, elem):
+ if elem == ChatterLayoutElements.STATUS:
+ self._game_action(data)
+ else:
+ self._privmsg(data)
+
+ def _privmsg(self, data):
+ self._controller.join_private_channel(data.chatter.name)
+
+ def _game_action(self, data):
+ game = data.game
+ player = data.player
+ if game is None or player is None:
+ return
+ self._game_runner.run_game_with_url(game, player.id)
diff --git a/src/chat/channel_widget.py b/src/chat/channel_widget.py
new file mode 100644
index 000000000..a4a0af1fc
--- /dev/null
+++ b/src/chat/channel_widget.py
@@ -0,0 +1,193 @@
+import logging
+import re
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QTextCursor
+from PyQt6.QtGui import QTextDocument
+
+from util.qt import monkeypatch_method
+
+logger = logging.getLogger(__name__)
+
+
+class ChannelWidget(QObject):
+ line_typed = pyqtSignal(str)
+ chatter_list_resized = pyqtSignal(object)
+ url_clicked = pyqtSignal(QUrl)
+ css_reloaded = pyqtSignal()
+
+ def __init__(self, channel, chat_area_css, theme, chat_config):
+ QObject.__init__(self)
+ self.channel = channel
+ self._chat_area_css = chat_area_css
+ self._chat_area_css.changed.connect(self._reload_css)
+ self._chat_config = chat_config
+ self.set_theme(theme)
+
+ @classmethod
+ def build(cls, channel, chat_area_css, theme, chat_config, **kwargs):
+ return cls(channel, chat_area_css, theme, chat_config)
+
+ @property
+ def chat_area(self):
+ return self.form.chatArea
+
+ @property
+ def chat_edit(self):
+ return self.form.chatEdit
+
+ @property
+ def nick_frame(self):
+ return self.form.nickFrame
+
+ @property
+ def nick_list(self):
+ return self.form.nickList
+
+ @property
+ def nick_filter(self):
+ return self.form.nickFilter
+
+ @property
+ def announce_line(self):
+ return self.form.announceLine
+
+ def set_theme(self, theme):
+ formc, basec = theme.loadUiType("chat/channel.ui")
+ self.form = formc()
+ self.base = basec()
+ self.form.setupUi(self.base)
+
+ # Used by chat widget so it knows it corresponds to this widget
+ self.base.cid = self.channel.id_key
+ self.chat_edit.returnPressed.connect(self._at_line_typed)
+ self.nick_list.resized.connect(self._chatter_list_resized)
+ self.chat_edit.set_channel(self.channel)
+ self.nick_filter.textChanged.connect(self._set_chatter_filter)
+ self.chat_area.anchorClicked.connect(self._url_clicked)
+ self._override_widget_methods()
+ self._load_css()
+ self._sticky_scroll = ChatAreaStickyScroll(
+ self.chat_area.verticalScrollBar(),
+ )
+
+ def _override_widget_methods(self):
+
+ def on_key_release(obj, old_fn, keyevent):
+ if keyevent.key() == 67: # Ctrl-C
+ self.chat_area.copy()
+ else:
+ old_fn(keyevent)
+ monkeypatch_method(self.base, "keyReleaseEvent", on_key_release)
+
+ def _chatter_list_resized(self, size):
+ self.chatter_list_resized.emit(size)
+
+ def _url_clicked(self, url):
+ self.url_clicked.emit(url)
+
+ # This might be fairly expensive, as we reapply all chat lines to the area.
+ # Make sure it's not called really often!
+ def _reload_css(self):
+ logger.info("Reloading chat CSS...")
+ self._load_css()
+ self.css_reloaded.emit() # Qt does not reapply css on its own
+
+ def _load_css(self):
+ self.chat_area.document().setDefaultStyleSheet(self._chat_area_css.css)
+
+ def clear_chat(self):
+ self.chat_area.document().setHtml("")
+
+ def add_avatar_resource(self, url, pix):
+ doc = self.chat_area.document()
+ link = QUrl(url)
+ if not doc.resource(QTextDocument.ResourceType.ImageResource, link):
+ doc.addResource(QTextDocument.ResourceType.ImageResource, link, pix)
+
+ def _set_chatter_filter(self, text):
+ self.nick_list.model().setFilterFixedString(text)
+
+ def _at_line_typed(self):
+ text = self.chat_edit.text()
+ self.chat_edit.clear()
+ fragments = text.split("\n")
+ for line in fragments:
+ # Compound wacky Whitespace
+ line = re.sub(r'\s', ' ', line).strip()
+ if not line:
+ continue
+ self.line_typed.emit(line)
+
+ def show_chatter_list(self, should_show):
+ self.nick_frame.setVisible(should_show)
+
+ def append_line(self, text):
+ # QTextEdit has its own ideas about scrolling and does not stay
+ # in place when adding content
+ self._sticky_scroll.save_scroll()
+
+ cursor = self.chat_area.textCursor()
+ cursor.movePosition(QTextCursor.MoveOperation.End)
+ self.chat_area.setTextCursor(cursor)
+ self.chat_area.insertHtml(text)
+
+ self._sticky_scroll.restore_scroll()
+
+ def remove_lines(self, number):
+ cursor = self.chat_area.textCursor()
+ cursor.movePosition(QTextCursor.MoveOperation.Start)
+ cursor.movePosition(QTextCursor.MoveOperation.Down, QTextCursor.MoveMode.KeepAnchor, number)
+ cursor.removeSelectedText()
+
+ def set_chatter_delegate(self, delegate):
+ self.nick_list.setItemDelegate(delegate)
+
+ def set_chatter_model(self, model):
+ self.nick_list.setModel(model)
+ model.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
+
+ def set_chatter_event_filter(self, event_filter):
+ self.nick_list.viewport().installEventFilter(event_filter)
+
+ def set_nick_edit_label(self, text):
+ self.nick_filter.setPlaceholderText(text)
+
+ @property
+ def hidden(self):
+ return not self.base.isVisible()
+
+ def set_topic(self, topic):
+ self.announce_line.setText(topic)
+
+
+class ChatAreaStickyScroll:
+ def __init__(self, scrollbar):
+ self._scrollbar = scrollbar
+ self._scrollbar.valueChanged.connect(self._track_maximum)
+ self._scrollbar.rangeChanged.connect(self._stick_at_range_changed)
+ self._is_set_to_maximum = True
+ self._old_value = self._scrollbar.value()
+ self._saved_scroll = 0
+
+ def save_scroll(self):
+ self._saved_scroll = self._scrollbar.value()
+
+ def restore_scroll(self):
+ if self._is_set_to_maximum:
+ self._scrollbar.setValue(self._scrollbar.maximum())
+ else:
+ self._scrollbar.setValue(self._saved_scroll)
+
+ def _track_maximum(self, val):
+ self._is_set_to_maximum = val == self._scrollbar.maximum()
+ self._old_value = val
+
+ def _stick_at_range_changed(self, min_, max_):
+ if self._is_set_to_maximum:
+ self._scrollbar.setValue(max_)
+ else:
+ self._scrollbar.setValue(self._old_value)
diff --git a/src/chat/chat_announcer.py b/src/chat/chat_announcer.py
new file mode 100644
index 000000000..e9ad8e6c0
--- /dev/null
+++ b/src/chat/chat_announcer.py
@@ -0,0 +1,36 @@
+from model.chat.channel import ChannelID, ChannelType
+from model.chat.chatline import ChatLine, ChatLineType
+
+
+class ChatAnnouncer:
+ def __init__(
+ self, model, chat_config, game_announcer, line_metadata_builder,
+ ):
+ self._model = model
+ self._chat_config = chat_config
+ self._game_announcer = game_announcer
+ self._line_metadata_builder = line_metadata_builder
+ self._game_announcer.announce.connect(self._announce)
+ self._model.disconnect_event.connect(self._at_chat_disconnected)
+
+ @property
+ def _announcement_channels(self):
+ return self._chat_config.announcement_channels
+
+ def _announce(self, msg, sender=None):
+ line = ChatLine(sender, msg, ChatLineType.ANNOUNCEMENT)
+ for name in self._announcement_channels:
+ cid = ChannelID(ChannelType.PUBLIC, name)
+ channel = self._model.channels.get(cid, None)
+ if channel is None:
+ continue
+ data = self._line_metadata_builder.get_meta(channel, line)
+ channel.lines.add_line(data)
+
+ def _at_chat_disconnected(self):
+ self._announce(
+ (
+ "Disconnected from chat! Right-click on the FAF icon "
+ "in the top-left to reconnect."
+ ),
+ )
diff --git a/src/chat/chat_controller.py b/src/chat/chat_controller.py
new file mode 100644
index 000000000..0b97ec36f
--- /dev/null
+++ b/src/chat/chat_controller.py
@@ -0,0 +1,375 @@
+from enum import Enum
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+from model.chat.channel import Channel
+from model.chat.channel import ChannelID
+from model.chat.channel import ChannelType
+from model.chat.channel import Lines
+from model.chat.channelchatter import ChannelChatter
+from model.chat.chatline import ChatLine
+from model.chat.chatline import ChatLineType
+from model.chat.chatter import Chatter
+
+
+class ChatController(QObject):
+ join_requested = pyqtSignal(object)
+
+ def __init__(
+ self, connection, model, user_relations, chat_config,
+ line_metadata_builder,
+ ):
+ QObject.__init__(self)
+ self._connection = connection
+ self._model = model
+ self._user_relations = user_relations
+ self._chat_config = chat_config
+ self._chat_config.updated.connect(self._at_config_updated)
+ self._line_metadata_builder = line_metadata_builder
+
+ c = connection
+ c.new_line.connect(self._at_new_line)
+ c.new_channel_chatters.connect(self._at_new_channel_chatters)
+ c.channel_chatter_left.connect(self._at_channel_chatter_left)
+ c.channel_chatter_joined.connect(self._at_channel_chatter_joined)
+ c.chatter_quit.connect(self._at_chatter_quit)
+ c.quit_channel.connect(self._at_quit_channel)
+ c.chatter_renamed.connect(self._at_chatter_renamed)
+ c.new_chatter_elevation.connect(self._at_new_chatter_elevation)
+ c.new_channel_topic.connect(self._at_new_channel_topic)
+ c.connected.connect(self._at_connected)
+ c.disconnected.connect(self._at_disconnected)
+ c.new_server_message.connect(self._at_new_server_message)
+
+ @classmethod
+ def build(
+ cls, connection, model, user_relations, chat_config,
+ line_metadata_builder, **kwargs
+ ):
+ return cls(
+ connection, model, user_relations, chat_config,
+ line_metadata_builder,
+ )
+
+ @property
+ def _channels(self):
+ return self._model.channels
+
+ @property
+ def _chatters(self):
+ return self._model.chatters
+
+ @property
+ def _ccs(self):
+ return self._model.channelchatters
+
+ def _check_add_new_channel(self, cid):
+ if cid not in self._channels:
+ channel = Channel(cid, Lines(), "")
+ self._channels[cid] = channel
+ if cid.type == ChannelType.PRIVATE:
+ self._add_me_to_channel(channel)
+ self._join_chatter_to_his_privchannel(cid.name)
+ return self._channels[cid]
+
+ def _add_me_to_channel(self, channel):
+ my_name = self._connection.nickname
+ me = None if my_name is None else self._chatters.get(my_name, None)
+ if me is not None:
+ cc = ChannelChatter(channel, me, "")
+ self._ccs[(channel.id_key, me.id_key)] = cc
+
+ def _join_chatter_to_his_privchannel(self, name):
+ channel = self._channels.get(ChannelID.private_cid(name), None)
+ if channel is None:
+ return
+ chatter = self._chatters.get(name, None)
+ if chatter is None:
+ return
+ key = (channel.id_key, chatter.id_key)
+ if key not in self._ccs:
+ self._ccs[key] = ChannelChatter(channel, chatter, "")
+
+ def _check_add_new_chatter(self, cinfo):
+ if cinfo.name not in self._chatters:
+ chatter = Chatter(cinfo.name, cinfo.hostname)
+ self._chatters[chatter.name] = chatter
+ self._join_chatter_to_his_privchannel(chatter.name)
+ return self._chatters[cinfo.name]
+
+ def _add_or_update_cc(self, cid, cinfo):
+ channel = self._check_add_new_channel(cid)
+ chatter = self._check_add_new_chatter(cinfo)
+ key = (channel.id_key, chatter.id_key)
+ if key not in self._ccs:
+ cc = ChannelChatter(channel, chatter, cinfo.elevation)
+ self._ccs[key] = cc
+ else:
+ self._ccs[key].update(elevation=cinfo.elevation)
+
+ def _remove_cc(self, cid, cinfo):
+ key = (cid, cinfo.name)
+ self._ccs.pop(key, None)
+
+ def _add_line(self, channel, line):
+ data = self._line_metadata_builder.get_meta(channel, line)
+ channel.lines.add_line(data)
+ self._trim_channel_lines(channel)
+
+ def _at_new_line(self, cid, cinfo, line):
+ if cid.type == ChannelType.PUBLIC and cid not in self._channels:
+ return
+
+ if self._should_ignore_chatter(cid, line.sender):
+ return
+
+ if cid.type == ChannelType.PRIVATE:
+ self._check_add_new_channel(cid)
+ # If a chatter messages us without having joined any channel, this
+ # is where we first hear of him
+ if cinfo is not None:
+ if cinfo.name not in self._chatters:
+ self._check_add_new_chatter(cinfo)
+ self._add_or_update_cc(cid, cinfo)
+
+ self._add_line(self._channels[cid], line)
+
+ def _at_new_channel_chatters(self, cid, chatters):
+ for c in chatters:
+ self._add_or_update_cc(cid, c)
+
+ def _at_channel_chatter_joined(self, cid, chatter):
+ self._at_new_channel_chatters(cid, [chatter])
+ self._announce_join(cid, chatter)
+
+ def _at_channel_chatter_left(self, cid, chatter):
+ self._announce_part(cid, chatter)
+ self._remove_cc(cid, chatter)
+
+ def _at_chatter_quit(self, chatter, msg):
+ chatter_obj = self._chatters.get(chatter.name, None)
+ if chatter_obj is None:
+ return
+ for cc in chatter_obj.channels.values():
+ self._announce_quit(cc.channel.id_key, chatter, msg)
+ self._chatters.pop(chatter.name, None)
+
+ def _joinpart(fn):
+ def wrap(self, cid, chatter, *args, **kwargs):
+ if not self._chat_config.joinsparts:
+ return
+ if self._should_ignore_chatter(cid, chatter.name):
+ return
+ channel = self._channels.get(cid, None)
+ if channel is None:
+ return
+ return fn(self, channel, chatter, *args, **kwargs)
+ return wrap
+
+ def _announce(self, channel, text):
+ line = ChatLine(None, text, ChatLineType.INFO)
+ self._add_line(channel, line)
+
+ def _announce_chatter(self, channel, chatter, text):
+ line = ChatLine(chatter.name, text, ChatLineType.INFO)
+ self._add_line(channel, line)
+
+ @_joinpart
+ def _announce_join(self, channel, chatter):
+ self._announce_chatter(channel, chatter, "joined the channel.")
+
+ @_joinpart
+ def _announce_part(self, channel, chatter):
+ self._announce_chatter(channel, chatter, "left the channel.")
+
+ def _announce_quit(self, cid, chatter, message):
+ if (
+ not self._chat_config.joinsparts
+ and cid.type != ChannelType.PRIVATE
+ ):
+ return
+ if self._should_ignore_chatter(cid, chatter.name):
+ return
+ channel = self._channels.get(cid, None)
+ if channel is None:
+ return
+ prefix = "quit"
+ if chatter.name in message: # Silence default messages
+ message = "{}.".format(prefix)
+ else:
+ message = "{}: {}".format(prefix, message)
+ self._announce_chatter(channel, chatter, message)
+
+ def _at_quit_channel(self, cid):
+ self._delete_channel_ignoring_connection(cid)
+
+ def _at_chatter_renamed(self, old, new):
+ if old not in self._chatters:
+ return
+ self._chatters[old].update(name=new)
+
+ def _at_new_chatter_elevation(self, cid, chatter, added, removed):
+ key = (cid, chatter.name)
+ if key not in self._ccs:
+ return
+ cc = self._ccs[key]
+ old = cc.elevation
+ new = ''.join(c for c in old + added if c not in removed)
+ cc.update(elevation=new)
+
+ def _at_new_channel_topic(self, cid, topic):
+ channel = self._channels.get(cid)
+ if channel is None:
+ return
+ channel.update(topic=topic)
+
+ def _at_connected(self):
+ privchannels = self._save_privchannels()
+ self._channels.clear()
+ self._chatters.clear()
+ self._ccs.clear()
+ self._model.connected = True
+ self._restore_privchannels(privchannels)
+
+ def _save_privchannels(self):
+ return [c for c in self._channels if c.type == ChannelType.PRIVATE]
+
+ def _restore_privchannels(self, cids):
+ for cid in cids:
+ self.join_channel(cid)
+
+ def _at_disconnected(self):
+ self._model.connected = False
+
+ def _at_new_server_message(self, msg):
+ self._model.add_server_message(msg)
+
+ def _at_config_updated(self, option):
+ if option == "max_chat_lines":
+ for channel in self._channels.values:
+ self._trim_channel_lines(channel)
+
+ def _trim_channel_lines(self, channel):
+ max_ = self._chat_config.max_chat_lines
+ trim_count = self._chat_config.chat_line_trim_count
+ if len(channel.lines) <= max_:
+ return
+ trim_amount = min(len(channel.lines), trim_count)
+ channel.lines.remove_lines(trim_amount)
+
+ # User actions start here.
+ def send_message(self, cid, message):
+ action, msg = MessageAction.parse_message(message)
+ try:
+ if action == MessageAction.MSG:
+ if self._connection.send_message(cid.name, msg):
+ self._at_new_line(cid, None, self._user_chat_line(msg))
+ elif action == MessageAction.PRIVMSG:
+ chatter_name, msg = msg.split(" ", 1)
+ if self._connection.send_message(chatter_name, msg):
+ cid = ChannelID.private_cid(chatter_name)
+ self._at_new_line(cid, None, self._user_chat_line(msg))
+ elif action == MessageAction.ME:
+ if self._connection.send_action(cid.name, msg):
+ self._at_new_line(
+ cid,
+ None,
+ self._user_chat_line(
+ msg, ChatLineType.ACTION,
+ ),
+ )
+ elif action == MessageAction.SEEN:
+ self._connection.send_action("nickserv", "info {}".format(msg))
+ elif action == MessageAction.TOPIC:
+ self._connection.set_topic(cid.name, msg)
+ elif action == MessageAction.JOIN:
+ self._connection.join(msg)
+ else:
+ pass # TODO - raise 'Sending failed' error back to the view?
+ except ValueError:
+ notice = (
+ "Sending failed. Message is too long or contains invalid"
+ " character."
+ )
+ self._announce(self._channels[cid], notice)
+ except BaseException:
+ notice = "Sending failed. Check your connection."
+ self._announce(self._channels[cid], notice)
+
+ def join_channel(self, cid):
+ # Don't join a private channel with ourselves
+ if (
+ cid.type == ChannelType.PRIVATE
+ and cid.name == self._connection.nickname
+ ):
+ return
+
+ self.join_requested.emit(cid)
+ if cid.type == ChannelType.PUBLIC:
+ self._connection.join(cid.name)
+ else:
+ self._check_add_new_channel(cid)
+
+ def join_public_channel(self, name):
+ self.join_channel(ChannelID(ChannelType.PUBLIC, name))
+
+ def join_private_channel(self, name):
+ self.join_channel(ChannelID(ChannelType.PRIVATE, name))
+
+ def _user_chat_line(self, msg, type_=ChatLineType.MESSAGE):
+ return ChatLine(self._connection.nickname, msg, type_)
+
+ def leave_channel(self, cid, reason):
+ if cid.type == ChannelType.PRIVATE:
+ self._delete_channel_ignoring_connection(cid)
+ else:
+ if not self._connection.part(cid.name, reason):
+ # We're disconnected from IRC - allow user to close tabs anyway
+ self._delete_channel_ignoring_connection(cid)
+
+ def _delete_channel_ignoring_connection(self, cid):
+ self._channels.pop(cid, None)
+
+ def _should_ignore_chatter(self, cid, name):
+ if name is None:
+ return False
+ if cid.type == ChannelType.PUBLIC:
+ cc = self._ccs.get((cid, name), None)
+ if cc is None or cc.is_mod() or cc.chatter.is_base_channel_mod():
+ return False
+
+ chatter = self._chatters.get(name, None)
+ if chatter is None:
+ return False
+ name = chatter.name
+ id_ = None if chatter.player is None else chatter.player.id
+ if self._user_relations.is_foe(id_, name):
+ if self._user_relations.is_chatterbox(id_, name):
+ return True
+ else:
+ return self._chat_config.ignore_foes
+ return self._user_relations.is_chatterbox(id_, name)
+
+
+class MessageAction(Enum):
+ MSG = "message"
+ UNKNOWN = "unknown"
+ PRIVMSG = "/msg "
+ ME = "/me "
+ SEEN = "/seen "
+ TOPIC = "/topic "
+ JOIN = "/join "
+
+ @classmethod
+ def parse_message(cls, msg):
+ if not msg.startswith("/"):
+ return cls.MSG, msg
+
+ for cmd in cls:
+ if cmd in [cls.MSG, cls.UNKNOWN]:
+ continue
+ if msg.startswith(cmd.value):
+ return cmd, msg[len(cmd.value):]
+
+ return cls.UNKNOWN, msg
diff --git a/src/chat/chat_greeter.py b/src/chat/chat_greeter.py
new file mode 100644
index 000000000..bcf0b980c
--- /dev/null
+++ b/src/chat/chat_greeter.py
@@ -0,0 +1,41 @@
+from model.chat.channel import ChannelType
+from model.chat.chatline import ChatLine, ChatLineType
+from util import irc_escape
+
+
+class ChatGreeter:
+ def __init__(self, model, theme, chat_config, line_metadata_builder):
+ self._model = model
+ self._model.channels.added.connect(self._at_channel_added)
+ self._chat_config = chat_config
+ self._line_metadata_builder = line_metadata_builder
+ self._greeted_channels = set()
+ self._greeting_format = theme.readfile("chat/raw.qhtml")
+
+ @property
+ def _greeting(self):
+ return self._chat_config.channel_greeting
+
+ @property
+ def _channels(self):
+ return self._chat_config.channels_to_greet_in
+
+ def _at_channel_added(self, channel):
+ cid = channel.id_key
+ if cid in self._greeted_channels:
+ return
+ if cid.type != ChannelType.PUBLIC or cid.name not in self._channels:
+ return
+ self._print_greeting(channel)
+ self._greeted_channels.add(cid)
+
+ def _print_greeting(self, channel):
+ for line in self._greeting:
+ text, color, size = line
+ text = irc_escape(text)
+ msg = self._greeting_format.format(
+ text=text, color=color, size=size,
+ )
+ line = ChatLine(None, msg, ChatLineType.RAW)
+ data = self._line_metadata_builder.get_meta(channel, line)
+ channel.lines.add_line(data)
diff --git a/src/chat/chat_view.py b/src/chat/chat_view.py
new file mode 100644
index 000000000..2bed55bd1
--- /dev/null
+++ b/src/chat/chat_view.py
@@ -0,0 +1,94 @@
+from chat.channel_tab import ChannelTab
+from chat.channel_view import ChannelView
+from chat.chat_widget import ChatWidget
+from model.chat.channel import ChannelType
+
+
+class ChatView:
+ def __init__(
+ self, target_viewed_channel, model, controller, widget,
+ channel_view_builder, channel_tab_builder,
+ ):
+ self._target_viewed_channel = None
+ self._model = model
+ self._controller = controller
+ self._controller.join_requested.connect(self._at_join_requested)
+ self.widget = widget
+ self._channel_view_builder = channel_view_builder
+ self._channel_tab_builder = channel_tab_builder
+ self._channels = {}
+ self._model.channels.added.connect(self._add_channel)
+ self._model.channels.removed.connect(self._remove_channel)
+ self._model.new_server_message.connect(self._new_server_message)
+ self.widget.channel_quit_request.connect(self._at_channel_quit_request)
+ self.widget.tab_changed.connect(self._at_tab_changed)
+ self._add_channels()
+
+ self.target_viewed_channel = target_viewed_channel
+
+ @classmethod
+ def build(cls, target_viewed_channel, model, controller, **kwargs):
+ chat_widget = ChatWidget.build(**kwargs)
+ channel_view_builder = ChannelView.builder(
+ controller, channelchatterset=model.channelchatters, **kwargs
+ )
+ channel_tab_builder = ChannelTab.builder(**kwargs)
+ return cls(
+ target_viewed_channel, model, controller, chat_widget,
+ channel_view_builder, channel_tab_builder,
+ )
+
+ def _add_channels(self):
+ for channel in self._model.channels.values():
+ self._add_channel(channel)
+
+ def _add_channel(self, channel):
+ if channel.id_key in self._channels:
+ return
+ tab = self._channel_tab_builder(channel.id_key, self.widget)
+ view = self._channel_view_builder(channel, tab)
+ self._channels[channel.id_key] = view
+ self.widget.add_channel(view.widget, channel.id_key)
+ self._try_to_join_target_channel()
+
+ def _remove_channel(self, channel):
+ if channel.id_key not in self._channels:
+ return
+ self.widget.remove_channel(channel.id_key)
+ del self._channels[channel.id_key]
+
+ def _new_server_message(self, msg):
+ self.widget.write_server_message(msg)
+
+ def _at_channel_quit_request(self, cid):
+ self._controller.leave_channel(cid, "tab closed")
+
+ def _at_tab_changed(self, cid):
+ self._channels[cid].on_shown()
+
+ def _at_join_requested(self, cid):
+ if cid.type == ChannelType.PRIVATE:
+ self.target_viewed_channel = cid
+
+ def entered(self):
+ current = self.widget.current_channel()
+ if current is None:
+ return
+ self._channels[current].on_shown()
+
+ @property
+ def target_viewed_channel(self):
+ return self._target_viewed_channel
+
+ @target_viewed_channel.setter
+ def target_viewed_channel(self, value):
+ self._target_viewed_channel = value
+ self._try_to_join_target_channel()
+
+ def _try_to_join_target_channel(self):
+ if self._target_viewed_channel is None:
+ return
+ if self._target_viewed_channel not in self._channels:
+ return
+ self.widget.switch_to_channel(self._target_viewed_channel)
+ self._target_viewed_channel = None
diff --git a/src/chat/chat_widget.py b/src/chat/chat_widget.py
new file mode 100644
index 000000000..308b08ded
--- /dev/null
+++ b/src/chat/chat_widget.py
@@ -0,0 +1,128 @@
+from enum import Enum
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QTabBar
+
+from model.chat.channel import PARTY_CHANNEL_SUFFIX
+from model.chat.channel import ChannelType
+
+
+class TabIcon(Enum):
+ IDLE = "idle"
+ NEW_MESSAGE = "new_message"
+ BLINK_ACTIVE = "blink_active"
+ BLINK_INACTIVE = "blink_inactive"
+
+
+class ChatWidget(QObject):
+ channel_quit_request = pyqtSignal(object)
+ tab_changed = pyqtSignal(object)
+
+ def __init__(self, theme):
+ QObject.__init__(self)
+ self._channels = {}
+ self._theme = theme
+ self.set_theme()
+
+ @classmethod
+ def build(cls, theme, **kwargs):
+ return cls(theme)
+
+ def set_theme(self):
+ formc, basec = self._theme.loadUiType("chat/chat.ui")
+ self.form = formc()
+ self.base = basec()
+ self.form.setupUi(self.base)
+ self.base.tabCloseRequested.connect(self._at_tab_close_request)
+ self.base.currentChanged.connect(self._at_tab_changed)
+ self.remove_server_tab_close_button()
+
+ def remove_server_tab_close_button(self):
+ self.base.tabBar().setTabButton(0, QTabBar.ButtonPosition.RightSide, None)
+
+ def add_channel(self, widget, key, index=None):
+ if key in self._channels:
+ return
+ self._channels[key] = widget
+ if index is None:
+ self._add_tab_in_default_spot(widget, key)
+ else:
+ self.base.insertTab(index, widget.base, key.name)
+ self.set_tab_icon(key, TabIcon.IDLE)
+
+ def _add_tab_in_default_spot(self, widget, key):
+ if key.name.endswith(PARTY_CHANNEL_SUFFIX):
+ tab_name = "Party Channel"
+ else:
+ tab_name = key.name
+ if key.type == ChannelType.PRIVATE:
+ self.base.addTab(widget.base, tab_name)
+ return
+ try:
+ last_public_tab = max([
+ self.base.indexOf(w.base)
+ for cid, w in self._channels.items()
+ if cid.type == ChannelType.PUBLIC and cid != key
+ ])
+ self.base.insertTab(last_public_tab + 1, widget.base, tab_name)
+ return
+ except ValueError:
+ pass
+ try:
+ first_private_tab = min([
+ self.base.indexOf(w.base)
+ for cid, w in self._channels.items()
+ if cid.type == ChannelType.PRIVATE and cid != key
+ ])
+ self.base.insertTab(first_private_tab, widget.base, tab_name)
+ return
+ except ValueError:
+ pass
+ self.base.addTab(widget.base, tab_name)
+
+ def remove_channel(self, key):
+ widget = self._channels.pop(key, None)
+ if widget is None:
+ return
+ self.base.removeTab(self.base.indexOf(widget.base))
+
+ def write_server_message(self, msg):
+ self.form.serverLogArea.appendPlainText(msg)
+
+ def _at_tab_close_request(self, idx):
+ self.channel_quit_request.emit(self.base.widget(idx).cid)
+
+ def switch_to_channel(self, key):
+ widget = self._channels.get(key, None)
+ if widget is None:
+ return
+ self.base.setCurrentIndex(self.base.indexOf(widget.base))
+
+ def set_tab_icon(self, key, name):
+ icon = self._theme.icon("chat/tabicon/{}.png".format(name.value))
+ widget = self._channels.get(key, None)
+ if widget is None:
+ return
+ idx = self.base.indexOf(widget.base)
+ self.base.setTabIcon(idx, icon)
+
+ def alert_tab(self):
+ QApplication.alert(self.base)
+
+ def _index_to_cid(self, idx):
+ for cid in self._channels:
+ if idx == self.base.indexOf(self._channels[cid].base):
+ return cid
+ return None
+
+ def _at_tab_changed(self, idx):
+ cid = self._index_to_cid(idx)
+ if cid is None:
+ return
+ self.tab_changed.emit(cid)
+
+ def current_channel(self):
+ current_idx = self.base.currentIndex()
+ return self._index_to_cid(current_idx)
diff --git a/src/chat/chatlineedit.py b/src/chat/chatlineedit.py
index 938437f52..0370531e0 100644
--- a/src/chat/chatlineedit.py
+++ b/src/chat/chatlineedit.py
@@ -4,15 +4,17 @@
@author: thygrrr
"""
-from PyQt5 import QtCore, QtWidgets
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
class ChatLineEdit(QtWidgets.QLineEdit):
"""
- A special promoted QLineEdit that is used in channel.ui to provide a mirc-style editing experience
- with completion and history.
+ A special promoted QLineEdit that is used in channel.ui to provide a
+ mirc-style editing experience with completion and history.
LATER: History and tab completion support
"""
+
def __init__(self, parent):
QtWidgets.QLineEdit.__init__(self, parent)
self.returnPressed.connect(self.on_line_entered)
@@ -20,27 +22,28 @@ def __init__(self, parent):
self.currentHistoryIndex = None
self.historyShown = False
self.completionStarted = False
- self.chatters = {}
+ self.channel = None
self.LocalChatterNameList = []
self.currenLocalChatter = None
- def set_chatters(self, chatters):
- self.chatters = chatters
+ def set_channel(self, channel):
+ self.channel = channel
def event(self, event):
- if event.type() == QtCore.QEvent.KeyPress:
- # Swallow a selection of keypresses that we want for our history support.
- if event.key() == QtCore.Qt.Key_Tab:
+ if event.type() == QtCore.QEvent.Type.KeyPress:
+ # Swallow a selection of keypresses that we want for our history
+ # support.
+ if event.key() == QtCore.Qt.Key.Key_Tab:
self.try_completion()
return True
- elif event.key() == QtCore.Qt.Key_Space:
+ elif event.key() == QtCore.Qt.Key.Key_Space:
self.accept_completion()
return QtWidgets.QLineEdit.event(self, event)
- elif event.key() == QtCore.Qt.Key_Up:
+ elif event.key() == QtCore.Qt.Key.Key_Up:
self.cancel_completion()
self.prev_history()
return True
- elif event.key() == QtCore.Qt.Key_Down:
+ elif event.key() == QtCore.Qt.Key.Key_Down:
self.cancel_completion()
self.next_history()
return True
@@ -57,7 +60,7 @@ def on_line_entered(self):
self.currentHistoryIndex = len(self.history) - 1
def showEvent(self, event):
- self.setFocus(True)
+ self.setFocus()
return QtWidgets.QLineEdit.showEvent(self, event)
def try_completion(self):
@@ -67,28 +70,38 @@ def try_completion(self):
return
# no completion if last character is a space
if self.text().rfind(" ") == (len(self.text()) - 1):
- return
+ return
- self.completionStarted = True
+ self.completionStarted = True
self.LocalChatterNameList = []
- self.completionText = self.text().split()[-1] # take last word from line
- self.completionLine = self.text().rstrip(self.completionText) # store line to be completed without the completion string
-
- # make a copy of users because the list might change frequently giving all kind of problems
- for chatter in self.chatters:
- if chatter.name.lower().startswith(self.completionText.lower()):
- self.LocalChatterNameList.append(chatter.name)
-
+ # take last word from line
+ self.completionText = self.text().split()[-1]
+ # store line to be completed without the completion string
+ self.completionLine = self.text().rstrip(self.completionText)
+
+ # make a copy of users because the list might change frequently
+ # giving all kind of problems
+ if self.channel is not None:
+ for cc in self.channel.chatters.values():
+ name = cc.chatter.name
+ if name.lower().startswith(self.completionText.lower()):
+ self.LocalChatterNameList.append(name)
+
if len(self.LocalChatterNameList) > 0:
- self.LocalChatterNameList.sort(key=lambda chatter: chatter.lower())
+ self.LocalChatterNameList.sort(
+ key=lambda chatter: chatter.lower(),
+ )
self.currenLocalChatter = 0
- self.setText(self.completionLine + self.LocalChatterNameList[self.currenLocalChatter])
+ localName = self.LocalChatterNameList[self.currenLocalChatter]
+ self.setText(self.completionLine + localName)
else:
self.currenLocalChatter = None
else:
if self.currenLocalChatter is not None:
- self.currenLocalChatter = (self.currenLocalChatter + 1) % len(self.LocalChatterNameList)
- self.setText(self.completionLine + self.LocalChatterNameList[self.currenLocalChatter])
+ self.currenLocalChatter += 1
+ self.currenLocalChatter %= len(self.LocalChatterNameList)
+ localName = self.LocalChatterNameList[self.currenLocalChatter]
+ self.setText(self.completionLine + localName)
def accept_completion(self):
self.completionStarted = False
@@ -98,14 +111,21 @@ def cancel_completion(self):
def prev_history(self):
if self.currentHistoryIndex is not None: # no history nothing to do
- if self.currentHistoryIndex > 0 and self.historyShown: # check for boundaries and only change index is hostory is alrady shown
+ # check for boundaries and only change index is history is already
+ # shown
+ if self.currentHistoryIndex > 0 and self.historyShown:
self.currentHistoryIndex -= 1
self.historyShown = True
self.setText(self.history[self.currentHistoryIndex])
-
+
def next_history(self):
if self.currentHistoryIndex is not None:
- if self.currentHistoryIndex < len(self.history)-1 and self.historyShown: # check for boundaries and only change index is hostory is alrady shown
+ # check for boundaries and only change index is history is already
+ # shown
+ if (
+ self.currentHistoryIndex < len(self.history) - 1
+ and self.historyShown
+ ):
self.currentHistoryIndex += 1
self.historyShown = True
- self.setText(self.history[self.currentHistoryIndex])
+ self.setText(self.history[self.currentHistoryIndex])
diff --git a/src/chat/chatter.py b/src/chat/chatter.py
deleted file mode 100644
index 31669c6d4..000000000
--- a/src/chat/chatter.py
+++ /dev/null
@@ -1,562 +0,0 @@
-from PyQt5 import QtWidgets, QtCore, QtGui
-from PyQt5.QtCore import QUrl
-from PyQt5.QtNetwork import QNetworkRequest
-from chat._avatarWidget import AvatarWidget
-import time
-from urllib import parse
-
-from fa.replay import replay
-from fa import maps
-
-import util
-from config import Settings
-
-from model.game import GameState
-from client.aliasviewer import AliasWindow
-from chat.gameinfo import SensitiveMapInfoChecker
-from downloadManager import PreviewDownloadRequest
-
-"""
-A chatter is the representation of a person on IRC, in a channel's nick list.
-There are multiple chatters per channel.
-There can be multiple chatters for every Player in the Client.
-"""
-
-
-class Chatter(QtWidgets.QTableWidgetItem):
- SORT_COLUMN = 2
- AVATAR_COLUMN = 1
- RANK_COLUMN = 0
- STATUS_COLUMN = 3
- MAP_COLUMN = 4
-
- RANK_ELEVATION = 0
- RANK_FRIEND = 1
- RANK_USER = 2
- RANK_NONPLAYER = 3
- RANK_FOE = 4
-
- def __init__(self, parent, user, channel, chat_widget, me):
- QtWidgets.QTableWidgetItem.__init__(self, None)
-
- # TODO: for now, userflags and ranks aren't properly interpreted :-/
- # This is impractical if an operator reconnects too late.
- self.parent = parent
- self.chat_widget = chat_widget
- self.channel = channel
-
- self._me = me
- self._me.relationsUpdated.connect(self._check_player_relation)
- self._me.ircRelationsUpdated.connect(self._check_user_relation)
-
- self._map_dl_request = PreviewDownloadRequest()
- self._map_dl_request.done.connect(self._on_map_downloaded)
-
- self._aliases = AliasWindow(self.parent)
- self._game_info_hider = SensitiveMapInfoChecker(self._me)
-
- self.setFlags(QtCore.Qt.ItemIsEnabled)
- self.setTextAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
- self.avatarTip = ""
-
- self.avatarItem = QtWidgets.QTableWidgetItem()
- self.avatarItem.setFlags(QtCore.Qt.ItemIsEnabled)
- self.avatarItem.setTextAlignment(QtCore.Qt.AlignHCenter)
-
- self.rankItem = QtWidgets.QTableWidgetItem()
- self.rankItem.setFlags(QtCore.Qt.ItemIsEnabled)
- self.rankItem.setTextAlignment(QtCore.Qt.AlignHCenter)
-
- self.statusItem = QtWidgets.QTableWidgetItem()
- self.statusItem.setFlags(QtCore.Qt.ItemIsEnabled)
- self.statusItem.setTextAlignment(QtCore.Qt.AlignHCenter)
-
- self.mapItem = QtWidgets.QTableWidgetItem()
- self.mapItem.setFlags(QtCore.Qt.ItemIsEnabled)
- self.mapItem.setTextAlignment(QtCore.Qt.AlignHCenter)
-
- self._user = None
- self._user_player = None
- self._user_game = None
- # This updates the above three and the widget
- self.user = user
-
- row = self.parent.rowCount()
- self.parent.insertRow(row)
-
- self.parent.setItem(row, Chatter.SORT_COLUMN, self)
-
- self.parent.setItem(self.row(), Chatter.RANK_COLUMN, self.rankItem)
- self.parent.setItem(self.row(), Chatter.AVATAR_COLUMN, self.avatarItem)
- self.parent.setItem(self.row(), Chatter.STATUS_COLUMN, self.statusItem)
- self.parent.setItem(self.row(), Chatter.MAP_COLUMN, self.mapItem)
-
- @property
- def user(self):
- return self._user
-
- @user.setter
- def user(self, value):
- if self._user is not None:
- self.user_player = None # Clears game as well
- self._user.updated.disconnect(self.update_user)
- self._user.newPlayer.disconnect(self._set_user_player)
-
- self._user = value
- self.update_user()
-
- if self._user is not None:
- self._user.updated.connect(self.update_user)
- self._user.newPlayer.connect(self._set_user_player)
- self.user_player = self._user.player
-
- def _set_user_player(self, user, player):
- self.user_player = player
-
- @property
- def user_player(self):
- return self._user_player
-
- @user_player.setter
- def user_player(self, value):
- if self._user_player is not None:
- self.user_game = None
- self._user_player.updated.disconnect(self.update_player)
- self._user_player.newCurrentGame.disconnect(self._set_user_game)
-
- self._user_player = value
- self.update_player()
-
- if self._user_player is not None:
- self._user_player.updated.connect(self.update_player)
- self._user_player.newCurrentGame.connect(self._set_user_game)
- self.user_game = self._user_player.currentGame
-
- def _set_user_game(self, player, game):
- self.user_game = game
-
- @property
- def user_game(self):
- return self._user_game
-
- @user_game.setter
- def user_game(self, value):
- if self._user_game is not None:
- self._user_game.gameUpdated.disconnect(self.update_game)
- self._user_game.liveReplayAvailable.disconnect(self.update_game)
-
- self._user_game = value
- self.update_game()
-
- if self._user_game is not None:
- self._user_game.gameUpdated.connect(self.update_game)
- self._user_game.liveReplayAvailable.connect(self.update_game)
-
- def _check_player_relation(self, players):
- if self.user_player is None:
- return
-
- if self.user_player.id in players:
- self.set_color()
- self._verify_sort_order()
-
- def _check_user_relation(self, users):
- if self.user.name in users:
- self.set_color()
- self._verify_sort_order()
-
- def is_filtered(self, _filter):
- clan = None if self.user_player is None else self.user_player.clan
- clan = clan if clan is not None else ""
- name = self.user.name
- if _filter in clan.lower() or _filter in name.lower():
- return True
- return False
-
- def set_visible(self, visible):
- if visible:
- self.tableWidget().showRow(self.row())
- else:
- self.tableWidget().hideRow(self.row())
-
- def __ge__(self, other):
- """ Comparison operator used for item list sorting """
- return not self.__lt__(other)
-
- def __lt__(self, other):
- """ Comparison operator used for item list sorting """
- self_rank = self.get_user_rank(self)
- other_rank = self.get_user_rank(other)
-
- if self._me.login is not None:
- if self.user.name == self._me.login:
- return True
- if other.user.name == self._me.login:
- return False
-
- # if not same rank sort
- if self_rank != other_rank:
- return self_rank < other_rank
-
- # Default: Alphabetical
- return self.user.name.lower() < other.user.name.lower()
-
- def _verify_sort_order(self):
- if self.row() != -1:
- self.channel.verify_sort_order(self)
-
- def _get_id_name(self):
- _id = -1 if self.user_player is None else self.user_player.id
- name = self.user.name
- return _id, name
-
- def get_user_rank(self, user):
- # TODO: Add subdivision for admin?
- me = self._me
- _id, name = user._get_id_name()
- if user.mod_elevation():
- return self.RANK_ELEVATION
- if me.isFriend(_id, name):
- return self.RANK_FRIEND - (2 if self.chat_widget.client.friendsontop else 0)
- if me.isFoe(_id, name):
- return self.RANK_FOE
- if user.user_player is not None:
- return self.RANK_USER
-
- return self.RANK_NONPLAYER
-
- def mod_elevation(self):
- if not self.user.is_mod(self.channel.name):
- return None
- return self.user.elevation[self.channel.name]
-
- def update_avatar(self):
- # FIXME: prodding the underlying C++ object to see if it exists
- # Needed if we're gone while downloading our avatar
- # We don't subclass QObject, so we have to do it this way
- try:
- self.isSelected()
- except RuntimeError:
- return
- try:
- avatar = self.user_player.avatar
- except AttributeError:
- avatar = None
-
- if avatar is not None:
- self.avatarTip = avatar["tooltip"]
- url = parse.unquote(avatar["url"])
- avatarPix = util.respix(url)
-
- if avatarPix:
- self.avatarItem.setIcon(QtGui.QIcon(avatarPix))
- self.avatarItem.setToolTip(self.avatarTip)
- else:
- if util.addcurDownloadAvatar(url, self):
- self.chat_widget.nam.get(QNetworkRequest(QUrl(url)))
- else:
- # No avatar set.
- self.avatarItem.setIcon(QtGui.QIcon())
- self.avatarItem.setToolTip(None)
-
- def set_chatter_name(self):
- if self.user_player is not None and self.user_player.clan is not None:
- self.setText("[{}]{}".format(self.user_player.clan,
- self.user.name))
- else:
- self.setText(self.user.name)
-
- def update_user(self):
- self.set_chatter_name()
- self.set_color()
- self._verify_sort_order()
-
- def update_player(self):
- self.set_chatter_name()
- self.update_rank()
- self.update_country()
- self.update_avatar()
-
- def update_country(self):
- player = self.user_player
- if player is None:
- self.setIcon(QtGui.QIcon())
- self.setToolTip("")
- return
- # server sends '' for no ip2country-resolution
- if player.country is None or player.country == '':
- country = '__'
- else:
- country = player.country
- self.setIcon(util.THEME.icon("chat/countries/{}.png".format(country.lower())))
- self.setToolTip(country)
-
- def update_rank(self):
- player = self.user_player
- if player is None:
- self.rankItem.setIcon(util.THEME.icon("chat/rank/civilian.png"))
- self.rankItem.setToolTip("IRC User")
- return
- # chr(0xB1) = +-
- formatting = ("Global Rating: {} ({} Games) [{}\xb1{}]\n"
- "Ladder Rating: {} [{}\xb1{}]")
- tooltip_str = formatting.format((int(player.rating_estimate())),
- player.number_of_games,
- int(player.rating_mean),
- int(player.rating_deviation),
- int(player.ladder_estimate()),
- int(player.ladder_rating_mean),
- int(player.ladder_rating_deviation))
- league = player.league
- if league is not None:
- icon_str = league["league"]
- tooltip_str = "Division : {}\n{}".format(league["division"],
- tooltip_str)
- else:
- icon_str = "newplayer"
- self.rankItem.setIcon(util.THEME.icon("chat/rank/{}.png".format(icon_str)))
- self.rankItem.setToolTip(tooltip_str)
-
- def update_game(self):
- self.update_status_tooltip()
- self.update_status_icon()
- self.update_map()
-
- def update_status_tooltip(self):
- # Status tooltip handling
- game = self.user_game
- should_hide_info = self._game_info_hider.has_sensitive_data(game)
- if game is not None and not game.closed():
- if should_hide_info:
- game_map = "[delayed reveal]"
- game_title = "[delayed reveal]"
- else:
- game_map = game.mapdisplayname
- game_title = game.title
- private_str = " (private)" if game.password_protected else ""
- delay_str = ""
- if game.state == GameState.OPEN:
- if game.host == self.user.name:
- head_str = "Hosting{private} game"
- else:
- head_str = "In{private} Lobby (host {host})"
- elif game.state == GameState.PLAYING:
- head_str = "Playing{delay}"
- if not game.has_live_replay:
- delay_str = " - LIVE DELAY (5 Min)"
- else: # game.state == something else
- head_str = "Playing maybe ..."
- formatting = "{} title: {} mod: {} map: {} players: {} / {} id: {}"
- game_str = formatting.format(head_str.format(private=private_str, delay=delay_str, host=game.host),
- game_title, game.featured_mod, game_map,
- game.num_players, game.max_players, game.uid)
- else: # game is None or closed
- game_str = "Idle"
-
- self.statusItem.setToolTip(game_str)
-
- def update_status_icon(self):
- # Status icon handling
- game = self.user_game
- if game is not None and not game.closed():
- if game.state == GameState.OPEN:
- if game.host == self.user.name:
- icon_str = "host"
- else:
- icon_str = "lobby"
- elif game.state == GameState.PLAYING:
- if game.has_live_replay:
- icon_str = "playing"
- else:
- icon_str = "playing5"
- else: # game.state == something else
- icon_str = "unknown"
- else: # game is None or closed
- icon_str = "none"
-
- self.statusItem.setIcon(util.THEME.icon("chat/status/%s.png" % icon_str))
-
- def update_map(self):
- # Map icon handling - if we're in game, show the map if toggled on
- game = self.user_game
- should_hide_info = self._game_info_hider.has_sensitive_data(game)
- if game is not None and not game.closed() and util.settings.value("chat/chatmaps", False):
- if should_hide_info:
- self.mapItem.setIcon(util.THEME.icon("chat/status/unknown.png"))
- self.mapItem.setToolTip("[delayed reveal]")
- else:
- mapname = game.mapname
- icon = maps.preview(mapname)
- if not icon:
- dler = self.chat_widget.client.map_downloader
- dler.download_preview(mapname, self._map_dl_request)
- else:
- self.mapItem.setIcon(icon)
-
- self.mapItem.setToolTip(game.mapdisplayname)
- else:
- self.mapItem.setIcon(QtGui.QIcon())
- self.mapItem.setToolTip("")
-
- def _on_map_downloaded(self, mapname, result):
- if self.user_game is None or self.user_game.mapname != mapname:
- return
- path, is_local = result
- self.mapItem.setIcon(util.THEME.icon(path, is_local))
-
- def update(self):
- self.update_user()
- self.update_player()
- self.update_game()
-
- def set_color(self):
- # FIXME - we should really get colors in the constructor
- pcolors = self.chat_widget.client.player_colors
- elevation = self.mod_elevation()
- _id, name = self._get_id_name()
- if elevation is not None:
- color = pcolors.getModColor(elevation, _id, name)
- else:
- color = pcolors.getUserColor(_id, name)
- self.setForeground(QtGui.QColor(color))
-
- def view_aliases(self):
- if self.user_player is not None:
- player_id = self.user_player.id
- else:
- player_id = None
- self._aliases.view_aliases(self.user.name, player_id)
-
- def select_avatar(self):
- avatarSelection = AvatarWidget(self.chat_widget.client, self.user.name, personal=True)
- avatarSelection.exec_()
-
- def add_avatar(self):
- avatarSelection = AvatarWidget(self.chat_widget.client, self.user.name)
- avatarSelection.exec_()
-
- def kick(self):
- pass
-
- def double_clicked(self, item):
- # filter yourself
- if self._me.login is not None:
- if self._me.login == self.user.name:
- return
- # Chatter name clicked
- if item == self:
- self.chat_widget.open_query(self.user, activate=True) # open and activate query window
-
- elif item == self.statusItem:
- self._interact_with_game()
-
- def _interact_with_game(self):
- game = self.user_game
- if game is None or game.closed():
- return
-
- url = game.url(self.user_player.id)
- if game.state == GameState.OPEN:
- self.join_in_game(url)
- elif game.state == GameState.PLAYING:
- self.view_replay(url)
-
- def pressed(self, item):
- menu = QtWidgets.QMenu(self.parent)
-
- def menu_add(action_str, action_connect, separator=False):
- if separator:
- menu.addSeparator()
- action = QtWidgets.QAction(action_str, menu)
- action.triggered.connect(action_connect) # Triggers
- menu.addAction(action)
-
- player = self.user_player
- game = self.user_game
- _id, name = self._get_id_name()
-
- if player is None or self._me.player is None:
- is_me = False
- else:
- is_me = player.id == self._me.player.id
-
- if is_me: # only for us. Either way, it will display our avatar, not anyone avatar.
- menu_add("Select Avatar", self.select_avatar)
-
- # power menu
- if self.chat_widget.client.power > 1:
- # admin and mod menus
- menu_add("Assign avatar", self.add_avatar, True)
-
- if self.chat_widget.client.power == 2:
-
- def send_the_orcs():
- route = Settings.get('mordor/host')
- if _id != -1:
- QtGui.QDesktopServices.openUrl(QUrl("{}/users/{}".format(route, _id)))
- else:
- QtGui.QDesktopServices.openUrl(QUrl("{}/users/{}".format(route, name)))
-
- menu_add("Send the Orcs", send_the_orcs, True)
- menu_add("Close Game", lambda: self.chat_widget.client.closeFA(name))
- menu_add("Close FAF Client", lambda: self.chat_widget.client.closeLobby(name))
-
- menu_add("View Aliases", self.view_aliases, True)
- if player is not None: # not for irc user
- if int(player.ladder_estimate()) != 0: # not for 'never played ladder'
- menu_add("View in Leaderboards", self.view_in_leaderboards)
-
- # Don't allow self to be invited to a game, or join one
- if game is not None and not is_me:
- if game.state == GameState.OPEN:
- menu_add("Join hosted Game", self._interact_with_game, True)
- elif game.state == GameState.PLAYING:
- time_running = time.time() - game.launched_at
- if game.has_live_replay:
- time_format = '%M:%S' if time_running < 60 * 60 else '%H:%M:%S'
- duration_str = time.strftime(time_format, time.gmtime(time_running))
- action_str = "View Live Replay (runs " + duration_str + ")"
- else:
- wait_str = time.strftime('%M:%S', time.gmtime(game.LIVE_REPLAY_DELAY_SECS - time_running))
- action_str = "WAIT " + wait_str + " to view Live Replay"
- menu_add(action_str, self._interact_with_game, True)
-
- if player is not None: # not for irc user
- menu_add("View Replays in Vault", self.view_vault_replay, True)
-
- # Friends and Foes Lists
- def player_or_irc_action(f, irc_f):
- _id, name = self._get_id_name()
- if player is not None:
- return lambda: f(_id)
- else:
- return lambda: irc_f(name)
-
- cl = self.chat_widget.client
- me = self._me
- if is_me: # We're ourselves
- pass
- elif me.isFriend(_id, name): # We're a friend
- menu_add("Remove friend", player_or_irc_action(cl.remFriend, me.remIrcFriend), True)
- elif me.isFoe(_id, name): # We're a foe
- menu_add("Remove foe", player_or_irc_action(cl.remFoe, me.remIrcFoe), True)
- else: # We're neither
- menu_add("Add friend", player_or_irc_action(cl.addFriend, me.addIrcFriend), True)
- # FIXME - chatwidget sets mod status very inconsistently
- if self.mod_elevation() is None: # so disable foeing mods for now
- menu_add("Add foe", player_or_irc_action(cl.addFoe, me.addIrcFoe))
-
- # Finally: Show the popup
- menu.popup(QtGui.QCursor.pos())
-
- def view_vault_replay(self):
- self.chat_widget.client.searchUserReplays(self.user.name)
-
- def join_in_game(self, url):
- self.chat_widget.client.joinGameFromURL(url)
-
- def view_replay(self, url):
- replay(url)
-
- def view_in_leaderboards(self):
- self.chat_widget.client.viewUserLeaderboards(self.user_player)
diff --git a/src/chat/chatter_menu.py b/src/chat/chatter_menu.py
new file mode 100644
index 000000000..b1b304cb7
--- /dev/null
+++ b/src/chat/chatter_menu.py
@@ -0,0 +1,232 @@
+import logging
+from enum import Enum
+
+from PyQt6.QtGui import QAction
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QMenu
+
+from model.game import GameState
+
+logger = logging.getLogger(__name__)
+
+
+class ChatterMenuItems(Enum):
+ SELECT_AVATAR = "Select avatar"
+ SEND_ORCS = "Send the Orcs"
+ CLOSE_GAME = "Close Game"
+ KICK_PLAYER = "Close FAF Client"
+ VIEW_ALIASES = "View aliases"
+ VIEW_IN_LEADERBOARDS = "View in Leaderboards"
+ JOIN_GAME = "Join hosted Game"
+ VIEW_LIVEREPLAY = "View live replay"
+ VIEW_REPLAYS = "View Replays in Vault"
+ ADD_FRIEND = "Add friend"
+ ADD_FOE = "Add foe"
+ REMOVE_FRIEND = "Remove friend"
+ REMOVE_FOE = "Remove foe"
+ ADD_CHATTERBOX = "Ignore"
+ REMOVE_CHATTERBOX = "Unignore"
+ COPY_USERNAME = "Copy username"
+ INVITE_TO_PARTY = "Invite to party"
+ KICK_FROM_PARTY = "Kick from party"
+
+
+class ChatterMenu:
+ def __init__(
+ self, me, power_tools, parent_widget, avatar_widget_builder,
+ alias_viewer, client_window, game_runner,
+ ):
+ self._me = me
+ self._power_tools = power_tools
+ self._parent_widget = parent_widget
+ self._avatar_widget_builder = avatar_widget_builder
+ self._alias_viewer = alias_viewer
+ self._client_window = client_window
+ self._game_runner = game_runner
+
+ @classmethod
+ def build(
+ cls, me, power_tools, parent_widget, avatar_widget_builder,
+ alias_viewer, client_window, game_runner, **kwargs
+ ):
+ return cls(
+ me, power_tools, parent_widget, avatar_widget_builder,
+ alias_viewer, client_window, game_runner,
+ )
+
+ def actions(self, cc):
+ chatter = cc.chatter
+ player = chatter.player
+ game = None if player is None else player.currentGame
+
+ if player is None or self._me.player is None:
+ is_me = False
+ else:
+ is_me = player.id == self._me.player.id
+
+ yield list(self.me_actions(is_me))
+ yield list(self.power_actions(self._power_tools.power))
+ yield list(self.chatter_actions())
+ yield list(self.player_actions(player, game, is_me))
+ yield list(self.friend_actions(player, chatter, cc, is_me))
+ yield list(self.ignore_actions(player, chatter, cc, is_me))
+ yield list(self.party_actions(player, is_me))
+
+ def chatter_actions(self):
+ yield ChatterMenuItems.COPY_USERNAME
+ yield ChatterMenuItems.VIEW_ALIASES
+
+ def me_actions(self, is_me):
+ if is_me:
+ yield ChatterMenuItems.SELECT_AVATAR
+
+ def power_actions(self, power):
+ if power == 2:
+ yield ChatterMenuItems.SEND_ORCS
+ yield ChatterMenuItems.CLOSE_GAME
+ yield ChatterMenuItems.KICK_PLAYER
+
+ def player_actions(self, player, game, is_me):
+ if game is not None and not is_me:
+ if game.state == GameState.OPEN:
+ yield ChatterMenuItems.JOIN_GAME
+ elif game.state == GameState.PLAYING:
+ yield ChatterMenuItems.VIEW_LIVEREPLAY
+
+ if player is not None:
+ if player.ladder_estimate != 0:
+ yield ChatterMenuItems.VIEW_IN_LEADERBOARDS
+ yield ChatterMenuItems.VIEW_REPLAYS
+
+ def friend_actions(self, player, chatter, cc, is_me):
+ if is_me:
+ return
+ id_ = -1 if player is None else player.id
+ name = chatter.name
+ if self._me.relations.model.is_friend(id_, name):
+ yield ChatterMenuItems.REMOVE_FRIEND
+ elif self._me.relations.model.is_foe(id_, name):
+ yield ChatterMenuItems.REMOVE_FOE
+ else:
+ yield ChatterMenuItems.ADD_FRIEND
+ yield ChatterMenuItems.ADD_FOE
+
+ def ignore_actions(self, player, chatter, cc, is_me):
+ if is_me:
+ return
+ id_ = -1 if player is None else player.id
+ name = chatter.name
+ if self._me.relations.model.is_chatterbox(id_, name):
+ yield ChatterMenuItems.REMOVE_CHATTERBOX
+ else:
+ if not cc.is_mod() and not chatter.is_base_channel_mod():
+ yield ChatterMenuItems.ADD_CHATTERBOX
+
+ def party_actions(self, player, is_me):
+ if is_me:
+ return
+ if player is None:
+ return
+ else:
+ if player.id in self._client_window.games.party.memberIds:
+ if (
+ self._me.player.id
+ == self._client_window.games.party.owner_id
+ ):
+ yield ChatterMenuItems.KICK_FROM_PARTY
+ elif player.currentGame is not None:
+ return
+ else:
+ yield ChatterMenuItems.INVITE_TO_PARTY
+
+ def get_context_menu(self, data, point):
+ return self.menu(data.cc)
+
+ def menu(self, cc):
+ menu = QMenu(self._parent_widget)
+
+ def add_entry(item):
+ action = QAction(item.value, menu)
+ action.triggered.connect(self.handler(cc, item))
+ menu.addAction(action)
+
+ first = True
+ for category in self.actions(cc):
+ if not category:
+ continue
+ if not first:
+ menu.addSeparator()
+ for item in category:
+ add_entry(item)
+ first = False
+ return menu
+
+ def handler(self, cc, kind):
+ chatter = cc.chatter
+ player = chatter.player
+ game = None if player is None else player.currentGame
+ return lambda: self._handle_action(chatter, player, game, kind)
+
+ def _handle_action(self, chatter, player, game, kind):
+ Items = ChatterMenuItems
+ if kind == Items.COPY_USERNAME:
+ self._copy_username(chatter)
+ elif kind == Items.SEND_ORCS:
+ self._power_tools.actions.send_the_orcs(chatter.name)
+ elif kind == Items.CLOSE_GAME:
+ self._power_tools.view.close_game_dialog.show(chatter.name)
+ elif kind == Items.KICK_PLAYER:
+ self._power_tools.view.kick_dialog(chatter.name)
+ elif kind == Items.SELECT_AVATAR:
+ self._avatar_widget_builder().show()
+ elif kind in [
+ Items.ADD_FRIEND, Items.ADD_FOE, Items.REMOVE_FRIEND,
+ Items.REMOVE_FOE,
+ ]:
+ self._handle_friends(chatter, player, kind)
+ elif kind in [Items.ADD_CHATTERBOX, Items.REMOVE_CHATTERBOX]:
+ self._handle_chatterboxes(chatter, player, kind)
+ elif kind == Items.VIEW_ALIASES:
+ self._view_aliases(chatter)
+ elif kind == Items.VIEW_REPLAYS:
+ self._client_window.view_replays(player.login)
+ elif kind == Items.VIEW_IN_LEADERBOARDS:
+ self._client_window.view_in_leaderboards(player)
+ elif kind in [Items.JOIN_GAME, Items.VIEW_LIVEREPLAY]:
+ self._game_runner.run_game_with_url(game, player.id)
+ elif kind == Items.INVITE_TO_PARTY:
+ self._client_window.invite_to_party(player.id)
+ elif kind == Items.KICK_FROM_PARTY:
+ self._client_window.games.kickPlayerFromParty(player.id)
+
+ def _copy_username(self, chatter):
+ QApplication.clipboard().setText(chatter.name)
+
+ def _handle_friends(self, chatter, player, kind):
+ ctl = self._me.relations.controller
+ ctl = ctl.faf if player is not None else ctl.irc
+ uid = player.id if player is not None else chatter.name
+
+ Items = ChatterMenuItems
+ if kind == Items.ADD_FRIEND:
+ ctl.friends.add(uid)
+ elif kind == Items.REMOVE_FRIEND:
+ ctl.friends.remove(uid)
+ if kind == Items.ADD_FOE:
+ ctl.foes.add(uid)
+ elif kind == Items.REMOVE_FOE:
+ ctl.foes.remove(uid)
+
+ def _handle_chatterboxes(self, chatter, player, kind):
+ ctl = self._me.relations.controller
+ ctl = ctl.faf if player is not None else ctl.irc
+ uid = player.id if player is not None else chatter.name
+
+ Items = ChatterMenuItems
+ if kind == Items.ADD_CHATTERBOX:
+ ctl.chatterboxes.add(uid)
+ elif kind == Items.REMOVE_CHATTERBOX:
+ ctl.chatterboxes.remove(uid)
+
+ def _view_aliases(self, chatter):
+ self._alias_viewer.view_aliases(chatter.name)
diff --git a/src/chat/chatter_model.py b/src/chat/chatter_model.py
new file mode 100644
index 000000000..88262a380
--- /dev/null
+++ b/src/chat/chatter_model.py
@@ -0,0 +1,617 @@
+from enum import Enum
+from enum import IntEnum
+from typing import Any
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QRect
+from PyQt6.QtCore import QSortFilterProxyModel
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtGui import QColor
+from PyQt6.QtGui import QIcon
+
+import util
+from chat.chatter_model_item import ChatterModelItem
+from chat.chatterlistview import ChatterListView
+from chat.gameinfo import SensitiveMapInfoChecker
+from fa import maps
+from model.game import GameState
+from model.rating import RatingType
+from util.qt_list_model import QtListModel
+
+
+class ChatterModel(QtListModel):
+ def __init__(self, channel, item_builder):
+ QtListModel.__init__(self, item_builder)
+ self._channel = channel
+
+ if self._channel is not None:
+ self._channel.added_chatter.connect(self.add_chatter)
+ self._channel.removed_chatter.connect(self.remove_chatter)
+
+ for chatter in self._channel.chatters:
+ self.add_chatter(chatter)
+
+ @classmethod
+ def build(cls, channel, **kwargs):
+ builder = ChatterModelItem.builder(**kwargs)
+ return cls(channel, builder)
+
+ def add_chatter(self, chatter):
+ self._add_item(chatter, chatter.id_key)
+
+ def remove_chatter(self, chatter):
+ self._remove_item(chatter.id_key)
+
+ def clear_chatters(self):
+ self._clear_items()
+
+ def invalidate_items(self):
+ start = self.index(0)
+ end = self.index(len(self._itemlist) - 1)
+ self.dataChanged.emit(start, end)
+
+
+class ChatterRank(IntEnum):
+ FRIEND_ON_TOP = -1
+ ELEVATED = 0
+ FRIEND = 1
+ CLANNIE = 2
+ USER = 3
+ NONPLAYER = 4
+ CHATTERBOX = 5
+ FOE = 6
+
+
+class ChatterSortFilterModel(QSortFilterProxyModel):
+ def __init__(self, model, me, user_relations, chat_config):
+ QSortFilterProxyModel.__init__(self)
+ self._me = me
+ self._user_relations = user_relations
+ self._chat_config = chat_config
+ self._chat_config.updated.connect(self._check_sort_changed)
+ self.setSourceModel(model)
+ self.sort(0)
+
+ @classmethod
+ def build(cls, model, me, user_relations, chat_config, **kwargs):
+ return cls(model, me, user_relations, chat_config)
+
+ def lessThan(self, leftIndex, rightIndex):
+ source = self.sourceModel()
+ left = source.data(leftIndex, Qt.ItemDataRole.DisplayRole)
+ right = source.data(rightIndex, Qt.ItemDataRole.DisplayRole)
+
+ comp_list = [self._lt_me, self._lt_rank, self._lt_alphabetical]
+ for lt in comp_list:
+ if lt(left, right):
+ return True
+ elif lt(right, left):
+ return False
+ return False
+
+ def _lt_me(self, left, right):
+ if self._me.login is None:
+ return False
+ return (
+ left.chatter.name == self._me.login
+ and right.chatter.name != self._me.login
+ )
+
+ def _lt_rank(self, left, right):
+ left_rank = self._get_user_rank(left)
+ right_rank = self._get_user_rank(right)
+ return left_rank < right_rank
+
+ def _lt_alphabetical(self, left, right):
+ return left.chatter.name.lower() < right.chatter.name.lower()
+
+ def _get_user_rank(self, item):
+ pid = item.player.id if item.player is not None else None
+ name = item.chatter.name
+ is_friend = self._user_relations.is_friend(pid, name)
+ if self._chat_config.friendsontop and is_friend:
+ return ChatterRank.FRIEND_ON_TOP
+ if item.cc.is_mod():
+ return ChatterRank.ELEVATED
+ if is_friend:
+ return ChatterRank.FRIEND
+ if self._me.is_clannie(pid):
+ return ChatterRank.CLANNIE
+ if self._user_relations.is_foe(pid, name):
+ return ChatterRank.FOE
+ if self._user_relations.is_chatterbox(pid, name):
+ return ChatterRank.CHATTERBOX
+ if item.player is not None:
+ return ChatterRank.USER
+ return ChatterRank.NONPLAYER
+
+ def filterAcceptsRow(self, row: int, parent: QtCore.QModelIndex) -> bool:
+ source = self.sourceModel()
+ index = source.index(row, 0, parent)
+ if not index.isValid():
+ return False
+ data = source.data(index, Qt.ItemDataRole.DisplayRole)
+ displayed_name = ChatterFormat.chatter_name(data.chatter)
+ return self.filterRegularExpression().match(displayed_name).hasMatch()
+
+ def _check_sort_changed(self, option):
+ if option == "friendsontop":
+ self.invalidate()
+
+ def invalidate_items(self):
+ self.sourceModel().invalidate_items()
+
+
+# TODO - place in some separate file?
+class ChatterFormat:
+ @classmethod
+ def name(cls, chatter, clan):
+ if clan is not None:
+ return "[{}]{}".format(clan, chatter)
+ else:
+ return chatter
+
+ @classmethod
+ def chatter_name(cls, chatter):
+ clan = None if chatter.player is None else chatter.player.clan
+ return cls.name(chatter.name, clan)
+
+
+class ChatterItemFormatter:
+ def __init__(self, avatars, player_colors, info_hider):
+ self._avatars = avatars
+ self._player_colors = player_colors
+ self._info_hider = info_hider
+
+ @classmethod
+ def build(cls, avatar_dler, player_colors, **kwargs):
+ info_hider = SensitiveMapInfoChecker.build(**kwargs)
+ return cls(avatar_dler, player_colors, info_hider)
+
+ def map_icon(self, data):
+ game = data.game
+ if game is None or game.closed():
+ should_hide_info = False
+ else:
+ should_hide_info = self._info_hider.has_sensitive_data(game)
+ if should_hide_info:
+ return None
+
+ name = data.map_name()
+ return None if name is None else maps.preview(name)
+
+ def chatter_name(self, data):
+ return ChatterFormat.chatter_name(data.chatter)
+
+ def chatter_color(self, data):
+ pid = -1 if data.player is None else data.player.id
+ colors = self._player_colors
+ cc = data.cc
+ if cc.is_mod():
+ return colors.get_mod_color(pid, data.chatter.name)
+ else:
+ return colors.get_user_color(pid, data.chatter.name)
+
+ def chatter_status(self, data):
+ game = data.game
+ if game is None or game.closed():
+ return "none"
+ if game.state == GameState.OPEN:
+ if game.host == data.chatter.name:
+ return "host"
+ return "lobby"
+ if game.state == GameState.PLAYING:
+ if game.has_live_replay:
+ return "playing"
+ return "playing5"
+ return "unknown"
+
+ def chatter_rank(self, data):
+ if data.player is None:
+ return "civilian"
+ league = data.player.league
+ if league is None or "league" not in league:
+ return "newplayer"
+ return league["league"]
+
+ def chatter_avatar_icon(self, data):
+ avatar_url = data.avatar_url()
+ if avatar_url is None:
+ return None
+ if avatar_url not in self._avatars.avatars:
+ return
+ return QIcon(self._avatars.avatars[avatar_url])
+
+ def chatter_country(self, data):
+ if data.player is None:
+ return None
+ country = data.player.country
+ if country is None or country == '':
+ return '__'
+ return country
+
+ def rank_tooltip(self, data):
+ if data.player is None:
+ return "IRC User"
+ player = data.player
+ # chr(0xB1) = +-
+ formatting = (
+ ("{} Rating: {} ({} Games) [{}\xb1{}]\n") * len(player.ratings)
+ )
+ tooltip_info_list = []
+ for rating_type in player.ratings.keys():
+ if rating_type == RatingType.LADDER.value:
+ rating_name = "Ladder"
+ else:
+ rating_name = (
+ rating_type.replace("_", " ").capitalize()
+ )
+ tooltip_info_list.extend([
+ rating_name,
+ player.rating_estimate(rating_type),
+ player.game_count(rating_type),
+ player.rating_mean(rating_type),
+ player.rating_deviation(rating_type),
+ ])
+
+ tooltip_str = formatting.format(*tooltip_info_list)
+ league = player.league
+ if league is not None and "division" in league:
+ tooltip_str = "Division : {}\n{}".format(
+ league["division"],
+ tooltip_str,
+ )
+ return tooltip_str.strip()
+
+ def status_tooltip(self, data):
+ # Status tooltip handling
+ game = data.game
+ if game is None or game.closed():
+ return "Idle"
+
+ if self._info_hider.has_sensitive_data(game):
+ game_map = "[delayed reveal]"
+ game_title = "[delayed reveal]"
+ else:
+ game_map = game.mapdisplayname
+ game_title = game.title
+
+ private_str = " (private)" if game.password_protected else ""
+ if game.state == GameState.PLAYING and not game.has_live_replay:
+ delay_str = " - LIVE DELAY (5 Min)"
+ else:
+ delay_str = ""
+
+ head_str = ""
+ if game.state == GameState.OPEN:
+ if game.host == data.player.login:
+ head_str = "Hosting{private} game"
+ else:
+ head_str = "In{private} Lobby (host {host})"
+ elif game.state == GameState.PLAYING:
+ head_str = "Playing{delay}"
+ header = head_str.format(
+ private=private_str, delay=delay_str,
+ host=game.host,
+ )
+
+ formatting = (
+ "{} "
+ "title: {} "
+ "mod: {} "
+ "map: {} "
+ "players: {} / {} "
+ "id: {}"
+ )
+
+ game_str = formatting.format(
+ header, game_title, game.featured_mod, game_map,
+ game.num_players - len(game.observers), game.max_players, game.uid,
+ )
+ return game_str
+
+ def avatar_tooltip(self, data):
+ try:
+ return data.player.avatar["tooltip"]
+ except (TypeError, AttributeError, KeyError):
+ return None
+
+ def map_tooltip(self, data):
+ if data.game is None:
+ return None
+ if self._info_hider.has_sensitive_data(data.game):
+ return "[delayed reveal]"
+ return data.game.mapdisplayname
+
+ def country_tooltip(self, data):
+ return self.chatter_country(data)
+
+ def nick_tooltip(self, data):
+ return self.country_tooltip(data)
+
+
+class ChatterItemDelegate(QtWidgets.QStyledItemDelegate):
+ def __init__(self, layout, formatter):
+ QtWidgets.QStyledItemDelegate.__init__(self)
+ self.layout = layout
+ self._formatter = formatter
+
+ @classmethod
+ def build(cls, layout, **kwargs):
+ formatter = ChatterItemFormatter.build(**kwargs)
+ return cls(layout, formatter)
+
+ def update_width(self, size):
+ current_size = self.layout.size
+ if size.width() != current_size.width():
+ current_size.setWidth(size.width())
+ self.layout.size = current_size
+
+ def paint(self, painter, option, index):
+ painter.save()
+
+ data = index.data()
+
+ self._draw_clear_option(painter, option)
+ self._handle_highlight(painter, option)
+
+ painter.translate(option.rect.left(), option.rect.top())
+
+ Elems = ChatterLayoutElements
+ draw = {
+ Elems.NICK: self._draw_nick,
+ Elems.STATUS: self._draw_status,
+ Elems.MAP: self._draw_map,
+ Elems.RANK: self._draw_rank,
+ Elems.AVATAR: self._draw_avatar,
+ Elems.COUNTRY: self._draw_country,
+ }
+ for item in self.layout.visible_items():
+ draw[item](painter, data)
+
+ painter.restore()
+
+ def _draw_clear_option(self, painter, option):
+ option.icon = QtGui.QIcon()
+ option.text = ""
+ option.widget.style().drawControl(
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
+ )
+
+ def _handle_highlight(
+ self,
+ painter: QtGui.QPainter,
+ option: QtWidgets.QStyleOptionViewItem,
+ ) -> None:
+ if option.state & QtWidgets.QStyle.StateFlag.State_Selected:
+ painter.fillRect(option.rect, option.palette.highlight)
+
+ def _draw_nick(self, painter: QtGui.QPainter, data: str) -> None:
+ text = self._formatter.chatter_name(data)
+ color = QColor(self._formatter.chatter_color(data))
+ clip = QRect(self.layout.sizes[ChatterLayoutElements.NICK])
+ text = self._get_elided_text(painter, text, clip.width())
+
+ painter.save()
+ pen = painter.pen()
+ pen.setColor(color)
+ painter.setPen(pen)
+
+ painter.drawText(clip, Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter, text)
+
+ painter.restore()
+
+ def _get_elided_text(self, painter: QtGui.QPainter, text: str, width: int) -> str:
+ metrics = painter.fontMetrics()
+ return metrics.elidedText(text, Qt.TextElideMode.ElideRight, width)
+
+ def _draw_status(self, painter, data):
+ status = self._formatter.chatter_status(data)
+ icon = util.THEME.icon("chat/status/{}.png".format(status))
+ self._draw_icon(painter, icon, ChatterLayoutElements.STATUS)
+
+ # TODO - handle optionality of maps
+ def _draw_map(self, painter, data):
+ icon = self._formatter.map_icon(data)
+ if not icon:
+ return
+ self._draw_icon(painter, icon, ChatterLayoutElements.MAP)
+
+ def _draw_rank(self, painter, data):
+ rank = self._formatter.chatter_rank(data)
+ icon = util.THEME.icon("chat/rank/{}.png".format(rank))
+ self._draw_icon(painter, icon, ChatterLayoutElements.RANK)
+
+ def _draw_avatar(self, painter, data):
+ icon = self._formatter.chatter_avatar_icon(data)
+ if not icon:
+ return
+ self._draw_icon(painter, icon, ChatterLayoutElements.AVATAR)
+
+ def _draw_country(self, painter, data):
+ country = self._formatter.chatter_country(data)
+ if country is None:
+ return
+ icon = util.THEME.icon("chat/countries/{}.png".format(country.lower()))
+ self._draw_icon(painter, icon, ChatterLayoutElements.COUNTRY)
+
+ def _draw_icon(self, painter, icon, element):
+ rect = self.layout.sizes[element]
+ icon.paint(painter, rect, QtCore.Qt.AlignmentFlag.AlignCenter)
+
+ def sizeHint(self, option, index):
+ return self.layout.size
+
+ def get_tooltip(self, data, elem):
+ if elem is None:
+ return None
+ return self._tooltip(data, elem)
+
+ def _tooltip(self, data, item):
+ if item == ChatterLayoutElements.RANK:
+ return self._formatter.rank_tooltip(data)
+ elif item == ChatterLayoutElements.STATUS:
+ return self._formatter.status_tooltip(data)
+ elif item == ChatterLayoutElements.AVATAR:
+ return self._formatter.avatar_tooltip(data)
+ elif item == ChatterLayoutElements.MAP:
+ return self._formatter.map_tooltip(data)
+ elif item == ChatterLayoutElements.COUNTRY:
+ return self._formatter.country_tooltip(data)
+ elif item == ChatterLayoutElements.NICK:
+ return self._formatter.nick_tooltip(data)
+
+
+class ChatterLayoutElements(Enum):
+ RANK = "rankBox"
+ STATUS = "statusBox"
+ AVATAR = "avatarBox"
+ MAP = "mapBox"
+ COUNTRY = "countryBox"
+ NICK = "nickBox"
+
+
+class ChatterLayout(QObject):
+ """Provides layout info for delegate using Qt widget layouts."""
+ LAYOUT_FILE = "chat/chatter.ui"
+
+ def __init__(self, theme, chat_config):
+ QObject.__init__(self)
+ self._theme = theme
+ self._chat_config = chat_config
+ self.sizes = {}
+ self.load_layout()
+ self._chat_config.updated.connect(self._at_chat_config_updated)
+ self._set_visibility()
+
+ @classmethod
+ def build(cls, theme, chat_config, **kwargs):
+ return cls(theme, chat_config)
+
+ def load_layout(self):
+ formc, basec = self._theme.loadUiType(self.LAYOUT_FILE)
+ self._form = formc()
+ self._base = basec()
+ self._form.setupUi(self._base)
+ self._size = self._base.size()
+
+ def _at_chat_config_updated(self, setting):
+ if setting == "hide_chatter_items":
+ self._set_visibility()
+
+ def _set_visibility(self):
+ for item in ChatterLayoutElements:
+ self._set_visible(item)
+ self._update_layout()
+
+ def _set_visible(self, item):
+ getattr(self._form, item.value).setVisible(self.is_visible(item))
+
+ def is_visible(self, item):
+ return item not in self._chat_config.hide_chatter_items
+
+ def visible_items(self):
+ return [i for i in ChatterLayoutElements if self.is_visible(i)]
+
+ @property
+ def size(self):
+ return self._base.size()
+
+ @size.setter
+ def size(self, size):
+ self._size = size
+ self._update_layout()
+
+ def element_at_point(self, point):
+ for elem in ChatterLayoutElements:
+ if self.sizes[elem].contains(point) and self.is_visible(elem):
+ return elem
+ return None
+
+ def _update_layout(self):
+ self._base.resize(self._size)
+ self._force_layout_recalculation()
+ for elem in ChatterLayoutElements:
+ self.sizes[elem] = self._get_widget_position(elem.value)
+
+ def _force_layout_recalculation(self):
+ layout = self._base.layout()
+ layout.update()
+ layout.activate()
+
+ def _get_widget_position(self, name):
+ widget = getattr(self._form, name)
+ size = widget.rect()
+ top_left = widget.mapTo(self._base, size.topLeft())
+ size.moveTopLeft(top_left)
+ return size
+
+
+class ChatterEventFilter(QObject):
+ double_clicked = pyqtSignal(object, object)
+
+ def __init__(self, chatter_layout, tooltip_handler, menu_handler):
+ QObject.__init__(self)
+ self._chatter_layout = chatter_layout
+ self._tooltip_handler = tooltip_handler
+ self._menu_handler = menu_handler
+
+ @classmethod
+ def build(cls, chatter_layout, tooltip_handler, menu_handler, **kwargs):
+ return cls(chatter_layout, tooltip_handler, menu_handler)
+
+ def eventFilter(self, obj, event):
+ if event.type() == QtCore.QEvent.Type.ToolTip:
+ return self._handle_tooltip(obj, event)
+ elif event.type() == QtCore.QEvent.Type.MouseButtonRelease:
+ if event.button() == QtCore.Qt.MouseButton.RightButton:
+ return self._handle_context_menu(obj, event)
+ elif event.type() == QtCore.QEvent.Type.MouseButtonDblClick:
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
+ return self._handle_double_click(obj, event)
+ return super().eventFilter(obj, event)
+
+ def _get_data_and_elem(
+ self,
+ widget: QtWidgets.QWidget,
+ event: QtGui.QMouseEvent,
+ ) -> tuple[Any, ChatterLayoutElements | None]:
+ view: ChatterListView = widget.parent()
+ idx = view.indexAt(event.pos())
+ if not idx.isValid():
+ return None, None
+ item_rect = view.visualRect(idx)
+ point = event.pos() - item_rect.topLeft()
+ elem = self._chatter_layout.element_at_point(point)
+ return idx.data(), elem
+
+ def _handle_tooltip(self, widget: QtWidgets.QWidget, event: QtGui.QMouseEvent) -> bool:
+ data, elem = self._get_data_and_elem(widget, event)
+ if data is None:
+ return False
+ tooltip_text = self._tooltip_handler.get_tooltip(data, elem)
+ if tooltip_text is None:
+ return False
+
+ QtWidgets.QToolTip.showText(event.globalPos(), tooltip_text, widget)
+ return True
+
+ def _handle_context_menu(self, widget, event):
+ data, elem = self._get_data_and_elem(widget, event)
+ if data is None:
+ return False
+
+ menu = self._menu_handler.get_context_menu(data, elem)
+ menu.popup(QtGui.QCursor.pos())
+ return True
+
+ def _handle_double_click(self, widget, event):
+ data, elem = self._get_data_and_elem(widget, event)
+ if data is None:
+ return False
+ self.double_clicked.emit(data, elem)
+ return True
diff --git a/src/chat/chatter_model_item.py b/src/chat/chatter_model_item.py
new file mode 100644
index 000000000..1cbd44ddf
--- /dev/null
+++ b/src/chat/chatter_model_item.py
@@ -0,0 +1,139 @@
+from urllib import parse
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+from downloadManager import DownloadRequest
+from fa import maps
+
+
+class ChatterModelItem(QObject):
+ """
+ UI representation of a chatter.
+ """
+ updated = pyqtSignal(object)
+
+ def __init__(self, cc, map_preview_dler, avatar_dler, relation_trackers):
+ QObject.__init__(self)
+
+ self._preview_dler = map_preview_dler
+ self._avatar_dler = avatar_dler
+ self._relation = relation_trackers
+
+ self._player = None
+ self._player_rel = None
+ self._game = None
+ self.cc = cc
+
+ self.cc.updated.connect(self._updated)
+ self.chatter.updated.connect(self._updated)
+ self.chatter.newPlayer.connect(self._set_player)
+ self._chatter_rel = self._relation.chatters[self.chatter.id_key]
+ self._chatter_rel.updated.connect(self._updated)
+
+ self._map_request = DownloadRequest()
+ self._map_request.done.connect(self._updated)
+ self._avatar_request = DownloadRequest()
+ self._avatar_request.done.connect(self._updated)
+
+ self.player = self.chatter.player
+
+ @classmethod
+ def builder(
+ cls, map_preview_dler, avatar_dler, relation_trackers, **kwargs
+ ):
+ def make(cc):
+ return cls(cc, map_preview_dler, avatar_dler, relation_trackers)
+ return make
+
+ def _updated(self):
+ self.updated.emit(self)
+
+ @property
+ def chatter(self):
+ return self.cc.chatter
+
+ def _set_player(self, chatter, new_player, old_player):
+ self.player = new_player
+ self._updated()
+
+ @property
+ def player(self):
+ return self._player
+
+ @player.setter
+ def player(self, value):
+ if self._player is not None:
+ self.game = None
+ self._player.updated.disconnect(self._at_player_updated)
+ self._player.newCurrentGame.disconnect(self._set_game)
+ self._player_rel.updated.disconnect(self._updated)
+ self._player_rel = None
+
+ self._player = value
+
+ if self._player is not None:
+ self._player.updated.connect(self._at_player_updated)
+ self._player.newCurrentGame.connect(self._set_game)
+ self._player_rel = self._relation.players[self._player.id_key]
+ self._player_rel.updated.connect(self._updated)
+ self.game = self._player.currentGame
+ self._download_avatar_if_needed()
+
+ def _at_player_updated(self):
+ self._download_avatar_if_needed()
+ self._updated()
+
+ def _set_game(self, player, game):
+ self.game = game
+ self._updated()
+
+ @property
+ def game(self):
+ return self._game
+
+ @game.setter
+ def game(self, value):
+ if self._game is not None:
+ self._game.updated.disconnect(self._at_game_updated)
+ self._game.liveReplayAvailable.disconnect(self._updated)
+
+ self._game = value
+
+ if self._game is not None:
+ self._game.updated.connect(self._at_game_updated)
+ self._game.liveReplayAvailable.connect(self._updated)
+ self._download_map_preview_if_needed()
+
+ def _at_game_updated(self, new, old):
+ if new.mapname != old.mapname:
+ self._download_map_preview_if_needed()
+ self._updated()
+
+ def _download_map_preview_if_needed(self):
+ name = self.map_name()
+ if name is None:
+ return
+ if not maps.preview(name):
+ self._preview_dler.download_preview(name, self._map_request)
+
+ def map_name(self):
+ game = self.game
+ if game is None or game.closed() or game.mapname is None:
+ return None
+ return self.game.mapname.lower()
+
+ def _download_avatar_if_needed(self):
+ avatar_url = self.avatar_url()
+ if avatar_url is None:
+ return
+ if avatar_url in self._avatar_dler.avatars:
+ return
+ self._avatar_dler.download_avatar(avatar_url, self._avatar_request)
+
+ def avatar_url(self):
+ try:
+ url = self.player.avatar["url"]
+ except (TypeError, AttributeError, KeyError):
+ return None
+ return parse.unquote(url)
diff --git a/src/chat/chatterlistview.py b/src/chat/chatterlistview.py
new file mode 100644
index 000000000..02723356d
--- /dev/null
+++ b/src/chat/chatterlistview.py
@@ -0,0 +1,17 @@
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtWidgets import QListView
+
+
+class ChatterListView(QListView):
+ """
+ Used to let chatter list delegate fit its width to list view's width.
+ """
+ resized = pyqtSignal(object)
+
+ def __init__(self, *args, **kwargs):
+ QListView.__init__(self, *args, **kwargs)
+
+ def resizeEvent(self, event):
+ QListView.resizeEvent(self, event)
+ self.resized.emit(self.maximumViewportSize())
+ self.updateGeometries()
diff --git a/src/chat/colors.py b/src/chat/colors.py
deleted file mode 100644
index a4d023e17..000000000
--- a/src/chat/colors.py
+++ /dev/null
@@ -1,11 +0,0 @@
-# This is potentially overriden by theming logic, sensible defaults provided
-
-OPERATOR_COLORS = {"~": "#FFFFFF",
- "&": "#FFFFFF",
- "@": "#FFFFFF",
- "%": "#FFFFFF",
- "+": "#FFFFFF"}
-
-CHAT_COLORS = {
- "default": "grey"
-}
diff --git a/src/chat/gameinfo.py b/src/chat/gameinfo.py
index ddef5edf0..857525c64 100644
--- a/src/chat/gameinfo.py
+++ b/src/chat/gameinfo.py
@@ -1,4 +1,5 @@
-from PyQt5.QtCore import QObject
+from PyQt6.QtCore import QObject
+
from model.game import GameState
@@ -7,6 +8,10 @@ def __init__(self, me):
QObject.__init__(self)
self._me = me
+ @classmethod
+ def build(cls, me, **kwargs):
+ return cls(me)
+
def has_sensitive_data(self, game):
if game is None or game.closed():
return False
diff --git a/src/chat/ircconnection.py b/src/chat/ircconnection.py
new file mode 100644
index 000000000..9ce80644f
--- /dev/null
+++ b/src/chat/ircconnection.py
@@ -0,0 +1,451 @@
+from __future__ import annotations
+
+import logging
+import re
+import sys
+
+from irc.client import Event
+from irc.client import IRCError
+from irc.client import ServerConnection
+from irc.client import SimpleIRCClient
+from irc.client import is_channel
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+import config
+import util
+from api.ApiAccessors import UserApiAccessor
+from chat.socketadapter import ConnectionFactory
+from chat.socketadapter import ReactorForSocketAdapter
+from model.chat.channel import ChannelID
+from model.chat.channel import ChannelType
+from model.chat.chatline import ChatLine
+from model.chat.chatline import ChatLineType
+
+logger = logging.getLogger(__name__)
+IRC_ELEVATION = '%@~%+&'
+
+
+def user2name(user):
+ return (user.split('!')[0]).strip(IRC_ELEVATION)
+
+
+def parse_irc_source(src):
+ """
+ :param src: IRC source argument
+ :return: (username, id, elevation, hostname)
+ """
+ try:
+ username, tail = src.split('!')
+ except ValueError:
+ username = src.split('!')[0]
+ tail = None
+
+ if username[0] in IRC_ELEVATION:
+ elevation, username = username[0], username[1:]
+ else:
+ elevation = ""
+
+ if tail is not None:
+ id, hostname = tail.split('@')
+ try:
+ id = int(id)
+ except ValueError:
+ id = -1
+ else:
+ id = -1
+ hostname = None
+
+ return username, id, elevation, hostname
+
+
+class ChatterInfo:
+ def __init__(self, name, hostname, elevation):
+ self.name = name
+ self.hostname = hostname
+ self.elevation = elevation
+
+
+class IrcSignals(QObject):
+ new_line = pyqtSignal(object, object, object)
+ new_server_message = pyqtSignal(str)
+ new_channel_chatters = pyqtSignal(object, list)
+ channel_chatter_joined = pyqtSignal(object, object)
+ channel_chatter_left = pyqtSignal(object, object)
+ chatter_quit = pyqtSignal(object, str)
+ quit_channel = pyqtSignal(object)
+ chatter_renamed = pyqtSignal(str, str)
+ new_chatter_elevation = pyqtSignal(object, object, str, str)
+ new_channel_topic = pyqtSignal(object, str)
+ connected = pyqtSignal()
+ disconnected = pyqtSignal()
+
+ def __init__(self):
+ QObject.__init__(self)
+
+
+class IrcConnection(IrcSignals, SimpleIRCClient):
+ reactor_class = ReactorForSocketAdapter
+
+ token_received = pyqtSignal(str)
+
+ def __init__(self, host: int, port: int) -> None:
+ IrcSignals.__init__(self)
+ SimpleIRCClient.__init__(self)
+
+ 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
+ self._nick = None
+
+ self._nickserv_registered = False
+ self._connected = False
+
+ @classmethod
+ def build(cls, settings: config.Settings, **kwargs) -> IrcConnection:
+ port = settings.get("chat/port", 443, int)
+ host = settings.get("chat/host", "chat." + config.defaults["host"], str)
+ return cls(host, port)
+
+ def setPortFromConfig(self) -> None:
+ self.port = config.Settings.get("chat/port", type=int)
+
+ def setHostFromConfig(self):
+ self.host = config.Settings.get('chat/host', type=str)
+
+ def disconnect_(self) -> None:
+ self.connection.disconnect()
+
+ def set_nick_and_username(self, nick: str, username: str) -> None:
+ self._nick = nick
+ self._username = username
+
+ def begin_connection_process(self) -> None:
+ self.api_accessor.get_by_endpoint("/irc/ergochat/token", self.handle_irc_token)
+
+ 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}")
+
+ def connect_(self, nick: str, username: str, password: str) -> bool:
+ logger.info(f"Connecting to IRC at: {self.host}:{self.port}")
+
+ self._nick = nick
+ self._username = username
+ self._password = password
+
+ try:
+ self.connect(
+ self.host,
+ self.port,
+ nick,
+ connect_factory=self.connect_factory,
+ ircname=nick,
+ sasl_login=username,
+ password=password,
+ )
+ self.connection.socket.message_received.connect(self.reactor.process_once)
+ return True
+ except IRCError:
+ logger.debug("Unable to connect to IRC server.")
+ logger.error("IRC Exception", exc_info=sys.exc_info())
+ return False
+
+ def is_connected(self):
+ return self.connection.is_connected()
+
+ def _only_if_connected(fn):
+ def _if_connected(self, *args, **kwargs):
+ if not self.connection.is_connected():
+ return False
+ fn(self, *args, **kwargs)
+ return True
+ return _if_connected
+
+ @_only_if_connected
+ def set_topic(self, channel, topic):
+ self.connection.topic(channel, topic)
+
+ @_only_if_connected
+ def send_message(self, target, text):
+ self.connection.privmsg(target, text)
+
+ @_only_if_connected
+ def send_action(self, target, text):
+ self.connection.action(target, text)
+
+ @_only_if_connected
+ def join(self, channel):
+ self.connection.join(channel)
+
+ @_only_if_connected
+ def part(self, channel, reason=""):
+ self.connection.part([channel], reason)
+
+ @property
+ def nickname(self):
+ return self._nick
+
+ def _log_event(self, e):
+ text = ' | '.join(e.arguments)
+ self.new_server_message.emit(
+ "[{}: {}->{}] {}".format(e.type, e.source, e.target, text),
+ )
+
+ def _log_client_message(self, text):
+ self.new_server_message.emit(text)
+
+ def on_welcome(self, c, e):
+ self._log_event(e)
+ if not self._connected:
+ self._connected = True
+ self.on_connected()
+
+ def _send_nickserv_creds(self, fmt):
+ self._log_client_message(
+ fmt.format(
+ nick=self._nick,
+ password='[password_hash]',
+ ),
+ )
+
+ msg = fmt.format(
+ nick=self._nick,
+ password=util.md5text(self._password),
+ )
+ self.connection.privmsg('NickServ', msg)
+
+ def _nickserv_identify(self):
+ self._send_nickserv_creds('identify {nick} {password}')
+
+ def _nickserv_register(self):
+ if self._nickserv_registered:
+ return
+ self._send_nickserv_creds(
+ 'register {password} {nick}@users.faforever.com',
+ )
+ self._nickserv_registered = True
+
+ def _nickserv_recover_if_needed(self):
+ if self.connection.get_nickname() != self._nick:
+ self._send_nickserv_creds('recover {nick} {password}')
+
+ def on_connected(self):
+ self._nickserv_recover_if_needed()
+ self.connected.emit()
+
+ def on_version(self, c, e):
+ msg = "Forged Alliance Forever " + util.VERSION_STRING
+ self.connection.privmsg(e.source, msg)
+
+ def on_motd(self, c, e):
+ self._log_event(e)
+
+ def on_endofmotd(self, c, e):
+ self._log_event(e)
+ self.connection.whois(self._nick)
+ self._nickserv_identify()
+
+ def on_namreply(self, c, e):
+ channel = ChannelID(ChannelType.PUBLIC, e.arguments[1])
+ listing = e.arguments[2].split()
+
+ def userdata(data):
+ name = data.strip(IRC_ELEVATION)
+ elevation = data[0] if data[0] in IRC_ELEVATION else ""
+ hostname = ''
+ return ChatterInfo(name, hostname, elevation)
+
+ chatters = [userdata(user) for user in listing]
+ self.new_channel_chatters.emit(channel, chatters)
+
+ def on_whoisuser(self, c: ServerConnection, e: Event) -> None:
+ self._log_event(e)
+
+ def _event_to_chatter(self, e):
+ name, _id, elevation, hostname = parse_irc_source(e.source)
+ return ChatterInfo(name, hostname, elevation)
+
+ def on_join(self, c, e):
+ channel = ChannelID(ChannelType.PUBLIC, e.target)
+ chatter = self._event_to_chatter(e)
+ self.channel_chatter_joined.emit(channel, chatter)
+
+ def on_part(self, c, e):
+ channel = ChannelID(ChannelType.PUBLIC, e.target)
+ chatter = self._event_to_chatter(e)
+ self.channel_chatter_left.emit(channel, chatter)
+ if chatter.name == self._nick:
+ self.quit_channel.emit(channel)
+
+ def on_quit(self, c, e):
+ chatter = self._event_to_chatter(e)
+ self.chatter_quit.emit(chatter, e.arguments[0])
+
+ def on_nick(self, c, e):
+ oldnick = user2name(e.source)
+ newnick = e.target
+
+ self.chatter_renamed.emit(oldnick, newnick)
+ self._log_event(e)
+
+ def on_mode(self, c, e):
+ if len(e.arguments) < 2:
+ return
+
+ name, _, elevation, hostname = parse_irc_source(e.arguments[1])
+ chatter = ChatterInfo(name, hostname, elevation)
+ modes = e.arguments[0]
+ channel = ChannelID(ChannelType.PUBLIC, e.target)
+ added, removed = self._parse_elevation(modes)
+ self.new_chatter_elevation.emit(
+ channel, chatter, added, removed,
+ )
+
+ def _parse_elevation(self, modes):
+ add = re.compile(r".*\+([a-z]+)")
+ remove = re.compile(r".*\-([a-z]+)")
+ mode_to_elevation = {"o": "@", "q": "~", "v": "+"}
+
+ def get_elevations(expr):
+ match = re.search(expr, modes)
+ if not match:
+ return ""
+ match = match.group(1)
+ return ''.join(mode_to_elevation.get(c, '') for c in match)
+
+ return get_elevations(add), get_elevations(remove)
+
+ def on_umode(self, c, e):
+ self._log_event(e)
+
+ def on_notice(self, c, e):
+ self._log_event(e)
+
+ def on_topic(self, c, e):
+ channel = ChannelID(ChannelType.PUBLIC, e.target)
+ announcement = " ".join(e.arguments)
+ self.new_channel_topic.emit(channel, announcement)
+
+ def on_currenttopic(self, c, e):
+ channel = ChannelID(ChannelType.PUBLIC, e.arguments[0])
+ announcement = " ".join(e.arguments[1:])
+ self.new_channel_topic.emit(channel, announcement)
+
+ def on_topicinfo(self, c, e):
+ self._log_event(e)
+
+ def on_list(self, c, e):
+ self._log_event(e)
+
+ def on_bannedfromchan(self, c, e):
+ self._log_event(e)
+
+ def _emit_line(
+ self, chatter, target, channel_type, text, type_=ChatLineType.MESSAGE,
+ ):
+ if channel_type == ChannelType.PUBLIC:
+ channel_name = target
+ else:
+ channel_name = chatter.name
+ chid = ChannelID(channel_type, channel_name)
+ line = ChatLine(chatter.name, text, type_)
+ self.new_line.emit(chid, chatter, line)
+
+ def on_pubmsg(self, c, e):
+ chatter = self._event_to_chatter(e)
+ target = e.target
+ text = "\n".join(e.arguments)
+ self._emit_line(chatter, target, ChannelType.PUBLIC, text)
+
+ def on_privnotice(self, c, e):
+ if e.source == self.host:
+ self._log_event(e)
+ return
+
+ chatter = self._event_to_chatter(e)
+ notice = e.arguments[0]
+ if chatter.name.lower() == 'nickserv':
+ self._log_event(e)
+ self._handle_nickserv_message(notice)
+ return
+
+ text = "\n".join(e.arguments)
+ msg_target, text = self._parse_target_from_privnotice_message(text)
+ if msg_target is not None:
+ channel_type = ChannelType.PUBLIC
+ else:
+ channel_type = ChannelType.PRIVATE
+ self._emit_line(
+ chatter, msg_target, channel_type, text, ChatLineType.NOTICE,
+ )
+
+ # Parsing message to get target channel instead is non-standard. To limit
+ # abuse potential, we match the pattern used by bots as closely as
+ # possible, and mark the line as notice so views can display them
+ # differently.
+ def _parse_target_from_privnotice_message(self, text: str) -> tuple[str, str]:
+ if re.match(r'\[[^ ]+\] ', text) is None:
+ return None, text
+ prefix, rest = text.split(" ", 1)
+ prefix = prefix[1:-1]
+ target = prefix.strip("[]")
+ if not is_channel(target):
+ return None, text
+ return target, rest
+
+ def _handle_nickserv_message(self, notice):
+ if (
+ "registered under your account" in notice
+ or "You are already identified" in notice
+ ):
+ if not self._connected:
+ self._connected = True
+ self.on_connected()
+ elif "isn't registered" in notice:
+ self._nickserv_register()
+ elif "choose a different nick" in notice or "registered." in notice:
+ self._nickserv_identify()
+ elif "you are now recognized" in notice:
+ self._nickserv_recover_if_needed()
+ elif "RELEASE" in notice:
+ self.connection.privmsg('release {} {}')
+ elif "hold on" in notice or "You have regained control" in notice:
+ self.connection.nick(self._nick)
+
+ def on_disconnect(self, c: ServerConnection, e: Event) -> None:
+ self._connected = False
+ self.disconnected.emit()
+
+ def on_privmsg(self, c, e):
+ chatter = self._event_to_chatter(e)
+ text = "\n".join(e.arguments)
+ self._emit_line(chatter, None, ChannelType.PRIVATE, text)
+
+ def on_action(self, c: ServerConnection, e: Event) -> None:
+ chatter = self._event_to_chatter(e)
+ target = e.target
+ text = "\n".join(e.arguments)
+ if is_channel(target):
+ chtype = ChannelType.PUBLIC
+ else:
+ chtype = ChannelType.PRIVATE
+ self._emit_line(chatter, target, chtype, text, ChatLineType.ACTION)
+
+ def on_nosuchnick(self, c, e):
+ self._nickserv_register()
+
+ def on_default(self, c, e):
+ self._log_event(e)
+ if "Nickname is already in use." in "\n".join(e.arguments):
+ self.connection.nick(self._nick + "_")
+
+ def on_kick(self, c, e):
+ pass
diff --git a/src/chat/irclib.py b/src/chat/irclib.py
deleted file mode 100644
index 649e25f88..000000000
--- a/src/chat/irclib.py
+++ /dev/null
@@ -1,1599 +0,0 @@
-
-# Copyright (C) 1999--2002 Joel Rosdahl
-#
-# This library is free software; you can redistribute it and/or
-# modify it under the terms of the GNU Lesser General Public
-# License as published by the Free Software Foundation; either
-# version 2.1 of the License, or (at your option) any later version.
-#
-# This library is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
-# Lesser General Public License for more details.
-#
-# You should have received a copy of the GNU Lesser General Public
-# License along with this library; if not, write to the Free Software
-# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
-#
-# keltus
-#
-# $Id: irclib.py,v 1.47 2008/09/25 22:00:59 keltus Exp $
-
-""" irclib -- Internet Relay Chat (IRC) protocol client library.
-
-This library is intended to encapsulate the IRC protocol at a quite
-low level. It provides an event-driven IRC client framework. It has
-a fairly thorough support for the basic IRC protocol, CTCP, DCC chat,
-but DCC file transfers is not yet supported.
-
-In order to understand how to make an IRC client, I'm afraid you more
-or less must understand the IRC specifications. They are available
-here: [IRC specifications].
-
-The main features of the IRC client framework are:
-
- * Abstraction of the IRC protocol.
- * Handles multiple simultaneous IRC server connections.
- * Handles server PONGing transparently.
- * Messages to the IRC server are done by calling methods on an IRC
- connection object.
- * Messages from an IRC server triggers events, which can be caught
- by event handlers.
- * Reading from and writing to IRC server sockets are normally done
- by an internal select() loop, but the select()ing may be done by
- an external main loop.
- * Functions can be registered to execute at specified times by the
- event-loop.
- * Decodes CTCP tagging correctly (hopefully); I haven't seen any
- other IRC client implementation that handles the CTCP
- specification subtilties.
- * A kind of simple, single-server, object-oriented IRC client class
- that dispatches events to instance methods is included.
-
-Current limitations:
-
- * The IRC protocol shines through the abstraction a bit too much.
- * Data is not written asynchronously to the server, i.e. the write()
- may block if the TCP buffers are stuffed.
- * There are no support for DCC file transfers.
- * The author haven't even read RFC 2810, 2811, 2812 and 2813.
- * Like most projects, documentation is lacking...
-
-.. [IRC specifications] http://www.irchelp.org/irchelp/rfc/
-"""
-
-import bisect
-import re
-import select
-import socket
-import string
-import time
-import ssl
-import logging
-
-VERSION = 0, 4, 8
-DEBUG = 0
-
-# TODO
-# ----
-# (maybe) thread safety
-# (maybe) color parser convenience functions
-# documentation (including all event types)
-# (maybe) add awareness of different types of ircds
-# send data asynchronously to the server (and DCC connections)
-# (maybe) automatically close unused, passive DCC connections after a while
-
-# NOTES
-# -----
-# connection.quit() only sends QUIT to the server.
-# ERROR from the server triggers the error event and the disconnect event.
-# dropping of the connection triggers the disconnect event.
-
-
-class IRCError(Exception):
- """Represents an IRC exception."""
-
-
-class IRC:
- """Class that handles one or several IRC server connections.
-
- When an IRC object has been instantiated, it can be used to create
- Connection objects that represent the IRC connections. The
- responsibility of the IRC object is to provide an event-driven
- framework for the connections and to keep the connections alive.
- It runs a select loop to poll each connection's TCP socket and
- hands over the sockets with incoming data for processing by the
- corresponding connection.
-
- The methods of most interest for an IRC client writer are server,
- add_global_handler, remove_global_handler, execute_at,
- execute_delayed, process_once and process_forever.
-
- Here is an example:
-
- irc = irclib.IRC()
- server = irc.server()
- server.connect(\"irc.some.where\", 6667, \"my_nickname\")
- server.privmsg(\"a_nickname\", \"Hi there!\")
- irc.process_forever()
-
- This will connect to the IRC server irc.some.where on port 6667
- using the nickname my_nickname and send the message \"Hi there!\"
- to the nickname a_nickname.
- """
-
- def __init__(self, fn_to_add_socket=None,
- fn_to_remove_socket=None,
- fn_to_add_timeout=None):
- """Constructor for IRC objects.
-
- Optional arguments are fn_to_add_socket, fn_to_remove_socket
- and fn_to_add_timeout. The first two specify functions that
- will be called with a socket object as argument when the IRC
- object wants to be notified (or stop being notified) of data
- coming on a new socket. When new data arrives, the method
- process_data should be called. Similarly, fn_to_add_timeout
- is called with a number of seconds (a floating point number)
- as first argument when the IRC object wants to receive a
- notification (by calling the process_timeout method). So, if
- e.g. the argument is 42.17, the object wants the
- process_timeout method to be called after 42 seconds and 170
- milliseconds.
-
- The three arguments mainly exist to be able to use an external
- main loop (for example Tkinter's or PyGTK's main app loop)
- instead of calling the process_forever method.
-
- An alternative is to just call ServerConnection.process_once()
- once in a while.
- """
-
- if fn_to_add_socket and fn_to_remove_socket:
- self.fn_to_add_socket = fn_to_add_socket
- self.fn_to_remove_socket = fn_to_remove_socket
- else:
- self.fn_to_add_socket = None
- self.fn_to_remove_socket = None
-
- self.fn_to_add_timeout = fn_to_add_timeout
- self.connections = []
- self.handlers = {}
- self.delayed_commands = [] # list of tuples in the format (time, function, arguments)
-
- self.add_global_handler("ping", _ping_ponger, -42)
-
- def server(self):
- """Creates and returns a ServerConnection object."""
-
- c = ServerConnection(self)
- self.connections.append(c)
- return c
-
- def process_data(self, sockets):
- """Called when there is more data to read on connection sockets.
-
- Arguments:
-
- sockets -- A list of socket objects.
-
- See documentation for IRC.__init__.
- """
- for s in sockets:
- for c in self.connections:
- if s == c._get_socket():
- c.process_data()
-
- def process_timeout(self):
- """Called when a timeout notification is due.
-
- See documentation for IRC.__init__.
- """
- t = time.time()
- while self.delayed_commands:
- if t >= self.delayed_commands[0][0]:
- self.delayed_commands[0][1](*self.delayed_commands[0][2])
- del self.delayed_commands[0]
- else:
- break
-
- def process_once(self, timeout=0.0):
- """Process data from connections once.
-
- Arguments:
-
- timeout -- How long the select() call should wait if no
- data is available.
-
- This method should be called periodically to check and process
- incoming data, if there are any. If that seems boring, look
- at the process_forever method.
- """
- sockets = [x._get_socket() for x in self.connections]
- sockets = [x for x in sockets if x is not None]
- if sockets:
- (i, o, e) = select.select(sockets, [], [], timeout)
- self.process_data(i)
- self.process_timeout()
-
- def process_forever(self, timeout=0.2):
- """Run an infinite loop, processing data from connections.
-
- This method repeatedly calls process_once.
-
- Arguments:
-
- timeout -- Parameter to pass to process_once.
- """
- while 1:
- self.process_once(timeout)
-
- def disconnect_all(self, message=""):
- """Disconnects all connections."""
- for c in self.connections:
- c.disconnect(message)
-
- def add_global_handler(self, event, handler, priority=0):
- """Adds a global handler function for a specific event type.
-
- Arguments:
-
- event -- Event type (a string). Check the values of the
- numeric_events dictionary in irclib.py for possible event
- types.
-
- handler -- Callback function.
-
- priority -- A number (the lower number, the higher priority).
-
- The handler function is called whenever the specified event is
- triggered in any of the connections. See documentation for
- the Event class.
-
- The handler functions are called in priority order (lowest
- number is highest priority). If a handler function returns
- \"NO MORE\", no more handlers will be called.
- """
- if event not in self.handlers:
- self.handlers[event] = []
- bisect.insort(self.handlers[event], (priority, handler))
-
- def remove_global_handler(self, event, handler):
- """Removes a global handler function.
-
- Arguments:
-
- event -- Event type (a string).
-
- handler -- Callback function.
-
- Returns 1 on success, otherwise 0.
- """
- if event not in self.handlers:
- return 0
- for h in self.handlers[event]:
- if handler == h[1]:
- self.handlers[event].remove(h)
- return 1
-
- def execute_at(self, at, function, arguments=()):
- """Execute a function at a specified time.
-
- Arguments:
-
- at -- Execute at this time (standard \"time_t\" time).
-
- function -- Function to call.
-
- arguments -- Arguments to give the function.
- """
- self.execute_delayed(at-time.time(), function, arguments)
-
- def execute_delayed(self, delay, function, arguments=()):
- """Execute a function after a specified time.
-
- Arguments:
-
- delay -- How many seconds to wait.
-
- function -- Function to call.
-
- arguments -- Arguments to give the function.
- """
- bisect.insort(self.delayed_commands, (delay+time.time(), function, arguments))
- if self.fn_to_add_timeout:
- self.fn_to_add_timeout(delay)
-
- def dcc(self, dcctype="chat"):
- """Creates and returns a DCCConnection object.
-
- Arguments:
-
- dcctype -- "chat" for DCC CHAT connections or "raw" for
- DCC SEND (or other DCC types). If "chat",
- incoming data will be split in newline-separated
- chunks. If "raw", incoming data is not touched.
- """
- c = DCCConnection(self, dcctype)
- self.connections.append(c)
- return c
-
- def _handle_event(self, connection, event):
- """[Internal]"""
- h = self.handlers
- for handler in h.get("all_events", []) + h.get(event.eventtype(), []):
- if handler[1](connection, event) == "NO MORE":
- return
-
- def _remove_connection(self, connection):
- """[Internal]"""
- self.connections.remove(connection)
- if self.fn_to_remove_socket:
- self.fn_to_remove_socket(connection._get_socket())
-
-_rfc_1459_command_regexp = re.compile("^(:(?P[^ ]+) +)?(?P[^ ]+)( *(?P .+))?")
-
-
-class Connection:
- """Base class for IRC connections.
-
- Must be overridden.
- """
- def __init__(self, irclibobj):
- self.irclibobj = irclibobj
-
- def _get_socket(self):
- raise IRCError("Not overridden")
-
- ##############################
- # Convenience wrappers.
-
- def execute_at(self, at, function, arguments=()):
- self.irclibobj.execute_at(at, function, arguments)
-
- def execute_delayed(self, delay, function, arguments=()):
- self.irclibobj.execute_delayed(delay, function, arguments)
-
-
-class ServerConnectionError(IRCError):
- pass
-
-
-class ServerNotConnectedError(ServerConnectionError):
- pass
-
-
-# Huh!? Crrrrazy EFNet doesn't follow the RFC: their ircd seems to
-# use \n as message separator! :P
-_linesep_regexp = re.compile(b"\r?\n")
-
-
-class ServerConnection(Connection):
- """This class represents an IRC server connection.
-
- ServerConnection objects are instantiated by calling the server
- method on an IRC object.
- """
-
- def __init__(self, irclibobj):
- Connection.__init__(self, irclibobj)
- self.connected = 0 # Not connected yet.
- self.socket = None
- self.ssl = None
-
- def connect(self, server, port, nickname, password=None, username=None,
- ircname=None, localaddress="", localport=0, use_ssl=False, ipv6=False):
- """Connect/reconnect to a server.
-
- Arguments:
-
- server -- Server name.
-
- port -- Port number.
-
- nickname -- The nickname.
-
- password -- Password (if any).
-
- username -- The username.
-
- ircname -- The IRC name ("realname").
-
- localaddress -- Bind the connection to a specific local IP address.
-
- localport -- Bind the connection to a specific local port.
-
- ssl -- Enable support for ssl.
-
- ipv6 -- Enable support for ipv6.
-
- This function can be called to reconnect a closed connection.
-
- Returns the ServerConnection object.
- """
- if self.connected:
- self.disconnect("Changing servers")
-
- self.previous_buffer = b""
- self.handlers = {}
- self.real_server_name = ""
- self.real_nickname = nickname
- self.server = server
- self.port = port
- self.nickname = nickname
- self.username = username or nickname
- self.ircname = ircname or nickname
- self.password = password
- self.localaddress = localaddress
- self.localport = localport
- self.localhost = socket.gethostname()
- if ipv6:
- self.socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
- else:
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- try:
- self.socket.bind((self.localaddress, self.localport))
- self.socket.connect((self.server, self.port))
- if use_ssl:
- self.ssl = ssl.wrap_socket(self.socket)
- self.ssl.settimeout(0.0)
- else:
- self.socket.settimeout(0.0)
- except socket.error as x:
- self.socket.close()
- self.socket = None
- raise ServerConnectionError("Couldn't connect to socket: %s" % x)
- self.connected = 1
- if self.irclibobj.fn_to_add_socket:
- self.irclibobj.fn_to_add_socket(self.socket)
-
- # Log on...
- if self.password:
- self.pass_(self.password)
- self.nick(self.nickname)
- self.user(self.username, self.ircname)
- return self
-
- def close(self):
- """Close the connection.
-
- This method closes the connection permanently; after it has
- been called, the object is unusable.
- """
-
- self.disconnect("Closing object")
- self.irclibobj._remove_connection(self)
-
- def _get_socket(self):
- """[Internal]"""
- return self.socket
-
- def get_server_name(self):
- """Get the (real) server name.
-
- This method returns the (real) server name, or, more
- specifically, what the server calls itself.
- """
-
- if self.real_server_name:
- return self.real_server_name
- else:
- return ""
-
- def get_nickname(self):
- """Get the (real) nick name.
-
- This method returns the (real) nickname. The library keeps
- track of nick changes, so it might not be the nick name that
- was passed to the connect() method. """
-
- return self.real_nickname
-
- def process_data(self):
- """[Internal]"""
- try:
- if self.ssl:
- new_data = self.ssl.read(2**14)
- else:
- new_data = self.socket.recv(2**14)
- except socket.timeout:
- # Nothing was interesting
- pass
- except socket.error as x:
- # The server hung up.
- self.disconnect("Connection reset by peer")
- return
-
- lines = _linesep_regexp.split(self.previous_buffer + new_data)
-
- # Save the last, unfinished line.
- self.previous_buffer = lines.pop()
-
- for line in lines:
- if DEBUG:
- print("FROM SERVER:", line)
-
- if not line:
- continue
-
- try:
- line = line.decode("utf-8", "replace") # utf-8 support hacked in by thygrrr (may break in some scenarios - see chardet python package)
- except:
- print("irclib: non-utf-8 line, unexpected encoding error " + line)
- line = "** encoding error - replaced by irclib.py **"
-
- prefix = None
- command = None
- arguments = None
- self._handle_event(Event("all_raw_messages",
- self.get_server_name(),
- None,
- [line]))
-
- m = _rfc_1459_command_regexp.match(line)
- if m.group("prefix"):
- prefix = m.group("prefix")
- if not self.real_server_name:
- self.real_server_name = prefix
-
- if m.group("command"):
- command = m.group("command").lower()
-
- if m.group("argument"):
- a = m.group("argument").split(" :", 1)
- arguments = a[0].split()
- if len(a) == 2:
- arguments.append(a[1])
-
- # Translate numerics into more readable strings.
- if command in numeric_events:
- command = numeric_events[command]
-
- if command == "nick":
- if nm_to_n(prefix) == self.real_nickname:
- self.real_nickname = arguments[0]
- elif command == "welcome":
- # Record the nickname in case the client changed nick
- # in a nicknameinuse callback.
- self.real_nickname = arguments[0]
-
- if command in ["privmsg", "notice"]:
- target, message = arguments[0], arguments[1]
- messages = _ctcp_dequote(message)
-
- if command == "privmsg":
- if is_channel(target):
- command = "pubmsg"
- else:
- if is_channel(target):
- command = "pubnotice"
- else:
- command = "privnotice"
-
- for m in messages:
- if type(m) is tuple:
- if command in ["privmsg", "pubmsg"]:
- command = "ctcp"
- else:
- command = "ctcpreply"
-
- m = list(m)
- if DEBUG:
- print("command: %s, source: %s, target: %s, arguments: %s" % (
- command, prefix, target, m))
- self._handle_event(Event(command, prefix, target, m))
- if command == "ctcp" and m[0] == "ACTION":
- self._handle_event(Event("action", prefix, target, m[1:]))
- else:
- if DEBUG:
- print("command: %s, source: %s, target: %s, arguments: %s" % (
- command, prefix, target, [m]))
- self._handle_event(Event(command, prefix, target, [m]))
- else:
- target = None
-
- if command == "quit":
- arguments = [arguments[0]]
- elif command == "ping":
- target = arguments[0]
- else:
- target = arguments[0]
- arguments = arguments[1:]
-
- if command == "mode":
- if not is_channel(target):
- command = "umode"
-
- if DEBUG:
- print("command: %s, source: %s, target: %s, arguments: %s" % (
- command, prefix, target, arguments))
- self._handle_event(Event(command, prefix, target, arguments))
-
- def _handle_event(self, event):
- """[Internal]"""
- try:
- self.irclibobj._handle_event(self, event)
- if event.eventtype() in self.handlers:
- for fn in self.handlers[event.eventtype()]:
- fn(self, event)
- except Exception as e:
- logging.getLogger('irclib').exception(e)
-
- def is_connected(self):
- """Return connection status.
-
- Returns true if connected, otherwise false.
- """
- return self.connected
-
- def add_global_handler(self, *args):
- """Add global handler.
-
- See documentation for IRC.add_global_handler.
- """
- self.irclibobj.add_global_handler(*args)
-
- def remove_global_handler(self, *args):
- """Remove global handler.
-
- See documentation for IRC.remove_global_handler.
- """
- self.irclibobj.remove_global_handler(*args)
-
- def action(self, target, action):
- """Send a CTCP ACTION command."""
- self.ctcp("ACTION", target, action)
-
- def admin(self, server=""):
- """Send an ADMIN command."""
- self.send_raw(" ".join(["ADMIN", server]).strip())
-
- def ctcp(self, ctcptype, target, parameter=""):
- """Send a CTCP command."""
- ctcptype = ctcptype.upper()
- self.privmsg(target, "\001%s%s\001" % (ctcptype, parameter and (" " + parameter) or ""))
-
- def ctcp_reply(self, target, parameter):
- """Send a CTCP REPLY command."""
- self.notice(target, "\001%s\001" % parameter)
-
- def disconnect(self, message=""):
- """Hang up the connection.
-
- Arguments:
-
- message -- Quit message.
- """
- if not self.connected:
- return
-
- self.connected = 0
-
- self.quit(message)
-
- try:
- self.socket.close()
- except socket.error as x:
- pass
- self.socket = None
- self._handle_event(Event("disconnect", self.server, "", [message]))
-
- def globops(self, text):
- """Send a GLOBOPS command."""
- self.send_raw("GLOBOPS :" + text)
-
- def info(self, server=""):
- """Send an INFO command."""
- self.send_raw(" ".join(["INFO", server]).strip())
-
- def invite(self, nick, channel):
- """Send an INVITE command."""
- self.send_raw(" ".join(["INVITE", nick, channel]).strip())
-
- def ison(self, nicks):
- """Send an ISON command.
-
- Arguments:
-
- nicks -- List of nicks.
- """
- self.send_raw("ISON " + " ".join(nicks))
-
- def join(self, channel, key=""):
- """Send a JOIN command."""
- self.send_raw("JOIN %s%s" % (channel, (key and (" " + key))))
-
- def kick(self, channel, nick, comment=""):
- """Send a KICK command."""
- self.send_raw("KICK %s %s%s" % (channel, nick, (comment and (" :" + comment))))
-
- def links(self, remote_server="", server_mask=""):
- """Send a LINKS command."""
- command = "LINKS"
- if remote_server:
- command = command + " " + remote_server
- if server_mask:
- command = command + " " + server_mask
- self.send_raw(command)
-
- def list(self, channels=None, server=""):
- """Send a LIST command."""
- command = "LIST"
- if channels:
- command = command + " " + ",".join(channels)
- if server:
- command = command + " " + server
- self.send_raw(command)
-
- def lusers(self, server=""):
- """Send a LUSERS command."""
- self.send_raw("LUSERS" + (server and (" " + server)))
-
- def mode(self, target, command):
- """Send a MODE command."""
- self.send_raw("MODE %s %s" % (target, command))
-
- def motd(self, server=""):
- """Send an MOTD command."""
- self.send_raw("MOTD" + (server and (" " + server)))
-
- def names(self, channels=None):
- """Send a NAMES command."""
- self.send_raw("NAMES" + (channels and (" " + ",".join(channels)) or ""))
-
- def nick(self, newnick):
- """Send a NICK command."""
- self.send_raw("NICK " + newnick)
-
- def notice(self, target, text):
- """Send a NOTICE command."""
- # Should limit len(text) here!
- self.send_raw("NOTICE %s :%s" % (target, text))
-
- def oper(self, nick, password):
- """Send an OPER command."""
- self.send_raw("OPER %s %s" % (nick, password))
-
- def part(self, channels, message=""):
- """Send a PART command."""
- if type(channels) == bytes:
- self.send_raw("PART " + channels + (message and (" " + message)))
- else:
- self.send_raw("PART " + ",".join(channels) + (message and (" " + message)))
-
- def pass_(self, password):
- """Send a PASS command."""
- self.send_raw("PASS " + password)
-
- def ping(self, target, target2=""):
- """Send a PING command."""
- self.send_raw("PING %s%s" % (target, target2 and (" " + target2)))
-
- def pong(self, target, target2=""):
- """Send a PONG command."""
- self.send_raw("PONG %s%s" % (target, target2 and (" " + target2)))
-
- def privmsg(self, target, text):
- """Send a PRIVMSG command."""
- # Should limit len(text) here!
- self.send_raw("PRIVMSG %s :%s" % (target, text))
-
- def privmsg_many(self, targets, text):
- """Send a PRIVMSG command to multiple targets."""
- # Should limit len(text) here!
- self.send_raw("PRIVMSG %s :%s" % (",".join(targets), text))
-
- def quit(self, message=""):
- """Send a QUIT command."""
- # Note that many IRC servers don't use your QUIT message
- # unless you've been connected for at least 5 minutes!
- self.send_raw("QUIT" + (message and (" :" + message)))
-
- def send_raw(self, string):
- """Send raw string to the server.
-
- The string will be padded with appropriate CR LF.
- """
- if self.socket is None:
- raise ServerNotConnectedError("Not connected.")
- try:
- if self.ssl:
- self.ssl.write((string + "\r\n").encode("utf-8")) #FIXME utf-8 support hacked in by thygrrrc(may break in some scenarios)
- else:
- self.socket.send((string + "\r\n").encode("utf-8")) #FIXME utf-8 support hacked in by thygrrr (may break in some scenarios)
- if DEBUG:
- print("TO SERVER:", string)
- except socket.error as x:
- # Ouch!
- self.disconnect("Connection reset by peer.")
-
- def squit(self, server, comment=""):
- """Send an SQUIT command."""
- self.send_raw("SQUIT %s%s" % (server, comment and (" :" + comment)))
-
- def stats(self, statstype, server=""):
- """Send a STATS command."""
- self.send_raw("STATS %s%s" % (statstype, server and ("u " + server)))
-
- def time(self, server=""):
- """Send a TIME command."""
- self.send_raw("TIME" + (server and (" " + server)))
-
- def topic(self, channel, new_topic=None):
- """Send a TOPIC command."""
- if new_topic is None:
- self.send_raw("TOPIC " + channel)
- else:
- self.send_raw("TOPIC %s :%s" % (channel, new_topic))
-
- def trace(self, target=""):
- """Send a TRACE command."""
- self.send_raw("TRACE" + (target and (" " + target)))
-
- def user(self, username, realname):
- """Send a USER command."""
- self.send_raw("USER %s 0 * :%s" % (username, realname))
-
- def userhost(self, nicks):
- """Send a USERHOST command."""
- self.send_raw("USERHOST " + ",".join(nicks))
-
- def users(self, server=""):
- """Send a USERS command."""
- self.send_raw("USERS" + (server and (" " + server)))
-
- def version(self, server=""):
- """Send a VERSION command."""
- self.send_raw("VERSION" + (server and (" " + server)))
-
- def wallops(self, text):
- """Send a WALLOPS command."""
- self.send_raw("WALLOPS :" + text)
-
- def who(self, target="", op=""):
- """Send a WHO command."""
- self.send_raw("WHO%s%s" % (target and (" " + target), op and (" o")))
-
- def whois(self, targets):
- """Send a WHOIS command."""
- self.send_raw("WHOIS " + ",".join(targets))
-
- def whowas(self, nick, max="", server=""):
- """Send a WHOWAS command."""
- self.send_raw("WHOWAS %s%s%s" % (nick,
- max and (" " + max),
- server and (" " + server)))
-
-
-class DCCConnectionError(IRCError):
- pass
-
-
-class DCCConnection(Connection):
- """This class represents a DCC connection.
-
- DCCConnection objects are instantiated by calling the dcc
- method on an IRC object.
- """
- def __init__(self, irclibobj, dcctype):
- Connection.__init__(self, irclibobj)
- self.connected = 0
- self.passive = 0
- self.dcctype = dcctype
- self.peeraddress = None
- self.peerport = None
-
- def connect(self, address, port):
- """Connect/reconnect to a DCC peer.
-
- Arguments:
- address -- Host/IP address of the peer.
-
- port -- The port number to connect to.
-
- Returns the DCCConnection object.
- """
- self.peeraddress = socket.gethostbyname(address)
- self.peerport = port
- self.socket = None
- self.previous_buffer = b""
- self.handlers = {}
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- self.passive = 0
- try:
- self.socket.connect((self.peeraddress, self.peerport))
- except socket.error as x:
- raise DCCConnectionError("Couldn't connect to socket: %s" % x)
- self.connected = 1
- if self.irclibobj.fn_to_add_socket:
- self.irclibobj.fn_to_add_socket(self.socket)
- return self
-
- def listen(self):
- """Wait for a connection/reconnection from a DCC peer.
-
- Returns the DCCConnection object.
-
- The local IP address and port are available as
- self.localaddress and self.localport. After connection from a
- peer, the peer address and port are available as
- self.peeraddress and self.peerport.
- """
- self.previous_buffer = b""
- self.handlers = {}
- self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- self.passive = 1
- try:
- self.socket.bind((socket.gethostbyname(socket.gethostname()), 0))
- self.localaddress, self.localport = self.socket.getsockname()
- self.socket.listen(10)
- except socket.error as x:
- raise DCCConnectionError("Couldn't bind socket: %s" % x)
- return self
-
- def disconnect(self, message=""):
- """Hang up the connection and close the object.
-
- Arguments:
-
- message -- Quit message.
- """
- if not self.connected:
- return
-
- self.connected = 0
- try:
- self.socket.close()
- except socket.error as x:
- pass
- self.socket = None
- self.irclibobj._handle_event(
- self,
- Event("dcc_disconnect", self.peeraddress, "", [message]))
- self.irclibobj._remove_connection(self)
-
- def process_data(self):
- """[Internal]"""
-
- if self.passive and not self.connected:
- conn, (self.peeraddress, self.peerport) = self.socket.accept()
- self.socket.close()
- self.socket = conn
- self.connected = 1
- if DEBUG:
- print("DCC connection from %s:%d" % (
- self.peeraddress, self.peerport))
- self.irclibobj._handle_event(
- self,
- Event("dcc_connect", self.peeraddress, None, None))
- return
-
- try:
- new_data = self.socket.recv(2**14)
- except socket.error as x:
- # The server hung up.
- self.disconnect("Connection reset by peer")
- return
- if not new_data:
- # Read nothing: connection must be down.
- self.disconnect("Connection reset by peer")
- return
-
- if self.dcctype == "chat":
- # The specification says lines are terminated with LF, but
- # it seems safer to handle CR LF terminations too.
- chunks = _linesep_regexp.split(self.previous_buffer + new_data)
-
- # Save the last, unfinished line.
- self.previous_buffer = chunks[-1]
- if len(self.previous_buffer) > 2**14:
- # Bad peer! Naughty peer!
- self.disconnect()
- return
- chunks = chunks[:-1]
- else:
- chunks = [new_data]
-
- command = "dccmsg"
- prefix = self.peeraddress
- target = None
- for chunk in chunks:
- if DEBUG:
- print("FROM PEER:", chunk)
- arguments = [chunk]
- if DEBUG:
- print("command: %s, source: %s, target: %s, arguments: %s" % (
- command, prefix, target, arguments))
- self.irclibobj._handle_event(
- self,
- Event(command, prefix, target, arguments))
-
- def _get_socket(self):
- """[Internal]"""
- return self.socket
-
- def privmsg(self, string):
- """Send data to DCC peer.
-
- The string will be padded with appropriate LF if it's a DCC
- CHAT session.
- """
- try:
- self.socket.send(string)
- if self.dcctype == "chat":
- self.socket.send("\n")
- if DEBUG:
- print("TO PEER: %s\n" % string)
- except socket.error as x:
- # Ouch!
- self.disconnect("Connection reset by peer.")
-
-
-class SimpleIRCClient:
- """A simple single-server IRC client class.
-
- This is an example of an object-oriented wrapper of the IRC
- framework. A real IRC client can be made by subclassing this
- class and adding appropriate methods.
-
- The method on_join will be called when a "join" event is created
- (which is done when the server sends a JOIN messsage/command),
- on_privmsg will be called for "privmsg" events, and so on. The
- handler methods get two arguments: the connection object (same as
- self.connection) and the event object.
-
- Instance attributes that can be used by sub classes:
-
- ircobj -- The IRC instance.
-
- connection -- The ServerConnection instance.
-
- dcc_connections -- A list of DCCConnection instances.
- """
- def __init__(self):
- self.ircobj = IRC()
- self.connection = self.ircobj.server()
- self.dcc_connections = []
- self.ircobj.add_global_handler("all_events", self._dispatcher, -10)
- self.ircobj.add_global_handler("dcc_disconnect", self._dcc_disconnect, -10)
-
- def _dispatcher(self, c, e):
- """[Internal]"""
- m = "on_" + e.eventtype()
- if hasattr(self, m):
- getattr(self, m)(c, e)
- else:
- if m != "on_all_raw_messages":
- if hasattr(self, "on_default"):
- self.on_default(c, e)
-
- def _dcc_disconnect(self, c, e):
- self.dcc_connections.remove(c)
-
- def irc_connect(self, server, port, nickname, password=None, username=None,
- ircname=None, localaddress="", localport=0, ssl=False, ipv6=False):
- """Connect/reconnect to a server.
-
- Arguments:
-
- server -- Server name.
-
- port -- Port number.
-
- nickname -- The nickname.
-
- password -- Password (if any).
-
- username -- The username.
-
- ircname -- The IRC name.
-
- localaddress -- Bind the connection to a specific local IP address.
-
- localport -- Bind the connection to a specific local port.
-
- ssl -- Enable support for ssl.
-
- ipv6 -- Enable support for ipv6.
-
- This function can be called to reconnect a closed connection.
- """
- self.connection.connect(server, port, nickname,
- password, username, ircname,
- localaddress, localport, ssl, ipv6)
-
- def irc_disconnect(self, message="ctrl-k"):
- self.connection.disconnect(message)
-
- def dcc_connect(self, address, port, dcctype="chat"):
- """Connect to a DCC peer.
-
- Arguments:
-
- address -- IP address of the peer.
-
- port -- Port to connect to.
-
- Returns a DCCConnection instance.
- """
- dcc = self.ircobj.dcc(dcctype)
- self.dcc_connections.append(dcc)
- dcc.connect(address, port)
- return dcc
-
- def dcc_listen(self, dcctype="chat"):
- """Listen for connections from a DCC peer.
-
- Returns a DCCConnection instance.
- """
- dcc = self.ircobj.dcc(dcctype)
- self.dcc_connections.append(dcc)
- dcc.listen()
- return dcc
-
- def start(self):
- """Start the IRC client."""
- self.ircobj.process_forever()
-
- def once(self):
- """Poll IRC server once."""
- self.ircobj.process_once(timeout=0.01)
-
-
-class Event:
- """Class representing an IRC event."""
- def __init__(self, eventtype, source, target, arguments=None):
- """Constructor of Event objects.
-
- Arguments:
-
- eventtype -- A string describing the event.
-
- source -- The originator of the event (a nick mask or a server).
-
- target -- The target of the event (a nick or a channel).
-
- arguments -- Any event specific arguments.
- """
- self._eventtype = eventtype
- self._source = source
- self._target = target
- if arguments:
- self._arguments = arguments
- else:
- self._arguments = []
-
- def eventtype(self):
- """Get the event type."""
- return self._eventtype
-
- def source(self):
- """Get the event source."""
- return self._source
-
- def target(self):
- """Get the event target."""
- return self._target
-
- def arguments(self):
- """Get the event arguments."""
- return self._arguments
-
-_LOW_LEVEL_QUOTE = "\020"
-_CTCP_LEVEL_QUOTE = "\134"
-_CTCP_DELIMITER = "\001"
-
-_low_level_mapping = {
- "0": "\000",
- "n": "\n",
- "r": "\r",
- _LOW_LEVEL_QUOTE: _LOW_LEVEL_QUOTE
-}
-
-_low_level_regexp = re.compile(_LOW_LEVEL_QUOTE + "(.)")
-
-
-def mask_matches(nick, mask):
- """Check if a nick matches a mask.
-
- Returns true if the nick matches, otherwise false.
- """
- nick = irc_lower(nick)
- mask = irc_lower(mask)
- mask = mask.replace("\\", "\\\\")
- for ch in ".$|[](){}+":
- mask = mask.replace(ch, "\\" + ch)
- mask = mask.replace("?", ".")
- mask = mask.replace("*", ".*")
- r = re.compile(mask, re.IGNORECASE)
- return r.match(nick)
-
-_special = "-[]\\`^{}"
-nick_characters = string.ascii_letters + string.digits + _special
-_ircstring_translation = str.maketrans(string.ascii_uppercase + "[]\\^",
- string.ascii_lowercase + "{}|~")
-
-
-def irc_lower(s):
- """Returns a lowercased string.
-
- The definition of lowercased comes from the IRC specification (RFC
- 1459).
- """
- return s.translate(_ircstring_translation)
-
-
-def _ctcp_dequote(message):
- """[Internal] Dequote a message according to CTCP specifications.
-
- The function returns a list where each element can be either a
- string (normal message) or a tuple of one or two strings (tagged
- messages). If a tuple has only one element (ie is a singleton),
- that element is the tag; otherwise the tuple has two elements: the
- tag and the data.
-
- Arguments:
-
- message -- The message to be decoded.
- """
-
- def _low_level_replace(match_obj):
- ch = match_obj.group(1)
-
- # If low_level_mapping doesn't have the character as key, we
- # should just return the character.
- return _low_level_mapping.get(ch, ch)
-
- if _LOW_LEVEL_QUOTE in message:
- # Yup, there was a quote. Release the dequoter, man!
- message = _low_level_regexp.sub(_low_level_replace, message)
-
- if _CTCP_DELIMITER not in message:
- return [message]
- else:
- # Split it into parts. (Does any IRC client actually *use*
- # CTCP stacking like this?)
- chunks = message.split(_CTCP_DELIMITER)
-
- messages = []
- i = 0
- while i < len(chunks)-1:
- # Add message if it's non-empty.
- if len(chunks[i]) > 0:
- messages.append(chunks[i])
-
- if i < len(chunks)-2:
- # Aye! CTCP tagged data ahead!
- messages.append(tuple(chunks[i+1].split(" ", 1)))
-
- i = i + 2
-
- if len(chunks) % 2 == 0:
- # Hey, a lonely _CTCP_DELIMITER at the end! This means
- # that the last chunk, including the delimiter, is a
- # normal message! (This is according to the CTCP
- # specification.)
- messages.append(_CTCP_DELIMITER + chunks[-1])
-
- return messages
-
-
-def is_channel(string):
- """Check if a string is a channel name.
-
- Returns true if the argument is a channel name, otherwise false.
- """
- return string and string[0] in "#&+!"
-
-
-def ip_numstr_to_quad(num):
- """Convert an IP number as an integer given in ASCII
- representation (e.g. '3232235521') to an IP address string
- (e.g. '192.168.0.1')."""
- n = int(num)
- p = list(map(str, list(map(int, [n >> 24 & 0xFF, n >> 16 & 0xFF,
- n >> 8 & 0xFF, n & 0xFF]))))
- return ".".join(p)
-
-
-def ip_quad_to_numstr(quad):
- """Convert an IP address string (e.g. '192.168.0.1') to an IP
- number as an integer given in ASCII representation
- (e.g. '3232235521')."""
- p = list(map(int, quad.split(".")))
- s = str((p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3])
- if s[-1] == "L":
- s = s[:-1]
- return s
-
-
-def nm_to_n(s):
- """Get the nick part of a nickmask.
-
- (The source of an Event is a nickmask.)
- """
- return s.split("!")[0]
-
-
-def nm_to_uh(s):
- """Get the userhost part of a nickmask.
-
- (The source of an Event is a nickmask.)
- """
- return s.split("!")[1]
-
-
-def nm_to_h(s):
- """Get the host part of a nickmask.
-
- (The source of an Event is a nickmask.)
- """
- return s.split("@")[1]
-
-
-def nm_to_u(s):
- """Get the user part of a nickmask.
-
- (The source of an Event is a nickmask.)
- """
- s = s.split("!")[1]
- return s.split("@")[0]
-
-
-def parse_nick_modes(mode_string):
- """Parse a nick mode string.
-
- The function returns a list of lists with three members: sign,
- mode and argument. The sign is \"+\" or \"-\". The argument is
- always None.
-
- Example:
-
- >>> irclib.parse_nick_modes(\"+ab-c\")
- [['+', 'a', None], ['+', 'b', None], ['-', 'c', None]]
- """
-
- return _parse_modes(mode_string, "")
-
-
-def parse_channel_modes(mode_string):
- """Parse a channel mode string.
-
- The function returns a list of lists with three members: sign,
- mode and argument. The sign is \"+\" or \"-\". The argument is
- None if mode isn't one of \"b\", \"k\", \"l\", \"v\" or \"o\".
-
- Example:
-
- >>> irclib.parse_channel_modes(\"+ab-c foo\")
- [['+', 'a', None], ['+', 'b', 'foo'], ['-', 'c', None]]
- """
-
- return _parse_modes(mode_string, "bklvo")
-
-
-def _parse_modes(mode_string, unary_modes=""):
- """[Internal]"""
- modes = []
- arg_count = 0
-
- # State variable.
- sign = ""
-
- a = mode_string.split()
- if len(a) == 0:
- return []
- else:
- mode_part, args = a[0], a[1:]
-
- if mode_part[0] not in "+-":
- return []
- for ch in mode_part:
- if ch in "+-":
- sign = ch
- elif ch == " ":
- collecting_arguments = 1
- elif ch in unary_modes:
- if len(args) >= arg_count + 1:
- modes.append([sign, ch, args[arg_count]])
- arg_count = arg_count + 1
- else:
- modes.append([sign, ch, None])
- else:
- modes.append([sign, ch, None])
- return modes
-
-
-def _ping_ponger(connection, event):
- """[Internal]"""
- connection.pong(event.target())
-
-# Numeric table mostly stolen from the Perl IRC module (Net::IRC).
-numeric_events = {
- "001": "welcome",
- "002": "yourhost",
- "003": "created",
- "004": "myinfo",
- "005": "featurelist", # XXX
- "200": "tracelink",
- "201": "traceconnecting",
- "202": "tracehandshake",
- "203": "traceunknown",
- "204": "traceoperator",
- "205": "traceuser",
- "206": "traceserver",
- "207": "traceservice",
- "208": "tracenewtype",
- "209": "traceclass",
- "210": "tracereconnect",
- "211": "statslinkinfo",
- "212": "statscommands",
- "213": "statscline",
- "214": "statsnline",
- "215": "statsiline",
- "216": "statskline",
- "217": "statsqline",
- "218": "statsyline",
- "219": "endofstats",
- "221": "umodeis",
- "231": "serviceinfo",
- "232": "endofservices",
- "233": "service",
- "234": "servlist",
- "235": "servlistend",
- "241": "statslline",
- "242": "statsuptime",
- "243": "statsoline",
- "244": "statshline",
- "250": "luserconns",
- "251": "luserclient",
- "252": "luserop",
- "253": "luserunknown",
- "254": "luserchannels",
- "255": "luserme",
- "256": "adminme",
- "257": "adminloc1",
- "258": "adminloc2",
- "259": "adminemail",
- "261": "tracelog",
- "262": "endoftrace",
- "263": "tryagain",
- "265": "n_local",
- "266": "n_global",
- "300": "none",
- "301": "away",
- "302": "userhost",
- "303": "ison",
- "305": "unaway",
- "306": "nowaway",
- "311": "whoisuser",
- "312": "whoisserver",
- "313": "whoisoperator",
- "314": "whowasuser",
- "315": "endofwho",
- "316": "whoischanop",
- "317": "whoisidle",
- "318": "endofwhois",
- "319": "whoischannels",
- "321": "liststart",
- "322": "list",
- "323": "listend",
- "324": "channelmodeis",
- "329": "channelcreate",
- "331": "notopic",
- "332": "currenttopic",
- "333": "topicinfo",
- "341": "inviting",
- "342": "summoning",
- "346": "invitelist",
- "347": "endofinvitelist",
- "348": "exceptlist",
- "349": "endofexceptlist",
- "351": "version",
- "352": "whoreply",
- "353": "namreply",
- "361": "killdone",
- "362": "closing",
- "363": "closeend",
- "364": "links",
- "365": "endoflinks",
- "366": "endofnames",
- "367": "banlist",
- "368": "endofbanlist",
- "369": "endofwhowas",
- "371": "info",
- "372": "motd",
- "373": "infostart",
- "374": "endofinfo",
- "375": "motdstart",
- "376": "endofmotd",
- "377": "motd2", # 1997-10-16 -- tkil
- "381": "youreoper",
- "382": "rehashing",
- "384": "myportis",
- "391": "time",
- "392": "usersstart",
- "393": "users",
- "394": "endofusers",
- "395": "nousers",
- "401": "nosuchnick",
- "402": "nosuchserver",
- "403": "nosuchchannel",
- "404": "cannotsendtochan",
- "405": "toomanychannels",
- "406": "wasnosuchnick",
- "407": "toomanytargets",
- "409": "noorigin",
- "411": "norecipient",
- "412": "notexttosend",
- "413": "notoplevel",
- "414": "wildtoplevel",
- "421": "unknowncommand",
- "422": "nomotd",
- "423": "noadmininfo",
- "424": "fileerror",
- "431": "nonicknamegiven",
- "432": "erroneusnickname", # Thiss iz how its speld in thee RFC.
- "433": "nicknameinuse",
- "436": "nickcollision",
- "437": "unavailresource", # "Nick temporally unavailable"
- "441": "usernotinchannel",
- "442": "notonchannel",
- "443": "useronchannel",
- "444": "nologin",
- "445": "summondisabled",
- "446": "usersdisabled",
- "451": "notregistered",
- "461": "needmoreparams",
- "462": "alreadyregistered",
- "463": "nopermforhost",
- "464": "passwdmismatch",
- "465": "yourebannedcreep", # I love this one...
- "466": "youwillbebanned",
- "467": "keyset",
- "471": "channelisfull",
- "472": "unknownmode",
- "473": "inviteonlychan",
- "474": "bannedfromchan",
- "475": "badchannelkey",
- "476": "badchanmask",
- "477": "nochanmodes", # "Channel doesn't support modes"
- "478": "banlistfull",
- "481": "noprivileges",
- "482": "chanoprivsneeded",
- "483": "cantkillserver",
- "484": "restricted", # Connection is restricted
- "485": "uniqopprivsneeded",
- "491": "nooperhost",
- "492": "noservicehost",
- "501": "umodeunknownflag",
- "502": "usersdontmatch",
-}
-
-generated_events = [
- # Generated events
- "dcc_connect",
- "dcc_disconnect",
- "dccmsg",
- "disconnect",
- "ctcp",
- "ctcpreply",
-]
-
-protocol_events = [
- # IRC protocol events
- "error",
- "join",
- "kick",
- "mode",
- "part",
- "ping",
- "privmsg",
- "privnotice",
- "pubmsg",
- "pubnotice",
- "quit",
- "invite",
- "pong",
-]
-
-all_events = generated_events + protocol_events + list(numeric_events.values())
diff --git a/src/chat/lang.py b/src/chat/lang.py
new file mode 100644
index 000000000..835cb9aae
--- /dev/null
+++ b/src/chat/lang.py
@@ -0,0 +1,11 @@
+LANGUAGE_CHANNELS = {
+ "#french": ["fr"],
+ "#russian": ["ru", "by"], # Be conservative here
+ "#german": ["de"],
+}
+# Flip around for easier use
+DEFAULT_LANGUAGE_CHANNELS = {
+ code: channel
+ for channel, codes in LANGUAGE_CHANNELS.items()
+ for code in codes
+}
diff --git a/src/chat/language_channel_config.py b/src/chat/language_channel_config.py
new file mode 100644
index 000000000..6f2588152
--- /dev/null
+++ b/src/chat/language_channel_config.py
@@ -0,0 +1,128 @@
+from PyQt6.QtCore import QAbstractListModel
+from PyQt6.QtCore import Qt
+
+from chat.lang import LANGUAGE_CHANNELS
+
+
+class ChannelEntry:
+ def __init__(self, name, icon, checked):
+ self.name = name
+ self.icon = icon
+ self.checked = checked
+
+
+class LanguageChannelConfig:
+ def __init__(self, parent_widget, settings, theme):
+ self._parent_widget = parent_widget
+ self._settings = settings
+ self._theme = theme
+ self._base = None
+ self._form = None
+ self._model = None
+ self._setup_widget()
+ self._setup_model()
+
+ def _setup_widget(self):
+ formc, basec = self._theme.loadUiType(
+ "chat/language_channel_config.ui",
+ )
+ self._form = formc()
+ self._base = basec(self._parent_widget)
+ self._form.setupUi(self._base)
+ self._form.endDialogBox.accepted.connect(self._on_accepted)
+ self._form.endDialogBox.rejected.connect(self._on_rejected)
+
+ def _setup_model(self):
+ self._model = CheckableStringListModel()
+ self._form.channelListView.setModel(self._model)
+
+ def _load_data(self):
+ self._model.load_data(self._chan_flag_list())
+
+ def _chan_flag_list(self):
+ checked_channels = self._current_channels()
+ channels = []
+ for name, langs in LANGUAGE_CHANNELS.items():
+ icon = self._country_icon(langs[0])
+ checked = name in checked_channels
+ channels.append(ChannelEntry(name, icon, checked))
+
+ channels.sort(key=lambda x: x.name)
+ return channels
+
+ # TODO - move somewhere
+ def _country_icon(self, country):
+ return self._theme.icon("chat/countries/{}.png".format(country))
+
+ def _current_channels(self):
+ checked_channels = self._settings.get('client/lang_channels', "")
+ return [c for c in checked_channels.split(';') if c]
+
+ def _save_channels(self):
+ channels = self._model.checked_channels()
+ self._settings.set('client/lang_channels', ';'.join(channels))
+
+ def _on_accepted(self):
+ self._save_channels()
+ self._base.accept()
+
+ def _on_rejected(self):
+ self._base.reject()
+
+ def run(self):
+ self._load_data()
+ self._base.show()
+
+
+class CheckableStringListModel(QAbstractListModel):
+ def __init__(self):
+ QAbstractListModel.__init__(self)
+ self._items = []
+
+ def rowCount(self, parent):
+ if parent.isValid():
+ return 0
+ return len(self._items)
+
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
+ item = self._index_item(index)
+ if item is None:
+ return None
+ if role == Qt.ItemDataRole.DisplayRole:
+ return item.name
+ if role == Qt.ItemDataRole.DecorationRole:
+ return item.icon
+ if role == Qt.ItemDataRole.CheckStateRole:
+ return item.checked
+ return None
+
+ def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
+ item = self._index_item(index)
+ if item is None:
+ return False
+ if role == Qt.ItemDataRole.CheckStateRole:
+ item.checked = value
+ self.dataChanged.emit(index, index, [Qt.ItemDataRole.CheckStateRole])
+ return True
+ return False
+
+ def _index_item(self, index):
+ if not index.isValid():
+ return None
+ row = index.row()
+ if row < 0 or row >= len(self._items):
+ return None
+ return self._items[row]
+
+ def load_data(self, entries):
+ self.modelAboutToBeReset.emit()
+ self._items = entries
+ self.modelReset.emit()
+
+ def flags(self, index):
+ if index.isValid():
+ return Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEnabled
+ return 0
+
+ def checked_channels(self):
+ return [i.name for i in self._items if i.checked]
diff --git a/src/chat/line_restorer.py b/src/chat/line_restorer.py
new file mode 100644
index 000000000..9341e2ce8
--- /dev/null
+++ b/src/chat/line_restorer.py
@@ -0,0 +1,23 @@
+class ChatLineRestorer:
+ def __init__(self, model):
+ self._model = model
+ self._saved_channels = {}
+ self._model.channels.added.connect(self._at_channel_added)
+ self._model.channels.removed.connect(self._at_channel_removed)
+
+ def _at_channel_removed(self, channel):
+ self._save_channel_lines(channel)
+
+ def _save_channel_lines(self, channel):
+ self._saved_channels[channel.id_key] = [line for line in channel.lines]
+
+ def _at_channel_added(self, channel):
+ self._restore_channel_lines(channel)
+
+ def _restore_channel_lines(self, channel):
+ saved = self._saved_channels.get(channel.id_key, None)
+ if saved is None:
+ return
+ for line in saved:
+ channel.lines.add_line(line)
+ del self._saved_channels[channel.id_key]
diff --git a/src/chat/socketadapter.py b/src/chat/socketadapter.py
new file mode 100644
index 000000000..3a4422025
--- /dev/null
+++ b/src/chat/socketadapter.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import logging
+import time
+
+from irc.client import Reactor
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QAbstractSocket
+from PyQt6.QtNetwork import QNetworkRequest
+from PyQt6.QtWebSockets import QWebSocket
+
+logger = logging.getLogger(__name__)
+
+
+class WebSocketToSocket(QObject):
+ """ Allows to use QWebSocket as a 'socket' """
+
+ message_received = 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.buffer = b""
+
+ def on_socket_error(self, error: QAbstractSocket.SocketError) -> None:
+ logger.error(f"SocketAdapter error: {error}. Details: {self.socket.errorString()}")
+
+ 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
+ # library (irc) requires them
+ self.buffer += message + b"\r\n"
+ self.message_received.emit()
+
+ def read(self, size: int) -> bytes:
+ if self.socket.state() != QAbstractSocket.SocketState.ConnectedState:
+ raise OSError
+ ans, self.buffer = self.buffer[:size], self.buffer[size:]
+ return ans
+
+ def recv(self, size: int) -> bytes:
+ """ Alias for read, just in case """
+ return self.read(size)
+
+ def shutdown(self, how: int) -> None:
+ self.socket.deleteLater()
+
+ def write(self, message: bytes) -> None:
+ sent = self.socket.sendBinaryMessage(message.strip())
+ if sent == 0:
+ raise OSError
+
+ def send(self, message: bytes) -> None:
+ """ Alias for write, just in case """
+ self.write(message)
+
+ def _prepare_request(self, server_address: tuple[str, int]) -> QNetworkRequest:
+ host, port = server_address
+ request = QNetworkRequest()
+ request.setUrl(QUrl(f"wss://{host}:{port}"))
+ request.setRawHeader(b"Sec-WebSocket-Protocol", b"binary.ircv3.net")
+ return request
+
+ 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()
+
+ def close(self) -> None:
+ self.socket.close()
+
+
+class ReactorForSocketAdapter(Reactor):
+ def process_once(self, timeout: float = 0.01) -> None:
+ if self.sockets:
+ self.process_data(self.sockets)
+ else:
+ time.sleep(timeout)
+ self.process_timeout()
+
+
+class ConnectionFactory:
+ def connect(self, server_address: tuple[str, int]) -> None:
+ sock = WebSocketToSocket()
+ sock.connect(server_address)
+ return sock
+
+ __call__ = connect
diff --git a/src/client/__init__.py b/src/client/__init__.py
index eadf5a530..1cb67b3b6 100644
--- a/src/client/__init__.py
+++ b/src/client/__init__.py
@@ -1,38 +1,14 @@
-# Initialize logging system
import logging
-from PyQt5.QtNetwork import QNetworkAccessManager
-from enum import IntEnum
-
-from config import Settings
-
-logger = logging.getLogger(__name__)
-# logger.setLevel(logging.DEBUG)
-
-# Initialize all important globals
-LOBBY_HOST = Settings.get('lobby/host')
-LOBBY_PORT = Settings.get('lobby/port')
-LOCAL_REPLAY_PORT = Settings.get('lobby/relay/port')
-
-
-class ClientState(IntEnum):
- """
- Various states the client can be in.
- """
- SHUTDOWN = -666 # Going... DOWN!
-
- DISCONNECTED = -2
- CONNECTING = -1
- NONE = 0
- CONNECTED = 1
- LOGGED_IN = 2
-
+# Do not remove - promoted widget, py2exe does not include it otherwise
+from client.theme_menu import ThemeMenu
from ._clientwindow import ClientWindow
-# Do not remove - promoted widget, py2exe does not include it otherwise
-from client.theme_menu import ThemeMenu
+__all__ = (
+ "ThemeMenu",
+)
-instance = ClientWindow()
+logger = logging.getLogger(__name__)
-NetworkManager = QNetworkAccessManager(instance)
+instance = ClientWindow()
diff --git a/src/client/_clientwindow.py b/src/client/_clientwindow.py
index 5fcb79d86..ebbd2f6f0 100644
--- a/src/client/_clientwindow.py
+++ b/src/client/_clientwindow.py
@@ -1,214 +1,311 @@
-from PyQt5 import QtCore, QtWidgets, QtGui
+import logging
+import time
+from functools import partial
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtNetwork import QNetworkAccessManager
import config
-import connectivity
-from connectivity.helper import ConnectivityHelper
-from client import ClientState, LOBBY_HOST, LOBBY_PORT, LOCAL_REPLAY_PORT
+import fa
+import notifications as ns
+import util
+import util.crash
+from chat import ChatMVC
+from chat._avatarWidget import AvatarWidget
+from chat.channel_autojoiner import ChannelAutojoiner
+from chat.chat_announcer import ChatAnnouncer
+from chat.chat_controller import ChatController
+from chat.chat_greeter import ChatGreeter
+from chat.chat_view import ChatView
+from chat.chatter_model import ChatterLayoutElements
+from chat.ircconnection import IrcConnection
+from chat.language_channel_config import LanguageChannelConfig
+from chat.line_restorer import ChatLineRestorer
from client.aliasviewer import AliasSearchWindow
-from client.connection import LobbyInfo, ServerConnection, \
- Dispatcher, ConnectionState, ServerReconnecter
+from client.aliasviewer import AliasWindow
+from client.chat_config import ChatConfig
+from client.clientstate import ClientState
+from client.connection import ConnectionState
+from client.connection import Dispatcher
+from client.connection import LobbyInfo
+from client.connection import ServerConnection
+from client.connection import ServerReconnecter
from client.gameannouncer import GameAnnouncer
-from client.kick_dialog import KickDialog
from client.login import LoginWidget
from client.playercolors import PlayerColors
from client.theme_menu import ThemeMenu
-from client.updater import UpdateChecker, UpdateDialog
-from client.update_settings import UpdateSettingsDialog
from client.user import User
-from downloadManager import PreviewDownloader, MAP_PREVIEW_ROOT
-import fa
+from client.user import UserRelationController
+from client.user import UserRelationModel
+from client.user import UserRelations
+from client.user import UserRelationTrackers
+from connectivity.ConnectivityDialog import ConnectivityDialog
+from coop import CoopWidget
+from downloadManager import AvatarDownloader
+from downloadManager import MapSmallPreviewDownloader
from fa.factions import Factions
+from fa.game_runner import GameRunner
+from fa.game_session import GameSession
from fa.maps import getUserMapsFolder
-from functools import partial
-from games.gamemodel import GameModel
+from games import GamesWidget
from games.gameitem import GameViewBuilder
+from games.gamemodel import GameModel
from games.hostgamewidget import build_launcher
-import json
+from mapGenerator.mapgenManager import MapGeneratorManager
+from model.chat.channel import ChannelID
+from model.chat.channel import ChannelType
+from model.chat.chat import Chat
+from model.chat.chatline import ChatLineMetadataBuilder
from model.gameset import Gameset
+from model.gameset import PlayerGameIndex
from model.player import Player
from model.playerset import Playerset
-from modvault.utils import MODFOLDER
-import notifications as ns
+from model.rating import MatchmakerQueueType
+from model.rating import RatingType
+from news import NewsWidget
+from oauth.oauth_flow import OAuth2FlowInstance
+from power import PowerTools
+from replays import ReplaysWidget
from secondaryServer import SecondaryServer
-import time
-import util
-from ui.status_logo import StatusLogo
+from stats import StatsWidget
from ui.busy_widget import BusyWidget
+from ui.status_logo import StatusLogo
+from unitdb.unitdbtab import UnitDBTab
+from updater import ClientUpdateTools
+from vaults.mapvault.mapvault import MapVault
+from vaults.modvault.modvault import ModVault
+from vaults.modvault.utils import getModFolder
+from vaults.modvault.utils import setModFolder
-'''
-Created on Dec 1, 2011
-
-@author: thygrrr
-'''
+from .mouse_position import MousePosition
-import logging
logger = logging.getLogger(__name__)
FormClass, BaseClass = util.THEME.loadUiType("client/client.ui")
-class mousePosition(object):
- def __init__(self, parent):
- self.parent = parent
- self.onLeftEdge = False
- self.onRightEdge = False
- self.onTopEdge = False
- self.onBottomEdge = False
- self.cursorShapeChange = False
- self.warning_buttons = dict()
- self.onEdges = False
-
- def computeMousePosition(self, pos):
- self.onLeftEdge = pos.x() < 8
- self.onRightEdge = pos.x() > self.parent.size().width() - 8
- self.onTopEdge = pos.y() < 8
- self.onBottomEdge = pos.y() > self.parent.size().height() - 8
-
- self.onTopLeftEdge = self.onTopEdge and self.onLeftEdge
- self.onBottomLeftEdge = self.onBottomEdge and self.onLeftEdge
- self.onTopRightEdge = self.onTopEdge and self.onRightEdge
- self.onBottomRightEdge = self.onBottomEdge and self.onRightEdge
-
- self.onEdges = self.onLeftEdge or self.onRightEdge or self.onTopEdge or self.onBottomEdge
-
- def resetToFalse(self):
- self.onLeftEdge = False
- self.onRightEdge = False
- self.onTopEdge = False
- self.onBottomEdge = False
- self.cursorShapeChange = False
-
- def isOnEdge(self):
- return self.onEdges
-
-
class ClientWindow(FormClass, BaseClass):
"""
- This is the main lobby client that manages the FAF-related connection and data,
- in particular players, games, ranking, etc.
+ This is the main lobby client that manages the FAF-related connection and
+ data, in particular players, games, ranking, etc.
Its UI also houses all the other UIs for the sub-modules.
"""
state_changed = QtCore.pyqtSignal(object)
authorized = QtCore.pyqtSignal(object)
- # These signals notify connected modules of game state changes (i.e. reasons why FA is launched)
- viewingReplay = QtCore.pyqtSignal(QtCore.QUrl)
+ # These signals notify connected modules of game state changes
+ # (i.e. reasons why FA is launched)
+ viewing_replay = QtCore.pyqtSignal(object)
# Game state controls
- gameEnter = QtCore.pyqtSignal()
- gameExit = QtCore.pyqtSignal()
- gameFull = QtCore.pyqtSignal()
+ game_enter = QtCore.pyqtSignal()
+ game_exit = QtCore.pyqtSignal()
+ game_full = QtCore.pyqtSignal()
# These signals propagate important client state changes to other modules
- localBroadcast = QtCore.pyqtSignal(str, str)
- autoJoin = QtCore.pyqtSignal(list)
- channelsUpdated = QtCore.pyqtSignal(list)
+ local_broadcast = QtCore.pyqtSignal(str, str)
+ auto_join = QtCore.pyqtSignal(list)
+ channels_updated = QtCore.pyqtSignal(list)
+ unofficial_client = QtCore.pyqtSignal(str)
+
+ matchmaker_info = QtCore.pyqtSignal(dict)
+ party_invite = QtCore.pyqtSignal(dict)
- matchmakerInfo = QtCore.pyqtSignal(dict)
+ remember = config.Settings.persisted_property(
+ 'user/remember', type=bool, default_value=True,
+ )
+ refresh_token = config.Settings.persisted_property(
+ 'user/refreshToken', persist_if=lambda self: self.remember,
+ )
- remember = config.Settings.persisted_property('user/remember', type=bool, default_value=True)
- login = config.Settings.persisted_property('user/login', persist_if=lambda self: self.remember)
- password = config.Settings.persisted_property('user/password', persist_if=lambda self: self.remember)
+ game_logs = config.Settings.persisted_property(
+ 'game/logs', type=bool, default_value=True,
+ )
- gamelogs = config.Settings.persisted_property('game/logs', type=bool, default_value=True)
- useUPnP = config.Settings.persisted_property('game/upnp', type=bool, default_value=True)
- gamePort = config.Settings.persisted_property('game/port', type=int, default_value=6112)
+ use_chat = config.Settings.persisted_property(
+ 'chat/enabled', type=bool, default_value=True,
+ )
def __init__(self, *args, **kwargs):
- BaseClass.__init__(self, *args, **kwargs)
+ super(ClientWindow, self).__init__(*args, **kwargs)
logger.debug("Client instantiating")
# Hook to Qt's application management system
QtWidgets.QApplication.instance().aboutToQuit.connect(self.cleanup)
+ QtWidgets.QApplication.instance().applicationStateChanged.connect(
+ self.appStateChanged,
+ )
- self.uniqueId = None
+ self._network_access_manager = QNetworkAccessManager(self)
+ self.oauth_flow = OAuth2FlowInstance
+ self.oauth_flow.setParent(self)
+ self.oauth_flow.granted.connect(self.do_connect)
+ self.oauth_flow.granted.connect(self.save_refresh_token)
+ self.oauth_flow.requestFailed.connect(self.show_login_widget)
- self.sendFile = False
+ self.unique_id = None
+ self._chat_config = ChatConfig(util.settings)
+
+ self.send_file = False
self.warning_buttons = {}
# Tray icon
self.tray = QtWidgets.QSystemTrayIcon()
self.tray.setIcon(util.THEME.icon("client/tray_icon.png"))
+ self.tray.setToolTip("FAF Python Client")
+ self.tray.activated.connect(self.handle_tray_icon_activation)
+ tray_menu = QtWidgets.QMenu()
+ tray_menu.addAction("Open Client", self.show_normal)
+ tray_menu.addAction("Quit Client", self.close)
+ self.tray.setContextMenu(tray_menu)
+ # Mouse down on tray icon deactivates the application.
+ # So there is no way to know for sure if the tray icon was clicked from
+ # active application or from inactive application. So we assume that
+ # if the application was deactivated less than 0.5s ago, then the tray
+ # icon click (both left or right button) was made from the active app.
+ self._lastDeactivateTime = None
+ self.keepActiveForTrayIcon = 0.5
self.tray.show()
self._state = ClientState.NONE
self.session = None
+ self.game_session = None
# This dictates whether we login automatically in the beginning or
# after a disconnect. We turn it on if we're sure we have correct
# credentials and want to use them (if we were remembered or after
# login) and turn it off if we're getting fresh credentials or
# encounter a serious server error.
- self._autorelogin = self.remember
+ self._auto_relogin = self.remember
self.lobby_dispatch = Dispatcher()
- self.lobby_connection = ServerConnection(LOBBY_HOST, LOBBY_PORT,
- self.lobby_dispatch.dispatch)
- self.lobby_connection.state_changed.connect(self.on_connection_state_changed)
- self.lobby_reconnecter = ServerReconnecter(self.lobby_connection)
+ self.lobby_connection = ServerConnection(
+ config.Settings.get('lobby/host'),
+ config.Settings.get('lobby/port', type=int),
+ self.lobby_dispatch.dispatch,
+ )
+ self.lobby_connection.state_changed.connect(
+ self.on_connection_state_changed,
+ )
+ self.lobby_reconnector = ServerReconnecter(self.lobby_connection)
self.players = Playerset() # Players known to the client
-
self.gameset = Gameset(self.players)
- fa.instance.gameset = self.gameset # FIXME (needed fa/game_process L81 for self.game = self.gameset[uid])
+ self._player_game_relation = PlayerGameIndex(
+ self.gameset, self.players,
+ )
+
+ # FIXME (needed fa/game_process L81 for self.game = self.gameset[uid])
+ fa.instance.gameset = self.gameset
+
+ self.lobby_info = LobbyInfo(
+ self.lobby_dispatch, self.gameset, self.players,
+ )
# Handy reference to the User object representing the logged-in user.
self.me = User(self.players)
-
- self.map_downloader = PreviewDownloader(util.MAP_PREVIEW_DIR, MAP_PREVIEW_ROOT)
- self.mod_downloader = PreviewDownloader(util.MOD_PREVIEW_DIR, None)
+ self.login = None
+ self.id = None
+
+ self._chat_model = Chat.build(
+ playerset=self.players,
+ base_channels=['#aeolus'],
+ )
+
+ relation_model = UserRelationModel.build()
+ relation_controller = UserRelationController.build(
+ relation_model,
+ me=self.me,
+ settings=config.Settings,
+ lobby_info=self.lobby_info,
+ lobby_connection=self.lobby_connection,
+ )
+ relation_trackers = UserRelationTrackers.build(
+ relation_model,
+ playerset=self.players,
+ chatterset=self._chat_model.chatters,
+ )
+ self.user_relations = UserRelations(
+ relation_model, relation_controller, relation_trackers,
+ )
+ self.me.relations = self.user_relations
+
+ self.map_preview_downloader = MapSmallPreviewDownloader(util.MAP_PREVIEW_SMALL_DIR)
+ self.avatar_downloader = AvatarDownloader()
+
+ # Map generator
+ self.map_generator = MapGeneratorManager()
# Qt model for displaying active games.
- self.game_model = GameModel(self.me, self.map_downloader, self.gameset)
+ self.game_model = GameModel(self.me, self.map_preview_downloader, self.gameset)
- self.lobby_info = LobbyInfo(self.lobby_dispatch, self.gameset, self.players)
- self.gameset.newGame.connect(self.fill_in_session_info)
+ self.gameset.added.connect(self.fill_in_session_info)
- self.lobby_dispatch["session"] = self.handle_session
- self.lobby_dispatch["registration_response"] = self.handle_registration_response
+ self.lobby_info.serverSession.connect(self.handle_session)
+ self.lobby_dispatch["registration_response"] = (
+ self.handle_registration_response
+ )
self.lobby_dispatch["game_launch"] = self.handle_game_launch
self.lobby_dispatch["matchmaker_info"] = self.handle_matchmaker_info
- self.lobby_dispatch["social"] = self.handle_social
self.lobby_dispatch["player_info"] = self.handle_player_info
self.lobby_dispatch["notice"] = self.handle_notice
self.lobby_dispatch["invalid"] = self.handle_invalid
- self.lobby_dispatch["update"] = self.handle_update
self.lobby_dispatch["welcome"] = self.handle_welcome
- self.lobby_dispatch["authentication_failed"] = self.handle_authentication_failed
+ self.lobby_dispatch["authentication_failed"] = (
+ self.handle_authentication_failed
+ )
+ self.lobby_dispatch["irc_password"] = self.handle_irc_password
+ self.lobby_dispatch["update_party"] = self.handle_update_party
+ self.lobby_dispatch["kicked_from_party"] = (
+ self.handle_kicked_from_party
+ )
+ self.lobby_dispatch["party_invite"] = self.handle_party_invite
+ 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_info.social.connect(self.handle_social)
# Process used to run Forged Alliance (managed in module fa)
- fa.instance.started.connect(self.startedFA)
- fa.instance.finished.connect(self.finishedFA)
- fa.instance.error.connect(self.errorFA)
- self.gameset.newGame.connect(fa.instance.newServerGame)
+ fa.instance.started.connect(self.started_fa)
+ fa.instance.finished.connect(self.finished_fa)
+ fa.instance.errorOccurred.connect(self.error_fa)
+ self.gameset.added.connect(fa.instance.newServerGame)
# Local Replay Server
self.replayServer = fa.replayserver.ReplayServer(self)
- # GameSession
- self.game_session = None # type: fa.GameSession
-
# ConnectivityTest
- self.connectivity = None # type: ConnectivityHelper
+ self.connectivity = None # type - ConnectivityHelper
# stat server
- self.statsServer = SecondaryServer("Statistic", 11002, self.lobby_dispatch)
+ self.statsServer = SecondaryServer(
+ "Statistic", 11002, self.lobby_dispatch,
+ )
# create user interface (main window) and load theme
self.setupUi(self)
- util.THEME.setStyleSheet(self, "client/client.css")
+ util.THEME.stylesheets_reloaded.connect(self.load_stylesheet)
+ self.load_stylesheet()
- self.setWindowTitle("FA Forever " + util.VERSION_STRING)
+ self.setWindowTitle("FA Forever {}".format(util.VERSION_STRING))
# Frameless
- self.setWindowFlags(QtCore.Qt.FramelessWindowHint | QtCore.Qt.CustomizeWindowHint)
+ self.setWindowFlags(
+ QtCore.Qt.WindowType.FramelessWindowHint
+ | QtCore.Qt.WindowType.WindowSystemMenuHint
+ | QtCore.Qt.WindowType.WindowMinimizeButtonHint,
+ )
- self.rubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle)
+ self.rubber_band = QtWidgets.QRubberBand(
+ QtWidgets.QRubberBand.Shape.Rectangle,
+ )
- self.mousePosition = mousePosition(self)
- self.installEventFilter(self)
+ self.mouse_position = MousePosition(self)
+ self.installEventFilter(self) # register events
self.minimize = QtWidgets.QToolButton(self)
self.minimize.setIcon(util.THEME.icon("client/minimize-button.png"))
@@ -231,87 +328,117 @@ def __init__(self, *args, **kwargs):
self.maximize.setProperty("windowControlBtn", True)
self.minimize.setProperty("windowControlBtn", True)
- self.logo = StatusLogo(self)
- self.logo.disconnect_requested.connect(self.disconnect)
- self.logo.reconnect_requested.connect(self.reconnect)
- self.logo.about_dialog_requested.connect(self.linkAbout)
- self.logo.connectivity_dialog_requested.connect(self.connectivityDialog)
-
self.menu = self.menuBar()
- self.topLayout.addWidget(self.logo)
- titleLabel = QtWidgets.QLabel("FA Forever" if not config.is_beta() else "FA Forever BETA")
- titleLabel.setProperty('titleLabel', True)
- self.topLayout.addWidget(titleLabel)
+ title_label = QtWidgets.QLabel(
+ "FA Forever" if not config.is_beta() else "FA Forever BETA",
+ )
+ title_label.setProperty('titleLabel', True)
+ self.topLayout.addWidget(title_label)
self.topLayout.addStretch(500)
self.topLayout.addWidget(self.menu)
self.topLayout.addWidget(self.minimize)
self.topLayout.addWidget(self.maximize)
self.topLayout.addWidget(close)
self.topLayout.setSpacing(0)
- self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Fixed)
- self.maxNormal = False
+ self.setSizePolicy(
+ QtWidgets.QSizePolicy.Policy.Expanding, QtWidgets.QSizePolicy.Policy.Fixed,
+ )
+ self.is_window_maximized = False
close.clicked.connect(self.close)
- self.minimize.clicked.connect(self.showSmall)
- self.maximize.clicked.connect(self.showMaxRestore)
+ self.minimize.clicked.connect(self.showMinimized)
+ self.maximize.clicked.connect(self.show_max_restore)
self.moving = False
self.dragging = False
- self.draggingHover = False
+ self.dragging_hover = False
self.offset = None
- self.curSize = None
+ self.current_geometry = None
- sizeGrip = QtWidgets.QSizeGrip(self)
- self.mainGridLayout.addWidget(sizeGrip, 2, 2)
+ self.mainGridLayout.addWidget(QtWidgets.QSizeGrip(self), 2, 2)
# Wire all important signals
self._main_tab = -1
- self.mainTabs.currentChanged.connect(self.mainTabChanged)
+ self.mainTabs.currentChanged.connect(self.main_tab_changed)
self._vault_tab = -1
- self.topTabs.currentChanged.connect(self.vaultTabChanged)
+ self.topTabs.currentChanged.connect(self.vault_tab_changed)
- self.player_colors = PlayerColors(self.me)
+ self.player_colors = PlayerColors(
+ self.me, self.user_relations.model, util.THEME,
+ )
- self.game_announcer = GameAnnouncer(self.gameset, self.me,
- self.player_colors, self)
+ self.game_announcer = GameAnnouncer(
+ self.gameset, self.me, self.player_colors,
+ )
self.power = 0 # current user power
self.id = 0
# Initialize the Menu Bar according to settings etc.
+ self._language_channel_config = LanguageChannelConfig(
+ self, config.Settings, util.THEME,
+ )
self.initMenus()
# Load the icons for the tabs
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.whatNewTab), util.THEME.icon("client/feed.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.chatTab), util.THEME.icon("client/chat.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.gamesTab), util.THEME.icon("client/games.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.coopTab), util.THEME.icon("client/coop.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.vaultsTab), util.THEME.icon("client/mods.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.ladderTab), util.THEME.icon("client/ladder.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.tourneyTab), util.THEME.icon("client/tourney.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.unitdbTab), util.THEME.icon("client/twitch.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.replaysTab), util.THEME.icon("client/replays.png"))
- self.mainTabs.setTabIcon(self.mainTabs.indexOf(self.tutorialsTab), util.THEME.icon("client/tutorials.png"))
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.whatNewTab),
+ util.THEME.icon("client/feed.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.chatTab),
+ util.THEME.icon("client/chat.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.gamesTab),
+ util.THEME.icon("client/games.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.coopTab),
+ util.THEME.icon("client/coop.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.vaultsTab),
+ util.THEME.icon("client/mods.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.ladderTab),
+ util.THEME.icon("client/ladder.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.tourneyTab),
+ util.THEME.icon("client/tourney.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.unitdbTab),
+ util.THEME.icon("client/unitdb.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.replaysTab),
+ util.THEME.icon("client/replays.png"),
+ )
+ self.mainTabs.setTabIcon(
+ self.mainTabs.indexOf(self.tutorialsTab),
+ util.THEME.icon("client/tutorials.png"),
+ )
# for moderator
- self.modMenu = None
-
- self._alias_window = AliasSearchWindow(self)
- #self.nFrame = NewsFrame()
- #self.whatsNewLayout.addWidget(self.nFrame)
- #self.nFrame.collapse()
-
- #self.nFrame = NewsFrame()
- #self.whatsNewLayout.addWidget(self.nFrame)
+ self.mod_menu = None
+ self.power_tools = PowerTools.build(
+ playerset=self.players,
+ lobby_connection=self.lobby_connection,
+ theme=util.THEME,
+ parent_widget=self,
+ settings=config.Settings,
+ )
- #self.nFrame = NewsFrame()
- #self.whatsNewLayout.addWidget(self.nFrame)
+ self._alias_viewer = AliasWindow.build(parent_widget=self)
+ self._alias_search_window = AliasSearchWindow(self, self._alias_viewer)
+ self._game_runner = GameRunner(self.gameset, self)
+ self.connectivity_dialog = None
- #self.WPApi = WPAPI(self)
- #self.WPApi.newsDone.connect(self.on_wpapi_done)
- #self.WPApi.download()
-
- #self.controlsContainerLayout.setAlignment(self.pageControlFrame, QtCore.Qt.AlignRight)
+ def load_stylesheet(self):
+ self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
@property
def state(self):
@@ -337,241 +464,381 @@ def on_connection_state_changed(self, state):
def on_connected(self):
# Enable reconnect in case we used to explicitly stay offline
- self.lobby_reconnecter.enabled = True
-
- self.lobby_connection.send(dict(command="ask_session",
- version=config.VERSION,
- user_agent="faf-client"))
+ self.lobby_reconnector.enabled = True
+ self.lobby_connection.send(
+ dict(
+ command="ask_session",
+ version=config.VERSION,
+ user_agent="faf-client",
+ ),
+ )
def on_disconnected(self):
logger.warning("Disconnected from lobby server.")
self.gameset.clear()
self.clear_players()
+ self.games.stopSearch()
- @QtCore.pyqtSlot(bool)
- def on_actionSavegamelogs_toggled(self, value):
- self.gamelogs = value
-
- @QtCore.pyqtSlot(bool)
- def on_actionAutoDownloadMods_toggled(self, value):
- config.Settings.set('mods/autodownload', value is True)
-
- @QtCore.pyqtSlot(bool)
- def on_actionAutoDownloadMaps_toggled(self, value):
- config.Settings.set('maps/autodownload', value is True)
+ def appStateChanged(self, state):
+ if state == QtCore.Qt.ApplicationState.ApplicationInactive:
+ self._lastDeactivateTime = time.monotonic()
def eventFilter(self, obj, event):
- if event.type() == QtCore.QEvent.HoverMove:
- self.draggingHover = self.dragging
+ if event.type() == QtCore.QEvent.Type.HoverMove:
+ self.dragging_hover = self.dragging
if self.dragging:
- self.resizeWidget(self.mapToGlobal(event.pos()))
+ self.resize_widget(self.mapToGlobal(event.position()))
else:
- if self.maxNormal == False:
- self.mousePosition.computeMousePosition(event.pos())
+ if not self.is_window_maximized:
+ self.mouse_position.update_mouse_position(event.position())
else:
- self.mousePosition.resetToFalse()
- self.updateCursorShape(event.pos())
+ self.mouse_position.reset_to_false()
+ self.update_cursor_shape()
return False
- def updateCursorShape(self, pos):
- if self.mousePosition.onTopLeftEdge or self.mousePosition.onBottomRightEdge:
- self.mousePosition.cursorShapeChange = True
- self.setCursor(QtCore.Qt.SizeFDiagCursor)
- elif self.mousePosition.onTopRightEdge or self.mousePosition.onBottomLeftEdge:
- self.setCursor(QtCore.Qt.SizeBDiagCursor)
- self.mousePosition.cursorShapeChange = True
- elif self.mousePosition.onLeftEdge or self.mousePosition.onRightEdge:
- self.setCursor(QtCore.Qt.SizeHorCursor)
- self.mousePosition.cursorShapeChange = True
- elif self.mousePosition.onTopEdge or self.mousePosition.onBottomEdge:
- self.setCursor(QtCore.Qt.SizeVerCursor)
- self.mousePosition.cursorShapeChange = True
+ def update_cursor_shape(self):
+ if (
+ self.mouse_position.on_top_left_edge
+ or self.mouse_position.on_bottom_right_edge
+ ):
+ self.mouse_position.cursor_shape_change = True
+ self.setCursor(QtCore.Qt.CursorShape.SizeFDiagCursor)
+ elif (
+ self.mouse_position.on_top_right_edge
+ or self.mouse_position.on_bottom_left_edge
+ ):
+ self.setCursor(QtCore.Qt.CursorShape.SizeBDiagCursor)
+ self.mouse_position.cursor_shape_change = True
+ elif (
+ self.mouse_position.on_left_edge
+ or self.mouse_position.on_right_edge
+ ):
+ self.setCursor(QtCore.Qt.CursorShape.SizeHorCursor)
+ self.mouse_position.cursor_shape_change = True
+ elif (
+ self.mouse_position.on_top_edge
+ or self.mouse_position.on_bottom_edge
+ ):
+ self.setCursor(QtCore.Qt.CursorShape.SizeVerCursor)
+ self.mouse_position.cursor_shape_change = True
else:
- if self.mousePosition.cursorShapeChange == True:
+ if self.mouse_position.cursor_shape_change:
self.unsetCursor()
- self.mousePosition.cursorShapeChange = False
-
- def showSmall(self):
- self.showMinimized()
+ self.mouse_position.cursor_shape_change = False
+
+ def handle_tray_icon_activation(
+ self,
+ reason: QtWidgets.QSystemTrayIcon.ActivationReason,
+ ) -> None:
+ if reason is QtWidgets.QSystemTrayIcon.ActivationReason.Trigger:
+ if self._lastDeactivateTime is None:
+ self.showMinimized()
+ return
- def showMaxRestore(self):
- if (self.maxNormal):
- self.maxNormal = False
- if self.curSize:
- self.setGeometry(self.curSize)
+ inactiveTime = time.monotonic() - self._lastDeactivateTime
+ if (
+ self.isMinimized()
+ or inactiveTime >= self.keepActiveForTrayIcon
+ ):
+ self.show_normal()
+ else:
+ self.showMinimized()
+ elif reason is QtWidgets.QSystemTrayIcon.ActivationReason.Context:
+ position = QtGui.QCursor.pos()
+ position.setY(position.y() - self.tray.contextMenu().height())
+ self.tray.contextMenu().popup(position)
+
+ def show_normal(self):
+ self.showNormal()
+ self.activateWindow()
+
+ def show_max_restore(self):
+ if self.is_window_maximized:
+ self.is_window_maximized = False
+ if self.current_geometry:
+ self.setGeometry(self.current_geometry)
else:
- self.maxNormal = True
- self.curSize = self.geometry()
- self.setGeometry(QtWidgets.QDesktopWidget().availableGeometry(self))
+ self.is_window_maximized = True
+ self.current_geometry = self.geometry()
+ self.setGeometry(self.screen().availableGeometry())
def mouseDoubleClickEvent(self, event):
- self.showMaxRestore()
+ self.show_max_restore()
def mouseReleaseEvent(self, event):
self.dragging = False
self.moving = False
- if self.rubberBand.isVisible():
- self.maxNormal = True
- self.curSize = self.geometry()
- self.setGeometry(self.rubberBand.geometry())
- self.rubberBand.hide()
- # self.showMaxRestore()
+ if self.rubber_band.isVisible():
+ self.is_window_maximized = True
+ self.current_geometry = self.geometry()
+ self.setGeometry(self.rubber_band.geometry())
+ self.rubber_band.hide()
+ # self.show_max_restore()
def mousePressEvent(self, event):
- if event.button() == QtCore.Qt.LeftButton:
- if self.mousePosition.isOnEdge() and not self.maxNormal:
+ if event.button() == QtCore.Qt.MouseButton.LeftButton:
+ if (
+ self.mouse_position.is_on_edge()
+ and not self.is_window_maximized
+ ):
self.dragging = True
return
else:
self.dragging = False
self.moving = True
- self.offset = event.pos()
+ self.offset = event.position()
def mouseMoveEvent(self, event):
- if self.dragging and self.draggingHover == False:
- self.resizeWidget(event.globalPos())
+ if self.dragging and not self.dragging_hover:
+ self.resize_widget(event.globalPosition())
elif self.moving and self.offset is not None:
- desktop = QtWidgets.QDesktopWidget().availableGeometry(self)
- if event.globalPos().y() == 0:
- self.rubberBand.setGeometry(desktop)
- self.rubberBand.show()
- elif event.globalPos().x() == 0:
+ desktop = self.screen().availableGeometry()
+ if event.globalPosition().y() == 0:
+ self.rubber_band.setGeometry(desktop)
+ self.rubber_band.show()
+ elif event.globalPosition().x() == 0:
desktop.setRight(desktop.right() / 2.0)
- self.rubberBand.setGeometry(desktop)
- self.rubberBand.show()
- elif event.globalPos().x() == desktop.right():
+ self.rubber_band.setGeometry(desktop)
+ self.rubber_band.show()
+ elif event.globalPosition().x() == desktop.right():
desktop.setRight(desktop.right() / 2.0)
desktop.moveLeft(desktop.right())
- self.rubberBand.setGeometry(desktop)
- self.rubberBand.show()
+ self.rubber_band.setGeometry(desktop)
+ self.rubber_band.show()
else:
- self.rubberBand.hide()
- if self.maxNormal:
- self.showMaxRestore()
-
- self.move(event.globalPos() - self.offset)
-
- def resizeWidget(self, globalMousePos):
- if globalMousePos.y() == 0:
- self.rubberBand.setGeometry(QtWidgets.QDesktopWidget().availableGeometry(self))
- self.rubberBand.show()
+ self.rubber_band.hide()
+ if self.is_window_maximized:
+ self.show_max_restore()
+
+ point_f = event.globalPosition() - self.offset
+ self.move(point_f.toPoint())
+
+ def resize_widget(self, mouse_position: QtCore.QRectF) -> None:
+ mouse_point = mouse_position.toPoint()
+ if mouse_point.y() == 0:
+ self.rubber_band.setGeometry(self.screen().availableGeometry())
+ self.rubber_band.show()
else:
- self.rubberBand.hide()
-
- origRect = self.frameGeometry()
-
- left, top, right, bottom = origRect.getCoords()
- minWidth = self.minimumWidth()
- minHeight = self.minimumHeight()
- if self.mousePosition.onTopLeftEdge:
- left = globalMousePos.x()
- top = globalMousePos.y()
-
- elif self.mousePosition.onBottomLeftEdge:
- left = globalMousePos.x()
- bottom = globalMousePos.y()
- elif self.mousePosition.onTopRightEdge:
- right = globalMousePos.x()
- top = globalMousePos.y()
- elif self.mousePosition.onBottomRightEdge:
- right = globalMousePos.x()
- bottom = globalMousePos.y()
- elif self.mousePosition.onLeftEdge:
- left = globalMousePos.x()
- elif self.mousePosition.onRightEdge:
- right = globalMousePos.x()
- elif self.mousePosition.onTopEdge:
- top = globalMousePos.y()
- elif self.mousePosition.onBottomEdge:
- bottom = globalMousePos.y()
-
- newRect = QtCore.QRect(QtCore.QPoint(left, top), QtCore.QPoint(right, bottom))
- if newRect.isValid():
- if minWidth > newRect.width():
- if left != origRect.left():
- newRect.setLeft(origRect.left())
+ self.rubber_band.hide()
+
+ orig_rect = self.frameGeometry()
+
+ left, top, right, bottom = orig_rect.getCoords()
+ min_width = self.minimumWidth()
+ min_height = self.minimumHeight()
+ if self.mouse_position.on_top_left_edge:
+ left = mouse_point.x()
+ top = mouse_point.y()
+ elif self.mouse_position.on_bottom_left_edge:
+ left = mouse_point.x()
+ bottom = mouse_point.y()
+ elif self.mouse_position.on_top_right_edge:
+ right = mouse_point.x()
+ top = mouse_point.y()
+ elif self.mouse_position.on_bottom_right_edge:
+ right = mouse_point.x()
+ bottom = mouse_point.y()
+ elif self.mouse_position.on_left_edge:
+ left = mouse_point.x()
+ elif self.mouse_position.on_right_edge:
+ right = mouse_point.x()
+ elif self.mouse_position.on_top_edge:
+ top = mouse_point.y()
+ elif self.mouse_position.on_bottom_edge:
+ bottom = mouse_point.y()
+
+ new_rect = QtCore.QRect(
+ QtCore.QPoint(left, top),
+ QtCore.QPoint(right, bottom),
+ )
+ if new_rect.isValid():
+ if min_width > new_rect.width():
+ if left != orig_rect.left():
+ new_rect.setLeft(orig_rect.left())
else:
- newRect.setRight(origRect.right())
- if minHeight > newRect.height():
- if top != origRect.top():
- newRect.setTop(origRect.top())
+ new_rect.setRight(orig_rect.right())
+ if min_height > new_rect.height():
+ if top != orig_rect.top():
+ new_rect.setTop(orig_rect.top())
else:
- newRect.setBottom(origRect.bottom())
+ new_rect.setBottom(orig_rect.bottom())
- self.setGeometry(newRect)
+ self.setGeometry(new_rect)
def setup(self):
- from news import NewsWidget
- from chat import ChatWidget
- from coop import CoopWidget
- from games import GamesWidget
- from tutorials import TutorialsWidget
- from stats import StatsWidget
- from tourneys import TournamentsWidget
- from vault import MapVault
- from modvault import ModVault
- from replays import ReplaysWidget
- from chat._avatarWidget import AvatarWidget
-
- self.loadSettings()
-
- self.gameview_builder = GameViewBuilder(self.me,
- self.player_colors)
- self.game_launcher = build_launcher(self.players, self.me,
- self, self.gameview_builder,
- self.map_downloader)
+ self.load_settings()
+ self._chat_config.channel_blink_interval = 500
+ self._chat_config.channel_ping_timeout = 60 * 1000
+ self._chat_config.max_chat_lines = 200
+ self._chat_config.chat_line_trim_count = 50
+ self._chat_config.announcement_channels = ['#aeolus']
+ self._chat_config.channels_to_greet_in = ['#aeolus']
+ self._chat_config.newbie_channel_game_threshold = 50
+
+ wiki_link = util.Settings.get("WIKI_URL")
+ wiki_formatter = "Check out the wiki: {} for help with common issues."
+ wiki_msg = wiki_formatter.format(wiki_link)
+
+ self._chat_config.channel_greeting = [
+ ("Welcome to Forged Alliance Forever!", "red", "+3"),
+ (wiki_msg, "white", "+1"),
+ ("", "black", "+1"),
+ ("", "black", "+1"),
+ ]
+
+ self.gameview_builder = GameViewBuilder(self.me, self.player_colors)
+ self.game_launcher = build_launcher(
+ self.players, self.me,
+ self, self.gameview_builder,
+ self.map_preview_downloader,
+ )
+ self._avatar_widget_builder = AvatarWidget.builder(
+ parent_widget=self,
+ lobby_connection=self.lobby_connection,
+ lobby_info=self.lobby_info,
+ avatar_dler=self.avatar_downloader,
+ theme=util.THEME,
+ )
+
+ chat_connection = IrcConnection.build(settings=config.Settings)
+ line_metadata_builder = ChatLineMetadataBuilder.build(
+ me=self.me,
+ user_relations=self.user_relations.model,
+ )
+
+ chat_controller = ChatController.build(
+ connection=chat_connection,
+ model=self._chat_model,
+ user_relations=self.user_relations.model,
+ chat_config=self._chat_config,
+ me=self.me,
+ line_metadata_builder=line_metadata_builder,
+ )
+
+ target_channel = ChannelID(ChannelType.PUBLIC, '#aeolus')
+ chat_view = ChatView.build(
+ target_viewed_channel=target_channel,
+ model=self._chat_model,
+ controller=chat_controller,
+ parent_widget=self,
+ theme=util.THEME,
+ chat_config=self._chat_config,
+ player_colors=self.player_colors,
+ me=self.me,
+ user_relations=self.user_relations,
+ power_tools=self.power_tools,
+ map_preview_dler=self.map_preview_downloader,
+ avatar_dler=self.avatar_downloader,
+ avatar_widget_builder=self._avatar_widget_builder,
+ alias_viewer=self._alias_viewer,
+ client_window=self,
+ game_runner=self._game_runner,
+ )
+
+ channel_autojoiner = ChannelAutojoiner.build(
+ base_channels=['#aeolus'],
+ model=self._chat_model,
+ controller=chat_controller,
+ settings=config.Settings,
+ lobby_info=self.lobby_info,
+ chat_config=self._chat_config,
+ me=self.me,
+ )
+ chat_greeter = ChatGreeter(
+ model=self._chat_model,
+ theme=util.THEME,
+ chat_config=self._chat_config,
+ line_metadata_builder=line_metadata_builder,
+ )
+ chat_restorer = ChatLineRestorer(self._chat_model)
+ chat_announcer = ChatAnnouncer(
+ model=self._chat_model,
+ chat_config=self._chat_config,
+ game_announcer=self.game_announcer,
+ line_metadata_builder=line_metadata_builder,
+ )
+
+ self._chatMVC = ChatMVC(
+ self._chat_model, line_metadata_builder,
+ chat_connection, chat_controller,
+ channel_autojoiner, chat_greeter,
+ chat_restorer, chat_announcer, chat_view,
+ )
+
+ self.authorized.connect(self._connect_chat)
+
+ self.logo = StatusLogo(self, self._chatMVC.model)
+ self.logo.disconnect_requested.connect(self.disconnect_)
+ self.logo.reconnect_requested.connect(self.reconnect)
+ self.logo.chat_reconnect_requested.connect(self.chat_reconnect)
+ self.logo.about_dialog_requested.connect(self.linkAbout)
+ self.logo.connectivity_dialog_requested.connect(
+ self.connectivityDialog,
+ )
+ self.topLayout.insertWidget(0, self.logo)
# build main window with the now active client
self.news = NewsWidget(self)
- self.chat = ChatWidget(self, self.players, self.me)
- self.coop = CoopWidget(self, self.game_model, self.me,
- self.gameview_builder, self.game_launcher)
- self.games = GamesWidget(self, self.game_model, self.me,
- self.gameview_builder, self.game_launcher)
- self.tutorials = TutorialsWidget(self)
+ self.coop = CoopWidget(
+ self, self.game_model, self.me,
+ self.gameview_builder, self.game_launcher,
+ )
+ self.games = GamesWidget(
+ self, self.game_model, self.me,
+ self.gameview_builder, self.game_launcher,
+ )
self.ladder = StatsWidget(self)
- self.tourneys = TournamentsWidget(self)
- self.replays = ReplaysWidget(self, self.lobby_dispatch,
- self.gameset, self.players)
+ self.replays = ReplaysWidget(
+ self, self.lobby_dispatch, self.gameset, self.players,
+ )
self.mapvault = MapVault(self)
self.modvault = ModVault(self)
- self.notificationSystem = ns.Notifications(self, self.gameset,
- self.players, self.me)
+ self.notificationSystem = ns.Notifications(
+ self, self.gameset, self.players, self.me,
+ )
- # TODO: some day when the tabs only do UI we'll have all this in the .ui file
+ self._unitdb = UnitDBTab()
+
+ # TODO: some day when the tabs only do UI we'll have all this in the
+ # .ui file
self.whatNewTab.layout().addWidget(self.news)
- self.chatTab.layout().addWidget(self.chat)
+ self.chatTab.layout().addWidget(self._chatMVC.view.widget.base)
self.coopTab.layout().addWidget(self.coop)
self.gamesTab.layout().addWidget(self.games)
- self.tutorialsTab.layout().addWidget(self.tutorials)
self.ladderTab.layout().addWidget(self.ladder)
- self.tourneyTab.layout().addWidget(self.tourneys)
self.replaysTab.layout().addWidget(self.replays)
- self.mapsTab.layout().addWidget(self.mapvault.ui)
+ self.mapsTab.layout().addWidget(self.mapvault)
+ self.unitdbTab.layout().addWidget(self._unitdb.db_widget)
self.modsTab.layout().addWidget(self.modvault)
- # set menu states
- self.actionNsEnabled.setChecked(self.notificationSystem.settings.enabled)
- # Other windows
- self.avatarAdmin = self.avatarSelection = AvatarWidget(self, None)
+ # TODO: hiding some non-functional tabs. Either prune them or implement
+ # something useful in them.
+ self.mainTabs.removeTab(self.mainTabs.indexOf(self.tutorialsTab))
+ self.mainTabs.removeTab(self.mainTabs.indexOf(self.tourneyTab))
- # units database (ex. live streams)
- # old unitDB
- self.unitdbWebView.setUrl(QtCore.QUrl(config.Settings.get("UNITDB_URL")))
+ self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.whatNewTab))
+
+ # set menu states
+ self.actionNsEnabled.setChecked(
+ self.notificationSystem.settings.enabled,
+ )
# warning setup
+ self.labelAutomatchInfo.hide()
self.warning = QtWidgets.QHBoxLayout()
self.warnPlayer = QtWidgets.QLabel(self)
self.warnPlayer.setText(
- "A player of your skill level is currently searching for a 1v1 game. Click a faction to join them! ")
- self.warnPlayer.setAlignment(QtCore.Qt.AlignHCenter)
- self.warnPlayer.setAlignment(QtCore.Qt.AlignVCenter)
+ "A player of your skill level is currently searching for a 1v1 "
+ "game. Click a faction to join them! ",
+ )
+ self.warnPlayer.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
+ self.warnPlayer.setAlignment(QtCore.Qt.AlignmentFlag.AlignVCenter)
self.warnPlayer.setProperty("warning", True)
self.warning.addStretch()
self.warning.addWidget(self.warnPlayer)
@@ -579,21 +846,36 @@ def setup(self):
def add_warning_button(faction):
button = QtWidgets.QToolButton(self)
button.setMaximumSize(25, 25)
- button.setIcon(util.THEME.icon("games/automatch/%s.png" % faction.to_name()))
- button.clicked.connect(partial(self.games.startSearchRanked, faction))
+ button.setIcon(
+ util.THEME.icon(
+ "games/automatch/{}.png".format(faction.to_name()),
+ ),
+ )
+ button.clicked.connect(partial(self.ladderWarningClicked, faction))
self.warning.addWidget(button)
return button
- self.warning_buttons = {faction: add_warning_button(faction) for faction in Factions}
+ self.warning_buttons = {
+ faction: add_warning_button(faction)
+ for faction in Factions
+ }
self.warning.addStretch()
self.mainGridLayout.addLayout(self.warning, 2, 0)
self.warningHide()
- self._update_checker = UpdateChecker(self)
- self._update_checker.finished.connect(self.update_checked)
- self._update_checker.start()
+ self._update_tools = ClientUpdateTools.build(
+ config.VERSION, self, self._network_access_manager,
+ )
+ self._update_tools.mandatory_update_aborted.connect(self.close)
+ self._update_tools.checker.check()
+
+ def _connect_chat(self, me):
+ if not self.use_chat:
+ return
+ self._chatMVC.connection.set_nick_and_username(me.login, f"{me.login}@FAF")
+ self._chatMVC.connection.begin_connection_process()
def warningHide(self):
"""
@@ -612,26 +894,22 @@ def warningShow(self):
i.show()
def reconnect(self):
- self._update_checker.start()
-
- self.lobby_reconnecter.enabled = True
- self.lobby_connection.doConnect()
-
- def disconnect(self):
- # Used when the user explicitly demanded to stay offline.
- self.lobby_reconnecter.enabled = False
- self.lobby_connection.disconnect()
- self.chat.disconnect()
-
- @QtCore.pyqtSlot(list)
- def update_checked(self, releases):
- if len(releases) > 0:
- update_dialog = UpdateDialog(self)
- update_dialog.setup(releases)
- update_dialog.show()
- else:
- QtWidgets.QMessageBox.information(self,"No updates found",
- "No client updates were found")
+ self.lobby_reconnector.enabled = True
+ self.try_to_auto_login()
+
+ def disconnect_(self):
+ if self.state != ClientState.DISCONNECTED:
+ # Used when the user explicitly demanded to stay offline.
+ self._auto_relogin = self.remember
+ self.lobby_reconnector.enabled = False
+ self.lobby_connection.disconnect_()
+ self._chatMVC.connection.disconnect_()
+ self.games.onLogOut()
+ self.oauth_flow.stop_checking_expiration()
+ config.Settings.set("oauth/token", None, persist=False)
+
+ def chat_reconnect(self):
+ self._connect_chat(self.me)
@QtCore.pyqtSlot()
def cleanup(self):
@@ -656,15 +934,19 @@ def cleanup(self):
fa.instance.close()
# Terminate Lobby Server connection
- self.lobby_reconnecter.enabled = False
+ self.lobby_reconnector.enabled = False
if self.lobby_connection.socket_connected():
progress.setLabelText("Closing main connection.")
- self.lobby_connection.disconnect()
+ self.lobby_connection.disconnect_()
- # Clear UPnP Mappings...
- if self.useUPnP:
- progress.setLabelText("Removing UPnP port mappings")
- fa.upnp.removePortMappings()
+ # Close connectivity dialog
+ if self.connectivity_dialog is not None:
+ self.connectivity_dialog.close()
+ self.connectivity_dialog = None
+ # Close game session (and stop faf-ice-adapter.exe)
+ if self.game_session is not None:
+ self.game_session.closeIceAdapter()
+ self.game_session = None
# Terminate local ReplayServer
if self.replayServer:
@@ -673,10 +955,16 @@ def cleanup(self):
self.replayServer = None
# Clean up Chat
- if self.chat:
+ if self._chatMVC:
progress.setLabelText("Disconnecting from IRC")
- self.chat.disconnect()
- self.chat = None
+ self._chatMVC.connection.disconnect_()
+ self._chatMVC = None
+
+ # Clear cached game files if needed
+ util.clearGameCache()
+
+ # Get rid of generated maps
+ util.clearGeneratedMaps()
# Get rid of the Tray icon
if self.tray:
@@ -684,6 +972,9 @@ def cleanup(self):
self.tray.deleteLater()
self.tray = None
+ # Clear qt message handler to avoid crash at exit
+ config.clear_logging_handlers()
+
# Terminate UI
if self.isVisible():
progress.setLabelText("Closing main window")
@@ -696,10 +987,17 @@ def closeEvent(self, event):
self.saveWindow()
if fa.instance.running():
- if QtWidgets.QMessageBox.question(self, "Are you sure?", "Seems like you still have Forged Alliance "
- "running! Close anyway?",
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.No:
+ result = QtWidgets.QMessageBox.question(
+ self,
+ "Are you sure?",
+ (
+ "Seems like you still have Forged Alliance running!"
+ " Close anyway?"
+ ),
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
+ )
+ if result == QtWidgets.QMessageBox.StandardButton.No:
event.ignore()
return
@@ -708,103 +1006,278 @@ def closeEvent(self, event):
def initMenus(self):
self.actionCheck_for_Updates.triggered.connect(self.check_for_updates)
self.actionUpdate_Settings.triggered.connect(self.show_update_settings)
- self.actionLink_account_to_Steam.triggered.connect(partial(self.open_url, config.Settings.get("STEAMLINK_URL")))
- self.actionLinkWebsite.triggered.connect(partial(self.open_url, config.Settings.get("WEBSITE_URL")))
- self.actionLinkWiki.triggered.connect(partial(self.open_url, config.Settings.get("WIKI_URL")))
- self.actionLinkForums.triggered.connect(partial(self.open_url, config.Settings.get("FORUMS_URL")))
- self.actionLinkUnitDB.triggered.connect(partial(self.open_url, config.Settings.get("UNITDB_URL")))
- self.actionLinkMapPool.triggered.connect(partial(self.open_url, config.Settings.get("MAPPOOL_URL")))
- self.actionLinkGitHub.triggered.connect(partial(self.open_url, config.Settings.get("GITHUB_URL")))
-
- self.actionNsSettings.triggered.connect(lambda: self.notificationSystem.on_showSettings())
- self.actionNsEnabled.triggered.connect(lambda enabled: self.notificationSystem.setNotificationEnabled(enabled))
-
- self.actionWiki.triggered.connect(partial(self.open_url, config.Settings.get("WIKI_URL")))
- self.actionReportBug.triggered.connect(partial(self.open_url, config.Settings.get("TICKET_URL")))
+ self.actionLink_account_to_Steam.triggered.connect(
+ partial(self.open_url, config.Settings.get("STEAMLINK_URL")),
+ )
+ self.actionLinkWebsite.triggered.connect(
+ partial(self.open_url, config.Settings.get("WEBSITE_URL")),
+ )
+ self.actionLinkWiki.triggered.connect(
+ partial(self.open_url, config.Settings.get("WIKI_URL")),
+ )
+ self.actionLinkForums.triggered.connect(
+ partial(self.open_url, config.Settings.get("FORUMS_URL")),
+ )
+ self.actionLinkUnitDB.triggered.connect(
+ partial(self.open_url, config.Settings.get("UNITDB_URL")),
+ )
+ self.actionLinkMapPool.triggered.connect(
+ partial(self.open_url, config.Settings.get("MAPPOOL_URL")),
+ )
+ self.actionLinkGitHub.triggered.connect(
+ partial(self.open_url, config.Settings.get("GITHUB_URL")),
+ )
+
+ self.actionNsSettings.triggered.connect(
+ lambda: self.notificationSystem.on_showSettings(),
+ )
+ self.actionNsEnabled.triggered.connect(
+ lambda enabled: self.notificationSystem.setNotificationEnabled(
+ enabled,
+ ),
+ )
+
+ self.actionWiki.triggered.connect(
+ partial(self.open_url, config.Settings.get("WIKI_URL")),
+ )
+ self.actionReportBug.triggered.connect(
+ partial(self.open_url, config.Settings.get("TICKET_URL")),
+ )
self.actionShowLogs.triggered.connect(self.linkShowLogs)
- self.actionTechSupport.triggered.connect(partial(self.open_url, config.Settings.get("SUPPORT_URL")))
+ self.actionTechSupport.triggered.connect(
+ partial(self.open_url, config.Settings.get("SUPPORT_URL")),
+ )
self.actionAbout.triggered.connect(self.linkAbout)
self.actionClearCache.triggered.connect(self.clearCache)
self.actionClearSettings.triggered.connect(self.clearSettings)
self.actionClearGameFiles.triggered.connect(self.clearGameFiles)
+ self.actionClearMapGenerators.triggered.connect(
+ self.clearMapGenerators,
+ )
self.actionSetGamePath.triggered.connect(self.switchPath)
- self.actionSetGamePort.triggered.connect(self.switchPort)
- self.actionShowMapsDir.triggered.connect(lambda: util.showDirInFileBrowser(getUserMapsFolder()))
- self.actionShowModsDir.triggered.connect(lambda: util.showDirInFileBrowser(MODFOLDER))
- self.actionShowReplaysDir.triggered.connect(lambda: util.showDirInFileBrowser(util.REPLAY_DIR))
- self.actionShowThemesDir.triggered.connect(lambda: util.showDirInFileBrowser(util.THEME_DIR))
- # if game.prefs doesn't exist: show_dir -> empty folder / show_file -> 'file doesn't exist' message
- self.actionShowGamePrefs.triggered.connect(lambda: util.showDirInFileBrowser(util.LOCALFOLDER))
- #self.actionShowGamePrefs.triggered.connect(lambda: util.showFileInFileBrowser(util.PREFSFILENAME))
+ self.actionShowMapsDir.triggered.connect(
+ lambda: util.showDirInFileBrowser(getUserMapsFolder()),
+ )
+ self.actionShowModsDir.triggered.connect(
+ lambda: util.showDirInFileBrowser(getModFolder()),
+ )
+ self.actionShowReplaysDir.triggered.connect(
+ lambda: util.showDirInFileBrowser(util.REPLAY_DIR),
+ )
+ self.actionShowThemesDir.triggered.connect(
+ lambda: util.showDirInFileBrowser(util.THEME_DIR),
+ )
+ self.actionShowGamePrefs.triggered.connect(
+ lambda: util.showDirInFileBrowser(util.LOCALFOLDER),
+ )
+ self.actionShowClientConfigFile.triggered.connect(util.showConfigFile)
# Toggle-Options
- self.actionSetAutoLogin.triggered.connect(self.updateOptions)
+ self.actionSetAutoLogin.triggered.connect(self.update_options)
self.actionSetAutoLogin.setChecked(self.remember)
- self.actionSetAutoDownloadMods.toggled.connect(self.on_actionAutoDownloadMods_toggled)
- self.actionSetAutoDownloadMods.setChecked(config.Settings.get('mods/autodownload', type=bool, default=False))
- self.actionSetAutoDownloadMaps.toggled.connect(self.on_actionAutoDownloadMaps_toggled)
- self.actionSetAutoDownloadMaps.setChecked(config.Settings.get('maps/autodownload', type=bool, default=False))
- self.actionSetSoundEffects.triggered.connect(self.updateOptions)
- self.actionSetOpenGames.triggered.connect(self.updateOptions)
- self.actionSetJoinsParts.triggered.connect(self.updateOptions)
- self.actionSetNewbiesChannel.triggered.connect(self.updateOptions)
- self.actionSetAutoJoinChannels.triggered.connect(self.show_autojoin_settings_dialog)
- self.actionSetLiveReplays.triggered.connect(self.updateOptions)
- self.actionSetChatMaps.triggered.connect(self.toggleChatMaps)
- self.actionSaveGamelogs.toggled.connect(self.on_actionSavegamelogs_toggled)
- self.actionSaveGamelogs.setChecked(self.gamelogs)
- self.actionColoredNicknames.triggered.connect(self.updateOptions)
- self.actionFriendsOnTop.triggered.connect(self.updateOptions)
-
- self.actionCheckPlayerAliases.triggered.connect(self.checkPlayerAliases)
+ self.actionSetAutoDownloadMods.toggled.connect(
+ self.on_action_auto_download_mods_toggled,
+ )
+ self.actionSetAutoDownloadMods.setChecked(
+ config.Settings.get('mods/autodownload', type=bool, default=False),
+ )
+ self.actionSetAutoDownloadMaps.toggled.connect(
+ self.on_action_auto_download_maps_toggled,
+ )
+ self.actionSetAutoDownloadMaps.setChecked(
+ config.Settings.get('maps/autodownload', type=bool, default=False),
+ )
+ self.actionSetAutoGenerateMaps.toggled.connect(
+ self.on_action_auto_generate_maps_toggled,
+ )
+ self.actionSetAutoGenerateMaps.setChecked(
+ config.Settings.get(
+ 'mapGenerator/autostart',
+ type=bool,
+ default=False,
+ ),
+ )
+ self.actionSetSoundEffects.triggered.connect(self.update_options)
+ self.actionSetOpenGames.triggered.connect(self.update_options)
+ self.actionSetJoinsParts.triggered.connect(self.update_options)
+ self.actionSetNewbiesChannel.triggered.connect(self.update_options)
+ self.actionIgnoreFoes.triggered.connect(self.update_options)
+ self.actionSetLiveReplays.triggered.connect(self.update_options)
+ self.actionSaveGamelogs.setChecked(self.game_logs)
+ self.actionColoredNicknames.triggered.connect(self.update_options)
+ self.actionFriendsOnTop.triggered.connect(self.update_options)
+ self.actionSetAutoJoinChannels.triggered.connect(
+ self.show_autojoin_settings_dialog,
+ )
+ self.actionSaveGamelogs.toggled.connect(
+ self.on_action_save_game_logs_toggled,
+ )
+ self.actionVaultFallback.toggled.connect(
+ self.on_action_fault_fallback_toggled,
+ )
+ self.actionVaultFallback.setChecked(
+ config.Settings.get('vault/fallback', type=bool, default=False),
+ )
+ self.actionLanguageChannels.triggered.connect(
+ self._language_channel_config.run,
+ )
+
+ self.actionEnableIceAdapterInfoWindow.triggered.connect(
+ self.on_action_enable_ice_adapter_info_window,
+ )
+ self.actionEnableIceAdapterInfoWindow.setChecked(
+ config.Settings.get(
+ 'iceadapter/info_window',
+ type=bool,
+ default=False,
+ ),
+ )
+ self.actionSetIceAdapterWindowLaunchDelay.triggered.connect(
+ self.set_ice_adapter_window_launch_delay,
+ )
+
+ self.actionDoNotKeep.setChecked(
+ config.Settings.get('cache/do_not_keep', type=bool, default=True),
+ )
+ self.actionForever.setChecked(
+ config.Settings.get('cache/forever', type=bool, default=False),
+ )
+ self.actionSetYourOwnTimeInterval.setChecked(
+ config.Settings.get(
+ 'cache/own_settings', type=bool, default=False,
+ ),
+ )
+ self.actionKeepCacheWhileInSession.setChecked(
+ config.Settings.get('cache/in_session', type=bool, default=False),
+ )
+ self.actionKeepCacheWhileInSession.setVisible(
+ config.Settings.get('cache/do_not_keep', type=bool, default=True),
+ )
+ self.actionDoNotKeep.triggered.connect(self.saveCacheSettings)
+ self.actionForever.triggered.connect(
+ lambda: self.saveCacheSettings(own=False, forever=True),
+ )
+ self.actionSetYourOwnTimeInterval.triggered.connect(
+ lambda: self.saveCacheSettings(own=True, forever=False),
+ )
+ self.actionKeepCacheWhileInSession.toggled.connect(self.inSessionCache)
+
+ self.actionCheckPlayerAliases.triggered.connect(
+ self.checkPlayerAliases,
+ )
self._menuThemeHandler = ThemeMenu(self.menuTheme)
self._menuThemeHandler.setup(util.THEME.listThemes())
- self._menuThemeHandler.themeSelected.connect(lambda theme: util.THEME.setTheme(theme, True))
+ self._menuThemeHandler.themeSelected.connect(
+ lambda theme: util.THEME.setTheme(theme, True),
+ )
+
+ self._chat_vis_actions = {
+ ChatterLayoutElements.RANK: self.actionHideChatterRank,
+ ChatterLayoutElements.AVATAR: self.actionHideChatterAvatar,
+ ChatterLayoutElements.COUNTRY: self.actionHideChatterCountry,
+ ChatterLayoutElements.NICK: self.actionHideChatterNick,
+ ChatterLayoutElements.STATUS: self.actionHideChatterStatus,
+ ChatterLayoutElements.MAP: self.actionHideChatterMap,
+ }
+ for action in self._chat_vis_actions.values():
+ action.triggered.connect(self.update_options)
@QtCore.pyqtSlot()
- def updateOptions(self):
+ def update_options(self):
+ chat_config = self._chat_config
+
self.remember = self.actionSetAutoLogin.isChecked()
- self.soundeffects = self.actionSetSoundEffects.isChecked()
- self.game_announcer.announce_games = self.actionSetOpenGames.isChecked()
- self.joinsparts = self.actionSetJoinsParts.isChecked()
- self.useNewbiesChannel = self.actionSetNewbiesChannel.isChecked()
- self.chatmaps = self.actionSetChatMaps.isChecked()
- self.game_announcer.announce_replays = self.actionSetLiveReplays.isChecked()
-
- self.gamelogs = self.actionSaveGamelogs.isChecked()
- self.player_colors.coloredNicknames = self.actionColoredNicknames.isChecked()
- if self.friendsontop != self.actionFriendsOnTop.isChecked():
- self.friendsontop = self.actionFriendsOnTop.isChecked()
- self.chat.sort_channels()
+ if self.remember and self.refresh_token:
+ config.Settings.set('user/refreshToken', self.refresh_token)
+ chat_config.soundeffects = self.actionSetSoundEffects.isChecked()
+ chat_config.joinsparts = self.actionSetJoinsParts.isChecked()
+ chat_config.newbies_channel = self.actionSetNewbiesChannel.isChecked()
+ chat_config.ignore_foes = self.actionIgnoreFoes.isChecked()
+ chat_config.friendsontop = self.actionFriendsOnTop.isChecked()
+
+ invisible_items = [
+ i for i, a in self._chat_vis_actions.items() if a.isChecked()
+ ]
+ chat_config.hide_chatter_items.clear()
+ chat_config.hide_chatter_items |= invisible_items
+
+ announce_games = self.actionSetOpenGames.isChecked()
+ self.game_announcer.announce_games = announce_games
+ announce_replays = self.actionSetLiveReplays.isChecked()
+ self.game_announcer.announce_replays = announce_replays
+
+ self.game_logs = self.actionSaveGamelogs.isChecked()
+ colored_nicknames = self.actionColoredNicknames.isChecked()
+ self.player_colors.colored_nicknames = colored_nicknames
self.saveChat()
- def toggleChatMaps(self):
- self.updateOptions()
- self.chat.update_channels()
+ @QtCore.pyqtSlot(bool)
+ def on_action_save_game_logs_toggled(self, value):
+ self.game_logs = value
+
+ @QtCore.pyqtSlot(bool)
+ def on_action_auto_download_mods_toggled(self, value):
+ config.Settings.set('mods/autodownload', value is True)
+
+ @QtCore.pyqtSlot(bool)
+ def on_action_auto_download_maps_toggled(self, value):
+ config.Settings.set('maps/autodownload', value is True)
+
+ @QtCore.pyqtSlot(bool)
+ def on_action_auto_generate_maps_toggled(self, value):
+ config.Settings.set('mapGenerator/autostart', value is True)
+
+ @QtCore.pyqtSlot(bool)
+ def on_action_fault_fallback_toggled(self, value):
+ config.Settings.set('vault/fallback', value is True)
+ util.setPersonalDir()
+ setModFolder()
+
+ @QtCore.pyqtSlot(bool)
+ def on_action_enable_ice_adapter_info_window(self, value):
+ config.Settings.set('iceadapter/info_window', value is True)
@QtCore.pyqtSlot()
- def switchPath(self):
- fa.wizards.Wizard(self).exec_()
+ def set_ice_adapter_window_launch_delay(self):
+ seconds, ok = QtWidgets.QInputDialog().getInt(
+ self,
+ 'Set time interval',
+ 'Delay the launch of the info window by seconds:',
+ config.Settings.get(
+ 'iceadapter/delay_ui_seconds', type=int, default=10,
+ ),
+ min=0,
+ max=2147483647,
+ step=1,
+ )
+ if ok and seconds:
+ config.Settings.set('iceadapter/delay_ui_seconds', seconds)
@QtCore.pyqtSlot()
- def switchPort(self):
- from . import loginwizards
- loginwizards.gameSettingsWizard(self).exec_()
+ def switchPath(self):
+ fa.wizards.Wizard(self).exec()
@QtCore.pyqtSlot()
def clearSettings(self):
- result = QtWidgets.QMessageBox.question(None, "Clear Settings", "Are you sure you wish to clear all settings, "
- "login info, etc. used by this program?",
- QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No)
- if result == QtWidgets.QMessageBox.Yes:
+ result = QtWidgets.QMessageBox.question(
+ self,
+ "Clear Settings",
+ "Are you sure you wish to clear all settings, "
+ "login info, etc. used by this program?",
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
+ )
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
util.settings.clear()
util.settings.sync()
- QtWidgets.QMessageBox.information(None, "Restart Needed", "FAF will quit now.")
+ QtWidgets.QMessageBox.information(
+ self, "Restart Needed", "FAF will quit now.",
+ )
QtWidgets.QApplication.quit()
@QtCore.pyqtSlot()
@@ -816,9 +1289,15 @@ def clearGameFiles(self):
def clearCache(self):
changed = util.clearDirectory(util.CACHE_DIR)
if changed:
- QtWidgets.QMessageBox.information(None, "Restart Needed", "FAF will quit now.")
+ QtWidgets.QMessageBox.information(
+ self, "Restart Needed", "FAF will quit now.",
+ )
QtWidgets.QApplication.quit()
+ @QtCore.pyqtSlot()
+ def clearMapGenerators(self):
+ util.clearDirectory(util.MAPGEN_DIR)
+
# Clear the online users lists
def clear_players(self):
self.players.clear()
@@ -833,194 +1312,325 @@ def linkShowLogs(self):
@QtCore.pyqtSlot()
def connectivityDialog(self):
- dialog = connectivity.ConnectivityDialog(self.connectivity)
- dialog.exec_()
+ if (
+ self.game_session is not None
+ and self.game_session.ice_adapter_client is not None
+ ):
+ self.connectivity_dialog = ConnectivityDialog(
+ self.game_session.ice_adapter_client,
+ )
+ self.connectivity_dialog.show()
+ else:
+ QtWidgets.QMessageBox().information(
+ self,
+ "No game",
+ "The connectivity window is only available during the game.",
+ )
@QtCore.pyqtSlot()
def linkAbout(self):
dialog = util.THEME.loadUi("client/about.ui")
dialog.version_label.setText("Version: {}".format(util.VERSION_STRING))
- dialog.exec_()
+ dialog.exec()
@QtCore.pyqtSlot()
def check_for_updates(self):
- self._update_checker.respect_notify = False
- self._update_checker.start(reset_server=False)
+ self._update_tools.checker.check(always_notify=True)
@QtCore.pyqtSlot()
def show_update_settings(self):
- dialog = UpdateSettingsDialog(self)
- dialog.setup()
+ dialog = self._update_tools.settings_dialog()
dialog.show()
def checkPlayerAliases(self):
- self._alias_window.run()
+ self._alias_search_window.run()
def saveWindow(self):
util.settings.beginGroup("window")
util.settings.setValue("geometry", self.saveGeometry())
+ util.settings.setValue("maximized", self.is_window_maximized)
util.settings.endGroup()
def show_autojoin_settings_dialog(self):
- autojoin_channels_list = config.Settings.get('chat/auto_join_channels', [])
+ autojoin_channels_list = config.Settings.get(
+ 'chat/auto_join_channels',
+ default=[],
+ )
text_of_autojoin_settings_dialog = """
- Enter the list of channels you want to autojoin at startup, separated by ;
- For example: #poker;#newbie
- To disable autojoining channels, leave the box empty and press OK.
+ Enter the list of channels you want to autojoin at startup, separated
+ by ; For example: #poker;#newbie To disable autojoining channels,
+ leave the box empty and press OK.
"""
- channels_input_of_user, ok = QtWidgets.QInputDialog.getText(self, 'Set autojoin channels',
- text_of_autojoin_settings_dialog, QtWidgets.QLineEdit.Normal, ';'.join(autojoin_channels_list))
+ channels_input_of_user, ok = QtWidgets.QInputDialog.getText(
+ self,
+ 'Set autojoin channels',
+ text_of_autojoin_settings_dialog,
+ QtWidgets.QLineEdit.Normal,
+ ';'.join(autojoin_channels_list),
+ )
if ok:
- config.Settings.set('chat/auto_join_channels', list(map(str.strip, channels_input_of_user.split(';'))))
+ channels = [
+ c.strip()
+ for c in channels_input_of_user.split(';')
+ if c
+ ]
+ config.Settings.set('chat/auto_join_channels', channels)
+
+ @QtCore.pyqtSlot(bool)
+ def inSessionCache(self, value):
+ config.Settings.set('cache/in_session', value is True)
+
+ @QtCore.pyqtSlot()
+ def saveCacheSettings(self, own=False, forever=False):
+ if forever:
+ util.settings.beginGroup('cache')
+ util.settings.setValue('do_not_keep', False)
+ util.settings.setValue('forever', True)
+ util.settings.setValue('own_settings', False)
+ util.settings.setValue('number_of_days', -1)
+ util.settings.endGroup()
+ self.actionKeepCacheWhileInSession.setChecked(False)
+ elif own:
+ days, ok = QtWidgets.QInputDialog().getInt(
+ self,
+ 'Set time interval',
+ 'Keep game files in cache for this number of days:',
+ config.Settings.get(
+ 'cache/number_of_days', type=int, default=30,
+ ),
+ min=1,
+ max=2147483647,
+ step=10,
+ )
+ if ok and days:
+ util.settings.beginGroup('cache')
+ util.settings.setValue('do_not_keep', False)
+ util.settings.setValue('forever', False)
+ util.settings.setValue('own_settings', True)
+ util.settings.setValue('number_of_days', days)
+ util.settings.endGroup()
+ self.actionKeepCacheWhileInSession.setChecked(False)
+ else:
+ util.settings.beginGroup('cache')
+ util.settings.setValue('do_not_keep', True)
+ util.settings.setValue('forever', False)
+ util.settings.setValue('own_settings', False)
+ util.settings.setValue('number_of_days', 0)
+ util.settings.endGroup()
+ self.actionDoNotKeep.setChecked(
+ config.Settings.get('cache/do_not_keep', type=bool, default=True),
+ )
+ self.actionForever.setChecked(
+ config.Settings.get('cache/forever', type=bool, default=False),
+ )
+ self.actionSetYourOwnTimeInterval.setChecked(
+ config.Settings.get(
+ 'cache/own_settings', type=bool, default=False,
+ ),
+ )
+ self.actionKeepCacheWhileInSession.setVisible(
+ config.Settings.get('cache/do_not_keep', type=bool, default=True),
+ )
def saveChat(self):
util.settings.beginGroup("chat")
- util.settings.setValue("soundeffects", self.soundeffects)
- util.settings.setValue("livereplays", self.game_announcer.announce_replays)
+ util.settings.setValue(
+ "livereplays", self.game_announcer.announce_replays,
+ )
util.settings.setValue("opengames", self.game_announcer.announce_games)
- util.settings.setValue("joinsparts", self.joinsparts)
- util.settings.setValue("newbiesChannel", self.useNewbiesChannel)
- util.settings.setValue("chatmaps", self.chatmaps)
- util.settings.setValue("coloredNicknames", self.player_colors.coloredNicknames)
- util.settings.setValue("friendsontop", self.friendsontop)
+ util.settings.setValue(
+ "coloredNicknames", self.player_colors.colored_nicknames,
+ )
util.settings.endGroup()
+ self._chat_config.save_settings()
- def loadSettings(self):
- self.loadChat()
+ def load_settings(self):
+ self.load_chat()
# Load settings
util.settings.beginGroup("window")
geometry = util.settings.value("geometry", None)
- if geometry:
- self.restoreGeometry(geometry)
- util.settings.endGroup()
-
- util.settings.beginGroup("ForgedAlliance")
+ # FIXME: looks like bug in Qt: restoring from maximized geometry doesn't work
+ # see https://bugreports.qt.io/browse/QTBUG-123335 (?)
+ maximized = util.settings.value("maximized", defaultValue=False, type=bool)
util.settings.endGroup()
+ if maximized:
+ self.setGeometry(self.screen().availableGeometry())
+ elif geometry:
+ self.restoreGeometry(geometry)
- def loadChat(self):
+ def load_chat(self):
+ cc = self._chat_config
try:
util.settings.beginGroup("chat")
- self.soundeffects = (util.settings.value("soundeffects", "true") == "true")
- self.game_announcer.announce_games = (util.settings.value("opengames", "true") == "true")
- self.joinsparts = (util.settings.value("joinsparts", "false") == "true")
- self.chatmaps = (util.settings.value("chatmaps", "false") == "true")
- self.game_announcer.announce_replays = (util.settings.value("livereplays", "true") == "true")
- self.player_colors.coloredNicknames = (util.settings.value("coloredNicknames", "false") == "true")
- self.friendsontop = (util.settings.value("friendsontop", "false") == "true")
- self.useNewbiesChannel = (util.settings.value("newbiesChannel","true") == "true")
-
+ self.game_announcer.announce_games = (
+ util.settings.value("opengames", "true") == "true"
+ )
+ self.game_announcer.announce_replays = (
+ util.settings.value("livereplays", "true") == "true"
+ )
+ self.player_colors.colored_nicknames = (
+ util.settings.value("coloredNicknames", "false") == "true"
+ )
util.settings.endGroup()
- self.actionColoredNicknames.setChecked(self.player_colors.coloredNicknames)
- self.actionFriendsOnTop.setChecked(self.friendsontop)
- self.actionSetSoundEffects.setChecked(self.soundeffects)
- self.actionSetLiveReplays.setChecked(self.game_announcer.announce_replays)
- self.actionSetOpenGames.setChecked(self.game_announcer.announce_games)
- self.actionSetJoinsParts.setChecked(self.joinsparts)
- self.actionSetChatMaps.setChecked(self.chatmaps)
- self.actionSetNewbiesChannel.setChecked(self.useNewbiesChannel)
- except:
+ cc.load_settings()
+ self.actionColoredNicknames.setChecked(
+ self.player_colors.colored_nicknames,
+ )
+ self.actionFriendsOnTop.setChecked(cc.friendsontop)
+
+ for item in ChatterLayoutElements:
+ self._chat_vis_actions[item].setChecked(
+ item in cc.hide_chatter_items,
+ )
+ self.actionSetSoundEffects.setChecked(cc.soundeffects)
+ self.actionSetLiveReplays.setChecked(
+ self.game_announcer.announce_replays,
+ )
+ self.actionSetOpenGames.setChecked(
+ self.game_announcer.announce_games,
+ )
+ self.actionSetJoinsParts.setChecked(cc.joinsparts)
+ self.actionSetNewbiesChannel.setChecked(cc.newbies_channel)
+ self.actionIgnoreFoes.setChecked(cc.ignore_foes)
+ except BaseException:
pass
- def doConnect(self):
- if not self.replayServer.doListen(LOCAL_REPLAY_PORT):
+ def save_refresh_token(self) -> None:
+ self.refresh_token = self.oauth_flow.refreshToken()
+
+ def do_connect(self) -> bool:
+ if self.state in (ClientState.CONNECTING, ClientState.CONNECTED, ClientState.LOGGED_IN):
+ return True
+
+ if not self.replayServer.doListen():
return False
- self.lobby_connection.doConnect()
+ self.lobby_connection.do_connect()
return True
def set_remember(self, remember):
self.remember = remember
- self.actionSetAutoLogin.setChecked(self.remember) # FIXME - option updating is silly
+ # FIXME - option updating is silly
+ self.actionSetAutoLogin.setChecked(self.remember)
- def get_creds_and_login(self):
- # Try to autologin, or show login widget if we fail or can't do that.
- if self._autorelogin and self.password and self.login:
- if self.send_login(self.login, self.password):
- return
+ def try_to_auto_login(self) -> None:
+ if (
+ self._auto_relogin
+ and self.refresh_token
+ ):
+ self.oauth_flow.setRefreshToken(self.refresh_token)
+ self.oauth_flow.refreshAccessToken()
+ else:
+ self.show_login_widget()
+ def get_creds_and_login(self) -> None:
+ if self.send_token(self.oauth_flow.token()):
+ return
+ QtWidgets.QMessageBox.warning(
+ self, "Log In", "OAuth token verification failed, please relogin",
+ )
self.show_login_widget()
def show_login_widget(self):
- login_widget = LoginWidget(self.login, self.remember)
+ login_widget = LoginWidget(self.remember)
login_widget.finished.connect(self.on_widget_login_data)
login_widget.rejected.connect(self.on_widget_no_login)
- login_widget.request_quit.connect(self.on_login_widget_quit)
+ login_widget.request_quit.connect(
+ self.on_login_widget_quit, QtCore.Qt.ConnectionType.QueuedConnection,
+ )
login_widget.remember.connect(self.set_remember)
- login_widget.exec_()
+ login_widget.exec()
- def on_widget_login_data(self, login, password):
- self.login = login
- self.password = password
+ def on_widget_login_data(self, api_changed):
+ self.lobby_connection.setHostFromConfig()
+ self.lobby_connection.setPortFromConfig()
+ self._chatMVC.connection.setHostFromConfig()
+ self._chatMVC.connection.setPortFromConfig()
+ if api_changed:
+ self.ladder.refreshLeaderboards()
+ self.games.refreshMods()
- if self.send_login(login, password):
- return
- self.show_login_widget()
+ self.oauth_flow.setup_credentials()
+ self.oauth_flow.grant()
def on_widget_no_login(self):
- self.disconnect()
+ self.state = ClientState.DISCONNECTED
def on_login_widget_quit(self):
QtWidgets.QApplication.quit()
- def send_login(self, login, password):
- # Send login data once we have the creds.
- self._autorelogin = False # Fresh credentials
- if config.is_beta(): # Replace for develop here to not clobber the real pass
- password = util.password_hash("foo")
- self.uniqueId = util.uniqueID(self.login, self.session)
- if not self.uniqueId:
- QtWidgets.QMessageBox.critical(self,
- "Failed to calculate UID",
- "Failed to calculate your unique ID"
- " (a part of our smurf prevention system).\n"
- "Please report this to the tech support forum!")
+ def send_token(self, token):
+ # Send data once we have the creds.
+ self._autorelogin = False # Fresh credentials
+ self.unique_id = util.uniqueID(self.session)
+ if not self.unique_id:
+ QtWidgets.QMessageBox.critical(
+ self,
+ "Failed to calculate UID",
+ "Failed to calculate your unique ID"
+ " (a part of our smurf prevention system).\n"
+ "It is very likely this happens due to your antivirus software"
+ " deleting the faf-uid.exe file. If this has happened, please "
+ "add an exception and restore the file. The file "
+ "can also be restored by installing the client again.",
+ )
return False
- self.lobby_connection.send(dict(command="hello",
- login=login,
- password=password,
- unique_id=self.uniqueId,
- session=self.session))
+ self.lobby_connection.send(
+ dict(
+ command="auth",
+ token=token,
+ unique_id=self.unique_id,
+ session=self.session,
+ ),
+ )
return True
@QtCore.pyqtSlot()
- def startedFA(self):
+ def started_fa(self):
"""
Slot hooked up to fa.instance when the process has launched.
It will notify other modules through the signal gameEnter().
"""
logger.info("FA has launched in an attached process.")
- self.gameEnter.emit()
+ self.game_enter.emit()
@QtCore.pyqtSlot(int)
- def finishedFA(self, exit_code):
+ def finished_fa(self, exit_code):
"""
Slot hooked up to fa.instance when the process has ended.
It will notify other modules through the signal gameExit().
"""
if not exit_code:
- logger.info("FA has finished with exit code: " + str(exit_code))
+ logger.info("FA has finished with exit code: {}".format(exit_code))
else:
- logger.warning("FA has finished with exit code: " + str(exit_code))
- self.gameExit.emit()
+ logger.warning(
+ "FA has finished with exit code: {}".format(exit_code),
+ )
+ self.game_exit.emit()
@QtCore.pyqtSlot(QtCore.QProcess.ProcessError)
- def errorFA(self, error_code):
+ def error_fa(self, error_code):
"""
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:
logger.error("FA has failed to start")
- QtWidgets.QMessageBox.critical(self, "Error from FA", "FA has failed to start.")
+ QtWidgets.QMessageBox.critical(
+ self, "Error from FA", "FA has failed to start.",
+ )
elif error_code == 1:
logger.error("FA has crashed or killed after starting")
else:
- text = "FA has failed to start with error code: " + str(error_code)
+ text = (
+ "FA has failed to start with error code: {}"
+ .format(error_code)
+ )
logger.error(text)
QtWidgets.QMessageBox.critical(self, "Error from FA", text)
- self.gameExit.emit()
+ self.game_exit.emit()
- def _tabChanged(self, tab, curr, prev):
+ def tab_changed(self, tab, curr, prev):
"""
The main visible tab (module) of the client's UI has changed.
In this case, other modules may want to load some data or cease
@@ -1037,158 +1647,101 @@ def _tabChanged(self, tab, curr, prev):
tab = new_tab.layout().itemAt(0).widget()
if isinstance(tab, BusyWidget):
tab.busy_entered()
+ # FIXME - special concession for chat tab. In the future we should
+ # separate widgets from controlling classes, just like chat tab does -
+ # then we'll refactor this part.
+ if new_tab is self.chatTab:
+ self._chatMVC.view.entered()
@QtCore.pyqtSlot(int)
- def mainTabChanged(self, curr):
- self._tabChanged(self.mainTabs, curr, self._main_tab)
+ def main_tab_changed(self, curr):
+ self.tab_changed(self.mainTabs, curr, self._main_tab)
self._main_tab = curr
@QtCore.pyqtSlot(int)
- def vaultTabChanged(self, curr):
- self._tabChanged(self.topTabs, curr, self._vault_tab)
+ def vault_tab_changed(self, curr):
+ self.tab_changed(self.topTabs, curr, self._vault_tab)
self._vault_tab = curr
- @QtCore.pyqtSlot()
- def joinGameFromURL(self, url):
- """
- Tries to join the game at the given URL
- """
- logger.debug("joinGameFromURL: " + url.toString())
- if fa.instance.available():
- add_mods = []
- try:
- modstr = QtCore.QUrlQuery(url).queryItemValue("mods")
- add_mods = json.loads(modstr) # should be a list
- except:
- logger.info("Couldn't load urlquery value 'mods'")
- if fa.check.game(self):
- uid, mod, map = QtCore.QUrlQuery(url).queryItemValue('uid'), \
- QtCore.QUrlQuery(url).queryItemValue('mod'), \
- QtCore.QUrlQuery(url).queryItemValue('map')
- if fa.check.check(mod, map, sim_mods=add_mods):
- self.join_game(int(uid))
-
- @QtCore.pyqtSlot()
- def searchUserReplays(self, name):
- self.replays.set_player(name)
+ def view_replays(self, name, leaderboardName=None):
+ self.replays.set_player(name, leaderboardName)
self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.replaysTab))
- @QtCore.pyqtSlot()
- def viewUserLeaderboards(self, user):
- self.ladder.set_player(user)
+ def view_in_leaderboards(self, user):
+ self.ladder.setCurrentIndex(
+ self.ladder.indexOf(self.ladder.leaderboardsTab),
+ )
+ self.ladder.leaderboards.widget(0).searchPlayerInLeaderboard(user)
+ self.ladder.leaderboards.widget(1).searchPlayerInLeaderboard(user)
+ self.ladder.leaderboards.widget(2).searchPlayerInLeaderboard(user)
+ self.ladder.leaderboards.setCurrentIndex(1)
self.mainTabs.setCurrentIndex(self.mainTabs.indexOf(self.ladderTab))
- @QtCore.pyqtSlot()
- def forwardLocalBroadcast(self, source, message):
- self.localBroadcast.emit(source, message)
-
def manage_power(self):
""" update the interface accordingly to the power of the user """
- if self.power >= 1:
- if self.modMenu is None:
- self.modMenu = self.menu.addMenu("Administration")
+ if self.power_tools.power >= 1:
+ if self.mod_menu is None:
+ self.mod_menu = self.menu.addMenu("Administration")
- actionAvatar = QtWidgets.QAction("Avatar manager", self.modMenu)
- actionAvatar.triggered.connect(self.avatarManager)
- self.modMenu.addAction(actionAvatar)
+ action_lobby_kick = QtWidgets.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)
- self.modMenu.addSeparator()
+ action_close_fa = QtWidgets.QAction(
+ "Close Player's Game...",
+ self.mod_menu,
+ )
+ action_close_fa.triggered.connect(self._close_game_dialog)
+ self.mod_menu.addAction(action_close_fa)
- actionLobbyKick = QtWidgets.QAction("Close player's FAF Client...", self.modMenu)
- actionLobbyKick.triggered.connect(lambda: self.closeLobby())
- self.modMenu.addAction(actionLobbyKick)
+ def _close_game_dialog(self):
+ self.power_tools.view.close_game_dialog.show()
- actionCloseFA = QtWidgets.QAction("Close Player's Game...", self.modMenu)
- actionCloseFA.triggered.connect(lambda: util.userNameAction(self, 'Player to close FA (do not typo!)',
- lambda name: self.closeFA(name)))
- self.modMenu.addAction(actionCloseFA)
+ # Needed so that we ignore the bool from the triggered() signal
+ def _on_lobby_kick_triggered(self):
+ self.power_tools.view.kick_dialog()
- def requestAvatars(self, personal):
- if personal:
- self.lobby_connection.send(dict(command="avatar", action="list_avatar"))
- else:
- self.lobby_connection.send(dict(command="admin", action="requestavatars"))
-
- def joinChannel(self, username, channel):
- """ Join users to a channel """
- self.lobby_connection.send(dict(command="admin", action="join_channel",
- user_ids=[self.players.getID(username)], channel=channel))
-
- def closeFA(self, username):
- """ Close FA remotely """
- logger.info('closeFA for {}'.format(username))
- user_id = self.players.getID(username)
- if user_id != -1:
- self.lobby_connection.send(dict(command="admin", action="closeFA", user_id=user_id))
-
- def closeLobby(self, username=""):
- """ Close lobby remotely """
- logger.info('Opening kick dialog for {}'.format(username))
- kick_dialog = KickDialog(self)
- kick_dialog.reset(username)
- kick_dialog.show()
-
- def addFriend(self, friend_id):
- if friend_id in self.players:
- self.me.addFriend(int(friend_id))
- self.lobby_connection.send(dict(command="social_add", friend=friend_id))
-
- def addFoe(self, foe_id):
- if foe_id in self.players:
- self.me.addFoe(int(foe_id))
- self.lobby_connection.send(dict(command="social_add", foe=foe_id))
-
- def remFriend(self, friend_id):
- if friend_id in self.players:
- self.me.remFriend(int(friend_id))
- self.lobby_connection.send(dict(command="social_remove", friend=friend_id))
-
- def remFoe(self, foe_id):
- if foe_id in self.players:
- self.me.remFoe(int(foe_id))
- self.lobby_connection.send(dict(command="social_remove", foe=foe_id))
+ def close_fa(self, username):
+ self.power_tools.actions.close_fa(username)
def handle_session(self, message):
- self._update_checker.server_session()
-
self.session = str(message['session'])
self.get_creds_and_login()
- def handle_update(self, message):
- # Remove geometry settings prior to updating
- # could be incompatible with an updated client.
- config.Settings.remove('window/geometry')
-
- logger.warning("Server says we need an update")
- self._update_checker.server_update(message)
-
def handle_welcome(self, message):
self.state = ClientState.LOGGED_IN
- self._autorelogin = True
- self.id = message["id"]
- self.login = message["login"]
+ self._auto_relogin = True
+ self.id = message["me"]["id"]
+ self.login = message["me"]["login"]
- self.me.onLogin(message["login"], message["id"])
- logger.debug("Login success")
+ self.me.onLogin(self.login, self.id)
+ logger.info("Login success")
util.crash.CRASH_REPORT_USER = self.login
- if self.useUPnP:
- self.lobby_connection.set_upnp(self.gamePort)
-
- self.updateOptions()
+ self.update_options()
self.authorized.emit(self.me)
- # Run an initial connectivity test and initialize a gamesession object
- # when done
- self.connectivity = ConnectivityHelper(self, self.gamePort)
- self.connectivity.connectivity_status_established.connect(self.initialize_game_session)
- self.connectivity.start_test()
+ if self.game_session is None or self.game_session.game_uid is None:
+ self.game_session = GameSession(
+ player_id=self.id,
+ player_login=self.login,
+ )
+ elif self.game_session.game_uid is not None:
+ self.lobby_connection.send({
+ 'command': 'restore_game_session',
+ 'game_id': self.game_session.game_uid,
+ })
+
+ self.game_session.gameFullSignal.connect(self.emit_game_full)
- def initialize_game_session(self):
- self.game_session = fa.GameSession(self, self.connectivity)
- self.game_session.gameFullSignal.connect(self.game_full)
+ def handle_irc_password(self, message: dict) -> None:
+ # DEPRECATED: this command is meaningless and can be removed at any time
+ # see https://github.com/FAForever/server/issues/977
+ ...
def handle_registration_response(self, message):
if message["result"] == "SUCCESS":
@@ -1196,98 +1749,136 @@ def handle_registration_response(self, message):
self.handle_notice({"style": "notice", "text": message["error"]})
- def search_ranked(self, faction):
- def request_launch():
- msg = {
- 'command': 'game_matchmaking',
- 'mod': 'ladder1v1',
- 'state': 'start',
- 'gameport': self.gamePort,
- 'faction': faction
- }
- if self.connectivity.state == 'STUN':
- msg['relay_address'] = self.connectivity.relay_address
- self.lobby_connection.send(msg)
- self.game_session.ready.disconnect(request_launch)
- if self.game_session:
- self.game_session.ready.connect(request_launch)
- self.game_session.listen()
-
- def host_game(self, title, mod, visibility, mapname, password, is_rehost=False):
- def request_launch():
- msg = {
- 'command': 'game_host',
- 'title': title,
- 'mod': mod,
- 'visibility': visibility,
- 'mapname': mapname,
- 'password': password,
- 'is_rehost': is_rehost
- }
- if self.connectivity.state == 'STUN':
- msg['relay_address'] = self.connectivity.relay_address
- self.lobby_connection.send(msg)
- self.game_session.ready.disconnect(request_launch)
- if self.game_session:
- self.game_session.game_password = password
- self.game_session.ready.connect(request_launch)
- self.game_session.listen()
+ def ladderWarningClicked(self, faction=Factions.RANDOM):
+ subFactions = [False] * 4
+ if faction != Factions.RANDOM:
+ subFactions[faction.value - 1] = True
+ config.Settings.set(
+ "play/{}Factions".format(MatchmakerQueueType.LADDER.value),
+ subFactions,
+ )
+ try:
+ self.games.matchmakerQueues.widget(0).subFactions = subFactions
+ self.games.matchmakerQueues.widget(0).setFactionIcons(subFactions)
+ self.games.matchmakerQueues.widget(0).startSearchRanked()
+ except BaseException:
+ QtWidgets.QMessageBox.information(
+ self, "Starting search failed",
+ "Something went wrong, please retry",
+ )
+
+ def search_ranked(self, queue_name):
+ msg = {
+ 'command': 'game_matchmaking',
+ 'queue_name': queue_name,
+ 'state': 'start',
+ }
+ self.lobby_connection.send(msg)
+
+ def handle_match_found_message(self, message):
+ logger.info("Handling match_found via JSON {}".format(message))
+ self.warningHide()
+ self.labelAutomatchInfo.setText("Match found! Pending game launch...")
+ self.labelAutomatchInfo.show()
+ 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))
+ self.labelAutomatchInfo.setText("")
+ self.labelAutomatchInfo.hide()
+ self.games.handleMatchCancelled(message)
+
+ def host_game(
+ self,
+ title,
+ mod,
+ visibility,
+ mapname,
+ password,
+ is_rehost=False,
+ ):
+ msg = {
+ 'command': 'game_host',
+ 'title': title,
+ 'mod': mod,
+ 'visibility': visibility,
+ 'mapname': mapname,
+ 'password': password,
+ 'is_rehost': is_rehost,
+ }
+ self.lobby_connection.send(msg)
def join_game(self, uid, password=None):
- def request_launch():
- msg = {
- 'command': 'game_join',
- 'uid': uid,
- 'gameport': self.gamePort
- }
- if password:
- msg['password'] = password
- if self.connectivity.state == "STUN":
- msg['relay_address'] = self.connectivity.relay_address
- self.lobby_connection.send(msg)
- self.game_session.ready.disconnect(request_launch)
- if self.game_session:
- self.game_session.game_password = password
- self.game_session.ready.connect(request_launch)
- self.game_session.listen()
+ msg = {
+ 'command': 'game_join',
+ 'uid': uid,
+ 'gameport': 0,
+ }
+ if password:
+ msg['password'] = password
+ self.lobby_connection.send(msg)
def handle_game_launch(self, message):
- if not self.game_session or not self.connectivity.is_ready:
- logger.error("Not ready for game launch")
+ self.game_session.game_uid = message['uid']
+ self.game_session.startIceAdapter()
- logger.info("Handling game_launch via JSON " + str(message))
+ logger.info("Handling game_launch via JSON {}".format(message))
silent = False
# Do some special things depending of the reason of the game launch.
- rank = False
- # HACK: Ideally, this comes from the server, too. LATER: search_ranked message
+ # HACK: Ideally, this comes from the server, too.
+ # LATER: search_ranked message
arguments = []
- if message["mod"] == "ladder1v1":
- arguments.append('/' + Factions.to_name(self.games.race))
- # Player 1v1 rating
+ if self.games.matchFoundQueueName:
+ self.labelAutomatchInfo.setText("Launching the game...")
+ ratingType = message.get("rating_type", RatingType.GLOBAL.value)
+ factionSubset = config.Settings.get(
+ "play/{}Factions".format(self.games.matchFoundQueueName),
+ default=[False] * 4,
+ type=bool,
+ )
+ faction = Factions.set_faction(factionSubset)
+ arguments.append('/' + Factions.to_name(faction))
+ # Player rating
arguments.append('/mean')
- arguments.append(str(self.me.player.ladder_rating_mean))
+ arguments.append(
+ str(self.me.player.rating_mean(ratingType)),
+ )
arguments.append('/deviation')
- arguments.append(str(self.me.player.ladder_rating_deviation))
- arguments.append('/players 2') # Always 2 players in 1v1 ladder
- arguments.append('/team 1') # Always FFA team
+ arguments.append(
+ str(self.me.player.rating_deviation(ratingType)),
+ )
+
+ arguments.append('/players')
+ arguments.append(str(message["expected_players"]))
+ arguments.append('/team')
+ arguments.append(str(message["team"]))
+ arguments.append('/startspot')
+ arguments.append(str(message["map_position"]))
+ if message.get("game_options"):
+ arguments.append('/gameoptions')
+ for key, value in message["game_options"].items():
+ arguments.append('{}:{}'.format(key, value))
# Launch the auto lobby
- self.game_session.init_mode = 1
-
+ self.game_session.setLobbyInitMode("auto")
else:
# Player global rating
arguments.append('/mean')
- arguments.append(str(self.me.player.rating_mean))
+ arguments.append(str(self.me.player.global_rating_mean))
arguments.append('/deviation')
- arguments.append(str(self.me.player.rating_deviation))
+ arguments.append(str(self.me.player.global_rating_deviation))
if self.me.player.country is not None:
arguments.append('/country ')
arguments.append(self.me.player.country)
# Launch the normal lobby
- self.game_session.init_mode = 0
+ self.game_session.setLobbyInitMode("normal")
+
+ arguments.append('/numgames')
+ arguments.append(str(message["args"][1]))
if self.me.player.clan is not None:
arguments.append('/clan')
@@ -1300,18 +1891,21 @@ def handle_game_launch(self, message):
if "sim_mods" in message:
fa.mods.checkMods(message['sim_mods'])
- # UPnP Mapper - mappings are removed on app exit
- if self.useUPnP:
- self.lobby_connection.set_upnp(self.gamePort)
-
- info = dict(uid=message['uid'], recorder=self.login, featured_mod=message['mod'], launched_at=time.time())
+ info = dict(
+ uid=message['uid'],
+ recorder=self.login,
+ featured_mod=message['mod'],
+ launched_at=time.time(),
+ )
- self.game_session.game_uid = message['uid']
-
- fa.run(info, self.game_session.relay_port, arguments, self.game_session.game_uid)
+ fa.run(
+ info, self.game_session.relay_port, self.replayServer.serverPort(),
+ arguments, self.game_session.game_uid,
+ )
def fill_in_session_info(self, game):
- # sometimes we get the game_info message before a game session was created
+ # sometimes we get the game_info message before a game session was
+ # created
if self.game_session and game.uid == self.game_session.game_uid:
self.game_session.game_map = game.mapname
self.game_session.game_mod = game.featured_mod
@@ -1319,43 +1913,44 @@ def fill_in_session_info(self, game):
self.game_session.game_visibility = game.visibility.value
def handle_matchmaker_info(self, message):
+ logger.debug(
+ "Handling matchmaker info with message {}".format(message),
+ )
if not self.me.player:
return
- if "action" in message:
- self.matchmakerInfo.emit(message)
- elif "queues" in message:
- if self.me.player.ladder_rating_deviation > 200 or self.games.searching:
- return
- key = 'boundary_80s' if self.me.player.ladder_rating_deviation < 100 else 'boundary_75s'
- show = False
+ self.matchmaker_info.emit(message)
+ if "queues" in message:
+ show = None
for q in message['queues']:
if q['queue_name'] == 'ladder1v1':
+ show = False
mu = self.me.player.ladder_rating_mean
+ if self.me.player.ladder_rating_deviation < 100:
+ key = 'boundary_80s'
+ else:
+ key = 'boundary_75s'
for min, max in q[key]:
if min < mu < max:
show = True
- if show:
- self.warningShow()
- else:
- self.warningHide()
+ if (
+ self.me.player.ladder_rating_deviation > 200
+ or self.games.searching.get("ladder1v1", False)
+ ):
+ return
+ if show is not None:
+ if show and not self.games.matchFoundQueueName:
+ self.warningShow()
+ else:
+ self.warningHide()
def handle_social(self, message):
- if "friends" in message:
- self.me.setFriends(set([int(u) for u in message["friends"]]))
-
- if "foes" in message:
- self.me.setFoes(set([int(u) for u in message["foes"]]))
-
if "channels" in message:
# Add a delay to the notification system (insane cargo cult)
self.notificationSystem.disabledStartup = False
- self.channelsUpdated.emit(message["channels"])
-
- if "autojoin" in message:
- self.autoJoin.emit(message["autojoin"])
+ self.channels_updated.emit(message["channels"])
if "power" in message:
- self.power = message["power"]
+ self.power_tools.power = message["power"]
self.manage_power()
def handle_player_info(self, message):
@@ -1368,32 +1963,50 @@ def handle_player_info(self, message):
for player in players:
id_ = int(player["id_"])
+ logger.debug('Received update about player {}'.format(id_))
if id_ in self.players:
self.players[id_].update(**player)
else:
self.players[id_] = Player(**player)
- def avatarManager(self):
- self.requestAvatars(0)
- self.avatarSelection.show()
-
def handle_authentication_failed(self, message):
- QtWidgets.QMessageBox.warning(self, "Authentication failed", message["text"])
- self._autorelogin = False
- self.get_creds_and_login()
+ QtWidgets.QMessageBox.warning(
+ self, "Authentication failed", message["text"],
+ )
+ self._auto_relogin = False
+ self.disconnect_()
+ self.show_login_widget()
def handle_notice(self, message):
if "text" in message:
style = message.get('style', None)
if style == "error":
- QtWidgets.QMessageBox.critical(self, "Error from Server", message["text"])
+ logger.error(
+ "Received an error message from server: {}"
+ .format(message),
+ )
+ QtWidgets.QMessageBox.critical(
+ self, "Error from Server", message["text"],
+ )
elif style == "warning":
- QtWidgets.QMessageBox.warning(self, "Warning from Server", message["text"])
+ logger.warning(
+ "Received warning message from server: {}".format(message),
+ )
+ QtWidgets.QMessageBox.warning(
+ self, "Warning from Server", message["text"],
+ )
elif style == "scores":
- self.tray.showMessage("Scores", message["text"], QtWidgets.QSystemTrayIcon.Information, 3500)
- self.localBroadcast.emit("Scores", message["text"])
+ self.tray.showMessage(
+ "Scores", message["text"],
+ QtWidgets.QSystemTrayIcon.Information, 3500,
+ )
+ self.local_broadcast.emit("Scores", message["text"])
+ elif "You are using an unofficial client" in message["text"]:
+ self.unofficial_client.emit(message["text"])
else:
- QtWidgets.QMessageBox.information(self, "Notice from Server", message["text"])
+ QtWidgets.QMessageBox.information(
+ self, "Notice from Server", message["text"],
+ )
if message["style"] == "kill":
logger.info("Server has killed your Forged Alliance Process.")
@@ -1402,15 +2015,60 @@ def handle_notice(self, message):
if message["style"] == "kick":
logger.info("Server has kicked you from the Lobby.")
- # This is part of the protocol - in this case we should not relogin automatically.
+ # This is part of the protocol - in this case we should not relogin
+ # automatically.
if message["style"] in ["error", "kick"]:
- self._autorelogin = False
+ self._auto_relogin = False
def handle_invalid(self, message):
# We did something wrong and the server will disconnect, let's not
# reconnect and potentially cause the same error again and again
- self.lobby_reconnecter.enabled = False
+ self.lobby_reconnector.enabled = False
raise Exception(message)
- def game_full(self):
- self.gameFull.emit()
+ def emit_game_full(self):
+ self.game_full.emit()
+
+ def invite_to_party(self, recipient_id):
+ self.games.stopSearch()
+ msg = {
+ 'command': 'invite_to_party',
+ 'recipient_id': recipient_id,
+ }
+ self.lobby_connection.send(msg)
+
+ def handle_party_invite(self, message):
+ logger.info("Handling party_invite via JSON {}".format(message))
+ self.party_invite.emit(message)
+
+ def handle_update_party(self, message):
+ logger.info("Handling update_party via JSON {}".format(message))
+ self.games.updateParty(message)
+
+ def handle_kicked_from_party(self, message):
+ if self.me.player and self.me.player.currentGame is None:
+ QtWidgets.QMessageBox.information(
+ self, "Kicked", "You were kicked from party",
+ )
+ msg = {
+ "owner": self.me.id,
+ "members": [
+ {
+ "player": self.me.id,
+ "factions": ["uef", "cybran", "aeon", "seraphim"],
+ },
+ ],
+ }
+ self.games.updateParty(msg)
+
+ def set_faction(self, faction):
+ logger.info("Setting party factions to {}".format(faction))
+ msg = {
+ 'command': 'set_party_factions',
+ 'factions': faction,
+ }
+ self.lobby_connection.send(msg)
+
+ def handle_search_info(self, message):
+ logger.info("Handling search_info via JSON: {}".format(message))
+ self.games.handleMatchmakerSearchInfo(message)
diff --git a/src/client/aliasviewer.py b/src/client/aliasviewer.py
index 76a301343..6996511ce 100644
--- a/src/client/aliasviewer.py
+++ b/src/client/aliasviewer.py
@@ -1,218 +1,176 @@
-import urllib.request
-import urllib.error
-import urllib.parse
-import json
-import copy
-import time
-from PyQt5 import QtWidgets
-
import logging
-logger = logging.getLogger(__name__)
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QDateTime
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QTimer
-class ApiError(Exception):
- def __init__(self, reason):
- Exception.__init__(self)
- self.reason = reason
+from api.player_api import PlayerApiConnector
+logger = logging.getLogger(__name__)
-class AliasViewer:
- def __init__(self):
- pass
- # TODO refactor once async api is implemented
- def _api_request(self, link):
- try:
- with urllib.request.urlopen(link) as response:
- return json.loads(response.read().decode())
- except urllib.error.URLError as e:
- raise ApiError("Failed to get link {}: {}".format(link, e.reason))
- except json.decoder.JSONDecodeError as e:
- raise ApiError("Failed to decode incoming JSON")
-
- def _parse_time(self, t):
- return time.strptime(t, "%Y-%m-%dT%H:%M:%SZ")
-
- def player_id_by_name(self, checked_name):
- api_link = 'https://api.faforever.com/data/player' \
- '?filter=login=={name}' \
- '&fields[player]='
- query = api_link.format(name=checked_name)
- response = self._api_request(query)
- if response is None or len(response['data']) == 0:
- return None
- return int(response['data'][0]['id'])
-
- def names_previously_known(self, user_id):
- api_link = 'https://api.faforever.com/data/player/{id_}' \
- '?include=names' \
- '&fields[player]=login' \
- '&fields[nameRecord]=name,changeTime'
- query = api_link.format(id_=user_id)
- response = self._api_request(query)
- if response is None or 'included' not in response:
- return []
-
- aliases = []
- for name in response['included']:
- if name['type'] != 'nameRecord':
- continue
- nick_name = name['attributes']['name']
- try:
- nick_time = self._parse_time(name['attributes']['changeTime'])
- except ValueError:
- continue
- aliases.append({'name': nick_name, 'time': nick_time})
-
- player = response['data']
- aliases.append({'name': player['attributes']['login'],
- 'time': None})
- return aliases
-
- def name_used_by_others(self, checked_name):
- api_link = 'https://api.faforever.com/data/player' \
- '?include=names' \
- '&filter=(login=={name},names.name=={name})' \
- '&fields[player]=login,names' \
- '&fields[nameRecord]=name,changeTime'
- query = api_link.format(name=checked_name)
- response = self._api_request(query)
- if response is None or 'data' not in response:
- return []
-
- players = [p for p in response['data'] if p['type'] == 'player']
- if 'included' not in response:
- names = []
- else:
- names = [n for n in response['included'] if n['type'] == 'nameRecord'
- and n['attributes']['name'] == checked_name]
- result = []
-
- for p in players:
- p_login = p['attributes']['login']
- p_id = p['id']
- if 'relationships' not in p:
- p_name_ids = []
+class AliasViewer:
+ def __init__(self, client, alias_formatter):
+ self.client = client
+ self.formatter = alias_formatter
+ self.api_connector = PlayerApiConnector()
+ self.api_connector.alias_info.connect(self.process_alias_info)
+ self.name_to_find = ""
+ self.searching = False
+ self.timer = QTimer()
+ self.timer.timeout.connect(self.stop_alias_search)
+
+ def find_aliases(self, login):
+ if self.searching:
+ return
+ self.name_to_find = login
+ self.api_connector.requestDataForAliasViewer(login)
+ self.searching = True
+ self.timer.start(10000)
+
+ def stop_alias_search(self):
+ self.searching = False
+ self.timer.stop()
+
+ def process_alias_info(self, message):
+ self.stop_alias_search()
+
+ player_aliases, other_users = [], []
+ for player in message["data"]:
+ if player["login"].lower() == self.name_to_find.lower():
+ player_aliases.append({
+ "name": player["login"],
+ "changeTime": None,
+ })
+ for name_record in player["names"]:
+ player_aliases.append(name_record)
else:
- p_name_ids = set(n['id'] for n in p['relationships']['names']['data'])
- p_names = [n for n in names if n['id'] in p_name_ids]
- result_entry = {'name': p_login, 'id': p_id}
-
- if p_login == checked_name:
- result_entry['time'] = None
- result.append(copy.copy(result_entry))
- for name in p_names:
- try:
- t = self._parse_time(name['attributes']['changeTime'])
- result_entry['time'] = t
- result.append(copy.copy(result_entry))
- except ValueError:
- continue
- return result
+ for name_record in player["names"]:
+ name = name_record["name"]
+ if name.lower() == self.name_to_find.lower():
+ other_users.append({
+ "name": player["login"],
+ "changeTime": name_record["changeTime"],
+ })
+
+ self.show_aliases(player_aliases, other_users)
+
+ def show_aliases(self, player_aliases, other_users):
+ QtWidgets.QMessageBox.about(
+ self.client,
+ "Aliases : {}".format(self.name_to_find),
+ self.formatter.format_aliases(player_aliases, other_users),
+ )
class AliasFormatter:
def __init__(self):
pass
- def nick_times(self, times):
- past_times = [t for t in times if t['time'] is not None]
- current_times = [t for t in times if t['time'] is None]
-
- past_times.sort(key=lambda t: t['time'])
- name_format = "{}"
- past_format = "{}"
- current_format = "now"
- past_strings = [(name_format.format(e['name']),
- past_format.format(time.strftime('%Y-%m-%d %H:%M', e['time'])))
- for e in past_times]
- current_strings = [(name_format.format(e['name']),
- current_format)
- for e in current_times]
- return past_strings + current_strings
+ def nick_times(self, name_records):
+ past_records = [
+ record
+ for record in name_records
+ if record["changeTime"] is not None
+ ]
+ current_records = [
+ record
+ for record in name_records
+ if record["changeTime"] is None
+ ]
+
+ for record in past_records:
+ isoTime = QDateTime.fromString(record["changeTime"], Qt.DateFormat.ISODate)
+ record["changeTime"] = isoTime.toLocalTime()
+
+ past_records.sort(key=lambda record: record["changeTime"])
+
+ for record in past_records:
+ record["changeTime"] = QDateTime.toString(
+ record["changeTime"], "yyyy-MM-dd ' ' hh:mm",
+ )
+ for record in current_records:
+ record["changeTime"] = "now"
+
+ return past_records + current_records
def nick_time_table(self, nicks):
- table = '
' \
- '{}' \
- '
'
- head = '
Name
used until
'
+ table = (
+ '
{}
'
+ )
+ head = (
+ '
Name
used until'
+ '
'
+ )
line_fmt = '
{}
{}
'
- lines = [line_fmt.format(*n) for n in nicks]
+ lines = [
+ line_fmt.format(nick["name"], nick["changeTime"])
+ for nick in nicks
+ ]
return table.format(head + "".join(lines))
- def name_used_by_others(self, others, original_user=None):
- if others is None:
- return ''
-
- others = [u for u in others if u['name'] != original_user]
- if len(others) == 0 and original_user is None:
+ def name_used_by_others(self, player_aliases, other_users):
+ if len(player_aliases) == len(other_users) == 0:
return 'The name has never been used.'
- if len(others) == 0 and original_user is not None:
+ elif len(other_users) == 0:
return 'The name has never been used by anyone else.'
- return 'The name has previously been used by:{}'.format(
- self.nick_time_table(self.nick_times(others)))
+ return (
+ 'The name has previously been used by:{}'
+ .format(self.nick_time_table(self.nick_times(other_users)))
+ )
- def names_previously_known(self, response):
- if response is None:
+ def names_previously_known(self, player_aliases):
+ if len(player_aliases) == 0:
return ''
-
- if len(response) == 0:
+ elif len(player_aliases) == 1:
return 'The user has never changed their name.'
- return 'The player has previously been known as:{}'.format(
- self.nick_time_table(self.nick_times(response)))
+
+ return (
+ 'The player has previously been known as:{}'
+ .format(self.nick_time_table(self.nick_times(player_aliases)))
+ )
+
+ def format_aliases(self, player_aliases, other_users):
+ alias_format = self.names_previously_known(player_aliases)
+ others_format = self.name_used_by_others(player_aliases, other_users)
+ result = '{}
{}'.format(alias_format, others_format)
+ return result
class AliasWindow:
- def __init__(self, parent):
- self._parent = parent
- self._api = AliasViewer()
- self._fmt = AliasFormatter()
-
- def view_aliases(self, name, id_=None):
- player_aliases = None
- other_users = None
- try:
- other_users = self._api.name_used_by_others(name)
- if id_ is None:
- users_now = [u for u in other_users if u['time'] is None]
- if len(users_now) > 0:
- id_ = users_now[0]['id']
- if id_ is not None:
- player_aliases = self._api.names_previously_known(id_)
- except ApiError as e:
- logger.error(e.reason)
- warning_text = ("Failed to query the FAF API: "
- "{exception} "
- "Some info may be incomplete!")
- warning_text = warning_text.format(exception=e.reason)
- QtWidgets.QMessageBox.warning(self._parent,
- "API read error",
- warning_text)
-
- if player_aliases is None and other_users is None:
- return
+ def __init__(self, parent_widget, alias_viewer):
+ self._parent_widget = parent_widget
+ self._alias_viewer = alias_viewer
- alias_format = self._fmt.names_previously_known(player_aliases)
- others_format = self._fmt.name_used_by_others(other_users, name)
- result = '{}
",
+ )
progress.show()
while self.running() and progress.isVisible():
diff --git a/src/fa/game_runner.py b/src/fa/game_runner.py
new file mode 100644
index 000000000..0ff0f6a60
--- /dev/null
+++ b/src/fa/game_runner.py
@@ -0,0 +1,38 @@
+import logging
+
+import fa
+from fa.replay import replay
+from model.game import GameState
+from util.gameurl import GameUrl
+
+logger = logging.getLogger(__name__)
+
+
+class GameRunner:
+ def __init__(self, gameset, client_window):
+ self._gameset = gameset
+ self._client_window = client_window # FIXME
+
+ def run_game_with_url(self, game, pid):
+ gurl = game.url(pid)
+ if gurl is None:
+ return
+ self.run_game_from_url(gurl)
+
+ def run_game_from_url(self, gurl):
+ game = self._gameset.get(gurl.uid, None)
+ if game is None or game.closed():
+ return
+
+ if game.state == GameState.OPEN:
+ self._join_game_from_url(gurl)
+ elif game.state == GameState.PLAYING:
+ replay(gurl)
+
+ def _join_game_from_url(self, gurl: GameUrl) -> None:
+ logger.debug("Joining game from URL: " + gurl.to_url().toString())
+ if fa.instance.available():
+ add_mods = gurl.mods or {}
+ if fa.check.game(self):
+ if fa.check.check(gurl.mod, gurl.map, sim_mods=add_mods):
+ self._client_window.join_game(gurl.uid)
diff --git a/src/fa/game_session.py b/src/fa/game_session.py
index 2dc528d4b..39fa1e6d8 100644
--- a/src/fa/game_session.py
+++ b/src/fa/game_session.py
@@ -1,19 +1,23 @@
-from PyQt5.QtCore import QObject, pyqtSignal
-from PyQt5.QtNetwork import QTcpServer, QHostAddress
+import logging
from enum import IntEnum
-from connectivity.turn import TURNState
+from PyQt6.QtCore import QCoreApplication
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+import client
from config import setup_file_handler
-from fa.game_connection import GPGNetConnection
-from fa.game_process import instance
+from connectivity.IceAdapterClient import IceAdapterClient
+from connectivity.IceAdapterProcess import IceAdapterProcess
+from connectivity.IceServersPoller import IceServersPoller
+from fa.game_process import instance as game_process_instance
-import logging
logger = logging.getLogger(__name__)
-
# Log to a separate file to not pollute normal log with huge json dumps
logger.propagate = False
logger.addHandler(setup_file_handler('gamesession.log'))
+
class GameSessionState(IntEnum):
# Game services are entirely off
OFF = 0
@@ -31,7 +35,7 @@ class GameSession(QObject):
ready = pyqtSignal()
gameFullSignal = pyqtSignal()
- def __init__(self, client, connectivity):
+ def __init__(self, player_id, player_login):
QObject.__init__(self)
self._state = GameSessionState.OFF
self._rehost = False
@@ -41,41 +45,65 @@ def __init__(self, client, connectivity):
self.game_visibility = None
self.game_map = None
self.game_password = None
+ self.player_id = player_id
+ self.player_login = player_login
+ client.instance.lobby_dispatch.subscribe_to(
+ 'game', self.handle_message,
+ )
- # Subscribe to messages targeted at 'game' from the server
- client.lobby_dispatch.subscribe_to('game', self.handle_message)
-
- # Connectivity helper
- self.connectivity = connectivity
- self.connectivity.ready.connect(self.ready.emit)
- self.connectivity.peer_bound.connect(self._peer_bound)
-
- # Keep a parent pointer so we can use it to send
- # relay messages about the game state
- self._client = client # type: Client
- self.me = client.me
-
- self.game_port = client.gamePort
-
- # Use the normal lobby by default
- self.init_mode = 0
self._joins, self._connects = [], []
- # 'GPGNet' TCP listener
- self._game_listener = QTcpServer(self)
- self._game_listener.newConnection.connect(self._new_game_connection)
- self._game_listener.listen(QHostAddress.LocalHost)
-
- # We only allow one game connection at a time
- self._game_connection = None
-
- self._process = instance # type:'GameProcess'
+ self._process = game_process_instance # type - GameProcess
self._process.started.connect(self._launched)
self._process.finished.connect(self._exited)
+ self.state = GameSessionState.LISTENING
+
+ self._relay_port = 0
+
+ self.ice_adapter_process = None
+ self.ice_adapter_client = None
+ self.ice_servers_poller = None
+
+ def startIceAdapter(self):
+ self.ice_adapter_process = IceAdapterProcess(
+ player_id=self.player_id,
+ player_login=self.player_login,
+ game_id=self.game_uid,
+ )
+ self.ice_adapter_client = IceAdapterClient(game_session=self)
+ self.ice_adapter_client.statusChanged.connect(self.onIceAdapterStarted)
+ self.ice_adapter_client.connect_(
+ "127.0.0.1", self.ice_adapter_process.rpc_port(),
+ )
+ while self._relay_port == 0:
+ QCoreApplication.processEvents()
+
+ def onIceAdapterStarted(self, status: dict) -> None:
+ self._relay_port = status["gpgnet"]["local_port"]
+ logger.info(
+ "ICE adapter started an listening on port {} for GPGNet "
+ "connections".format(self._relay_port),
+ )
+ self.ice_adapter_client.statusChanged.disconnect(self.onIceAdapterStarted)
+ self.ice_servers_poller = IceServersPoller(self.ice_adapter_client, self.game_uid)
+
+ def closeIceAdapter(self):
+ if self.ice_adapter_client:
+ try:
+ self.ice_adapter_client.call("quit", blocking=True)
+ except RuntimeError:
+ pass
+ self.ice_adapter_client.close()
+ self.ice_adapter_client = None
+ if self.ice_adapter_process:
+ self.ice_adapter_process.close()
+ self.ice_adapter_process = None
+ self._relay_port = 0
+
@property
def relay_port(self):
- return self._game_listener.serverPort()
+ return self._relay_port
@property
def state(self):
@@ -85,117 +113,90 @@ def state(self):
def state(self, val):
self._state = val
- def listen(self):
- """
- Start listening for remote commands
-
- Call this in good time before hosting a game,
- e.g. when the host game dialog is being shown.
- """
- assert self.state == GameSessionState.OFF
- self.state = GameSessionState.LISTENING
- if self.connectivity.is_ready:
- self.ready.emit()
- else:
- self.connectivity.prepare()
-
- def _needs_game_connection(fn):
- def wrap(self, *args, **kwargs):
- if self._game_connection is None:
- logger.warning("{}.{}: tried to run without a game connection".format(
- self.__class__.__name__, fn.__name__))
- else:
- return fn(self, *args, **kwargs)
- return wrap
-
- @_needs_game_connection
def handle_message(self, message):
command, args = message.get('command'), message.get('args', [])
if command == 'SendNatPacket':
- addr_and_port, message = args
- host, port = addr_and_port.split(':')
- self.connectivity.send(message, (host, port))
+ # we ignore that for now with the ICE Adapter
+ pass
elif command == 'CreatePermission':
- addr_and_port = args[0]
- host, port = addr_and_port.split(':')
- self.connectivity.permit((host, port))
+ # we ignore that for now with the ICE Adapter
+ pass
elif command == 'JoinGame':
- addr, login, peer_id = args
- self._joins.append(peer_id)
- self.connectivity.bind(addr, login, peer_id)
+ login, peer_id = args
+ self.ice_adapter_client.call("joinGame", [login, peer_id])
+ elif command == 'HostGame':
+ self.ice_adapter_client.call("hostGame", [args[0]])
elif command == 'ConnectToPeer':
- addr, login, peer_id = args
- self._connects.append(peer_id)
- self.connectivity.bind(addr, login, peer_id)
+ login, peer_id, offer = args
+ self.ice_adapter_client.call(
+ "connectToPeer", [login, peer_id, offer],
+ )
+ elif command == 'DisconnectFromPeer':
+ self.ice_adapter_client.call("disconnectFromPeer", [args[0]])
+ elif command == "IceMsg":
+ peer_id, ice_msg = args
+ self.ice_adapter_client.call("iceMsg", [peer_id, ice_msg])
else:
- self._game_connection.send(command, *args)
+ logger.warning(
+ "sending unhandled GPGNet message {} {}".format(command, args),
+ )
+ self.ice_adapter_client.call("sendToGpgNet", [command, args])
def send(self, command_id, args):
logger.info("Outgoing relay message {} {}".format(command_id, args))
- self._client.lobby_connection.send({
+ client.instance.lobby_connection.send({
'command': command_id,
'target': 'game',
- 'args': args or []
+ 'args': args or [],
})
- @_needs_game_connection
- def _peer_bound(self, login, peer_id, port):
- logger.info("Bound peer {}/{} to {}".format(login, peer_id, port))
- if peer_id in self._connects:
- self._game_connection.send('ConnectToPeer', '127.0.0.1:{}'.format(port), login, peer_id)
- self._connects.remove(peer_id)
- elif peer_id in self._joins:
- self._game_connection.send('JoinGame', '127.0.0.1:{}'.format(port), login, peer_id)
- self._joins.remove(peer_id)
+ def setLobbyInitMode(self, lobby_init_mode):
+ # to do: make this call synchronous/blocking, because init_mode must be
+ # set before game_launch.
+ # See ClientWindow.handle_game_launch()
+ if (
+ not self.ice_adapter_client
+ or not self.ice_adapter_client.connected
+ ):
+ logger.error(
+ "ICE adapter client not connected when calling "
+ "setLobbyInitMode",
+ )
+ return
+ self.ice_adapter_client.call("setLobbyInitMode", [lobby_init_mode])
def _new_game_connection(self):
logger.info("Game connected through GPGNet")
- assert not self._game_connection
- self._game_connection = GPGNetConnection(self._game_listener.nextPendingConnection())
- self._game_connection.messageReceived.connect(self._on_game_message)
self.state = GameSessionState.RUNNING
+ self.ready.emit()
- @_needs_game_connection
def _on_game_message(self, command, args):
logger.info("Incoming GPGNet: {} {}".format(command, args))
- if command == "GameState":
- if args[0] == 'Idle':
- # autolobby, port, nickname, uid, hasSupcom
- self._game_connection.send("CreateLobby",
- self.init_mode,
- self.game_port + 1,
- self.me.player.login,
- self.me.player.id,
- 1)
- elif args[0] == 'Lobby':
- # TODO: Eagerly initialize the game by hosting/joining early
- pass
- elif command == 'Rehost':
+ if command == 'Rehost':
self._rehost = True
elif command == 'GameFull':
self.gameFullSignal.emit()
self.send(command, args)
- def _turn_state_changed(self, val):
- if val == TURNState.BOUND:
- self.ready.emit()
-
def _launched(self):
logger.info("Game has started")
+ client.instance.lobby_reconnector.keepalive = True
def _exited(self, status):
- self._game_connection = None
self.state = GameSessionState.OFF
logger.info("Game has exited with status code: {}".format(status))
self.send('GameState', ['Ended'])
+ client.instance.lobby_reconnector.keepalive = False
if self._rehost:
- self._client.host_game(title=self.game_name,
- mod=self.game_mod,
- visibility=self.game_visibility,
- mapname=self.game_map,
- password=self.game_password,
- is_rehost=True)
+ client.instance.host_game(
+ title=self.game_name,
+ mod=self.game_mod,
+ visibility=self.game_visibility,
+ mapname=self.game_map,
+ password=self.game_password,
+ is_rehost=True,
+ )
self._rehost = False
self.game_uid = None
@@ -204,3 +205,4 @@ def _exited(self, status):
self.game_visibility = None
self.game_map = None
self.game_password = None
+ self.closeIceAdapter()
diff --git a/src/fa/game_updater/__init__.py b/src/fa/game_updater/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/fa/game_updater/misc.py b/src/fa/game_updater/misc.py
new file mode 100644
index 000000000..09774e5a9
--- /dev/null
+++ b/src/fa/game_updater/misc.py
@@ -0,0 +1,75 @@
+import logging
+import time
+from enum import Enum
+from typing import NamedTuple
+
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QMessageBox
+
+
+# A set of exceptions we use to see what goes wrong during asynchronous data
+# transfer waits
+class UpdaterCancellation(Exception):
+ pass
+
+
+class UpdaterFailure(Exception):
+ pass
+
+
+class UpdaterTimeout(Exception):
+ pass
+
+
+class UpdaterResult(Enum):
+ SUCCESS = 0 # Update successful
+ NONE = -1 # Update operation is still ongoing
+ FAILURE = 1 # An error occured during updating
+ CANCEL = 2 # User cancelled the download process
+
+
+class ProgressInfo(NamedTuple):
+ progress: int
+ total: int
+ description: str = ""
+
+
+# This contains a complete dump of everything that was supplied to logOutput
+debug_log = []
+
+
+def clear_log() -> None:
+ global debug_log
+ debug_log = []
+
+
+def log(string: str, loger: logging.Logger) -> None:
+ loger.debug(string)
+ debug_log.append(str(string))
+
+
+def dump_plain_text() -> str:
+ return "\n".join(debug_log)
+
+
+def dump_HTML() -> str:
+ return " ".join(debug_log)
+
+
+def timestamp() -> str:
+ return time.strftime("%Y-%m-%d %H:%M:%S")
+
+
+# It works, but will need some work later
+def failure_dialog() -> None:
+ """
+ The dialog that shows the user the log if something went wrong.
+ """
+ mbox = QMessageBox()
+ mbox.setParent(QApplication.activeWindow())
+ mbox.setWindowFlags(Qt.WindowType.Dialog)
+ mbox.setWindowTitle("Update Failed")
+ mbox.setText("An error occurred during downloading/copying/moving files")
+ mbox.setDetailedText(dump_plain_text())
+ mbox.exec()
diff --git a/src/fa/game_updater/patcher.py b/src/fa/game_updater/patcher.py
new file mode 100644
index 000000000..028c52d02
--- /dev/null
+++ b/src/fa/game_updater/patcher.py
@@ -0,0 +1,30 @@
+import logging
+
+from PyQt6.QtCore import QFile
+
+from util.qt import qopen
+
+logger = logging.getLogger(__name__)
+
+
+class FAPatcher:
+ version_addresses = (0xd3d40, 0x47612d, 0x476666)
+
+ @staticmethod
+ def read_version(path: str) -> int:
+ with qopen(path, QFile.OpenModeFlag.ReadOnly) as file:
+ if not file.isOpen():
+ return -1
+ file.seek(FAPatcher.version_addresses[0])
+ return int.from_bytes(file.read(4), "little")
+
+ @staticmethod
+ def patch(path: str, version: int) -> bool:
+ with qopen(path, QFile.OpenModeFlag.ReadWrite) as file:
+ if not file.isOpen():
+ return False
+ for address in FAPatcher.version_addresses:
+ file.seek(address)
+ file.write(version.to_bytes(4, "little"))
+ logger.info(f"Patched {path!r} to version {version!r}")
+ return True
diff --git a/src/fa/game_updater/updater.py b/src/fa/game_updater/updater.py
new file mode 100644
index 000000000..09d90f3c8
--- /dev/null
+++ b/src/fa/game_updater/updater.py
@@ -0,0 +1,227 @@
+
+"""
+This is the FORGED ALLIANCE updater.
+
+It ensures, through communication with faforever.com, that Forged Alliance
+is properly updated, patched, and all required files for a given mod are
+installed
+
+@author thygrrr
+"""
+from __future__ import annotations
+
+import logging
+
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QThread
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSlot
+from PyQt6.QtGui import QTextCursor
+from PyQt6.QtWidgets import QDialog
+
+import util
+from downloadManager import FileDownload
+from fa.game_updater.misc import ProgressInfo
+from fa.game_updater.misc import UpdaterResult
+from fa.game_updater.misc import clear_log
+from fa.game_updater.misc import failure_dialog
+from fa.game_updater.misc import log
+from fa.game_updater.misc import timestamp
+from fa.game_updater.worker import UpdaterWorker
+
+logger = logging.getLogger(__name__)
+
+
+FormClass, BaseClass = util.THEME.loadUiType("fa/updater/updater.ui")
+
+
+class UpdaterProgressDialog(FormClass, BaseClass):
+ aborted = pyqtSignal()
+
+ def __init__(self, parent: QObject, silent: bool = False) -> None:
+ BaseClass.__init__(self, parent)
+ self.setupUi(self)
+ self.setModal(True)
+ self.logPlainTextEdit.setLineWrapMode(self.logPlainTextEdit.LineWrapMode.NoWrap)
+ self.logFrame.setVisible(False)
+ self.adjustSize()
+ self.watches = []
+
+ if silent:
+ self.abortButton.hide()
+
+ self.rejected.connect(self.abort)
+ self.abortButton.clicked.connect(self.reject)
+ self.detailsButton.clicked.connect(self.change_details_visibility)
+ self.load_stylesheet()
+
+ def load_stylesheet(self):
+ self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
+
+ def change_details_visibility(self) -> None:
+ visible = self.logFrame.isVisible()
+ self.logFrame.setVisible(not visible)
+ self.adjustSize()
+
+ def abort(self) -> None:
+ self.aborted.emit()
+
+ @pyqtSlot(str)
+ def append_log(self, text: str) -> None:
+ self.logPlainTextEdit.appendPlainText(text)
+
+ def replace_last_log_line(self, text: str) -> None:
+ self.logPlainTextEdit.moveCursor(
+ QTextCursor.MoveOperation.StartOfLine,
+ QTextCursor.MoveMode.KeepAnchor,
+ )
+ self.logPlainTextEdit.textCursor().removeSelectedText()
+ self.logPlainTextEdit.insertPlainText(text)
+
+ @pyqtSlot(QObject)
+ def add_watch(self, watch: QObject) -> None:
+ self.watches.append(watch)
+ watch.finished.connect(self.watch_finished)
+
+ @pyqtSlot()
+ def watch_finished(self) -> None:
+ for watch in self.watches:
+ if not watch.isFinished():
+ return
+ # equivalent to self.accept(), but clearer
+ self.done(QDialog.DialogCode.Accepted)
+
+ def on_processed_mod_changed(self, info: ProgressInfo) -> None:
+ text = f"Updating {info.description.upper()}... ({info.progress}/{info.total})"
+ self.currentModLabel.setText(text)
+ self.hashProgress.setValue(0)
+ self.modProgress.setValue(0)
+ self.extrasProgress.setValue(0)
+
+ def on_movies_progress(self, info: ProgressInfo) -> None:
+ self.extrasProgress.setMaximum(info.total)
+ self.extrasProgress.setValue(info.progress)
+ self.append_log(f"Checking for movies and sounds: {info.description}")
+
+ def on_hash_progress(self, info: ProgressInfo) -> None:
+ self.hashProgress.setMaximum(info.total)
+ self.hashProgress.setValue(info.progress)
+ self.append_log(f"Calculating md5: {info.description}")
+
+ def on_game_progress(self, info: ProgressInfo) -> None:
+ self.gameProgress.setMaximum(info.total)
+ self.gameProgress.setValue(info.progress)
+ self.append_log(f"Checking/copying game file: {info.description}")
+
+ def on_mod_progress(self, info: ProgressInfo) -> None:
+ if info.total == 0:
+ self.modProgress.setMaximum(1)
+ self.modProgress.setValue(1)
+ self.append_log("Everything is up to date.")
+ else:
+ self.append_log(f"Updating file: {info.description}")
+ self.modProgress.setMaximum(info.total)
+ self.modProgress.setValue(info.progress)
+
+ def on_download_progress(self, dler: FileDownload) -> None:
+ if dler.bytes_total == 0:
+ return
+
+ total = dler.bytes_total
+ ready = dler.bytes_progress
+
+ total_mb = round(total / (1024 ** 2), 2)
+ ready_mb = round(ready / (1024 ** 2), 2)
+
+ def construct_bar(blockchar: str = "=", fillchar: str = " ") -> str:
+ num_blocks = round(20 * ready / total)
+ empty_blocks = 20 - num_blocks
+ return f"[{blockchar * num_blocks}{fillchar * empty_blocks}]"
+
+ bar = construct_bar()
+ percent_text = f"{100 * ready / total:.1f}%"
+ text = f"{bar} {percent_text} ({ready_mb} MB / {total_mb} MB)"
+ self.replace_last_log_line(text)
+
+ def on_download_finished(self, dler: FileDownload) -> None:
+ self.append_log("Finished downloading.")
+
+ def on_download_started(self, dler: FileDownload) -> None:
+ self.append_log(f"Downloading file from {dler.addr}\n")
+
+
+class Updater(QObject):
+ """
+ This is the class that does the actual installation work.
+ """
+
+ finished = pyqtSignal()
+
+ def __init__(
+ self,
+ featured_mod: str,
+ version: int | None = None,
+ modversions: dict | None = None,
+ silent: bool = False,
+ *args,
+ **kwargs,
+ ):
+ """
+ Constructor
+ """
+ super().__init__(*args, **kwargs)
+
+ self.progress = UpdaterProgressDialog(None, silent)
+ self.progress.aborted.connect(self.abort)
+
+ self.worker_thread = QThread()
+ self.worker = UpdaterWorker(featured_mod, version, modversions, silent)
+ self.worker.moveToThread(self.worker_thread)
+
+ self.worker.done.connect(self.on_update_done)
+ self.worker.current_mod.connect(self.progress.on_processed_mod_changed)
+ self.worker.hash_progress.connect(self.progress.on_hash_progress)
+ self.worker.extras_progress.connect(self.progress.on_movies_progress)
+ self.worker.game_progress.connect(self.progress.on_game_progress)
+ self.worker.mod_progress.connect(self.progress.on_mod_progress)
+ self.worker.download_progress.connect(self.progress.on_download_progress)
+ self.worker.download_finished.connect(self.progress.on_download_finished)
+ self.worker.download_started.connect(self.progress.on_download_started)
+ self.worker_thread.started.connect(self.worker.do_update)
+ self.result = UpdaterResult.NONE
+
+ def run(self) -> UpdaterResult:
+ clear_log()
+ log(f"Update started at {timestamp()}", logger)
+ log(f"Using appdata: {util.APPDATA_DIR}", logger)
+
+ self.progress.show()
+ self.worker_thread.start()
+
+ loop = QEventLoop()
+ self.worker_thread.finished.connect(loop.quit)
+ loop.exec()
+
+ self.progress.accept()
+ log(f"Update finished at {timestamp()}", logger)
+ return self.result
+
+ def on_update_done(self, result: UpdaterResult) -> None:
+ self.result = result
+ self.handle_result_if_needed(result)
+ self.stop_thread()
+
+ def handle_result_if_needed(self, result: UpdaterResult) -> None:
+ # Integrated handlers for the various things that could go wrong
+ if result == UpdaterResult.CANCEL:
+ pass # The user knows damn well what happened here.
+ elif result == UpdaterResult.FAILURE:
+ failure_dialog()
+
+ def abort(self) -> None:
+ self.worker.abort()
+
+ def stop_thread(self) -> None:
+ self.worker_thread.quit()
+ self.worker_thread.wait(1000)
diff --git a/src/fa/game_updater/worker.py b/src/fa/game_updater/worker.py
new file mode 100644
index 000000000..25b93eb7d
--- /dev/null
+++ b/src/fa/game_updater/worker.py
@@ -0,0 +1,298 @@
+from __future__ import annotations
+
+import logging
+import os
+import shutil
+import stat
+from functools import wraps
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkAccessManager
+
+import util
+from api.featured_mod_api import FeaturedModApiConnector
+from api.featured_mod_api import FeaturedModFilesApiConnector
+from api.models.FeaturedMod import FeaturedMod
+from api.models.FeaturedModFile import FeaturedModFile
+from config import Settings
+from downloadManager import FileDownload
+from fa.game_updater.misc import ProgressInfo
+from fa.game_updater.misc import UpdaterCancellation
+from fa.game_updater.misc import UpdaterFailure
+from fa.game_updater.misc import UpdaterResult
+from fa.game_updater.misc import log
+from fa.game_updater.patcher import FAPatcher
+from fa.utils import unpack_movies_and_sounds
+
+logger = logging.getLogger(__name__)
+
+
+class UpdaterWorker(QObject):
+ done = pyqtSignal(UpdaterResult)
+
+ current_mod = pyqtSignal(ProgressInfo)
+ hash_progress = pyqtSignal(ProgressInfo)
+ extras_progress = pyqtSignal(ProgressInfo)
+ game_progress = pyqtSignal(ProgressInfo)
+ mod_progress = pyqtSignal(ProgressInfo)
+
+ download_started = pyqtSignal(FileDownload)
+ download_progress = pyqtSignal(FileDownload)
+ download_finished = pyqtSignal(FileDownload)
+
+ def __init__(
+ self,
+ featured_mod: str,
+ version: int | None,
+ modversions: dict | None,
+ silent: bool = False,
+ ) -> None:
+ super().__init__()
+ self.featured_mod = featured_mod
+ self.version = version
+ self.modversions = modversions
+ self.silent = silent
+
+ self.nam = QNetworkAccessManager(self)
+ self.result = UpdaterResult.NONE
+
+ keep_cache = not Settings.get("cache/do_not_keep", type=bool, default=True)
+ in_session_cache = Settings.get("cache/in_session", type=bool, default=False)
+ self.cache_enabled = keep_cache or in_session_cache
+
+ self.dlers: list[FileDownload] = []
+ self._interruption_requested = False
+ self.fa_patcher = FAPatcher()
+
+ def _check_interruption(fn):
+ @wraps(fn)
+ def wrapper(self, *args, **kwargs):
+ if self._interruption_requested:
+ raise UpdaterCancellation("User aborted the update")
+ return fn(self, *args, **kwargs)
+ return wrapper
+
+ def get_files_to_update(self, mod_id: str, version: str) -> list[dict]:
+ return FeaturedModFilesApiConnector(mod_id, version).get_files()
+
+ def get_featured_mod_by_name(self, technical_name: str) -> FeaturedMod:
+ return FeaturedModApiConnector().request_and_get_fmod_by_name(technical_name)
+
+ @staticmethod
+ def _filter_files_to_update(
+ files: list[FeaturedModFile],
+ precalculated_md5s: dict[str, str],
+ ) -> list[FeaturedModFile]:
+ return [file for file in files if precalculated_md5s[file.md5] != file.md5]
+
+ @_check_interruption
+ def _calculate_md5s(self, files: list[FeaturedModFile]) -> dict[str, str]:
+ total = len(files)
+ result = {}
+ for index, file in enumerate(files, start=1):
+ filepath = os.path.join(util.APPDATA_DIR, file.group, file.name)
+ result[file.md5] = util.md5(filepath)
+ self.hash_progress.emit(ProgressInfo(index, total, file.name))
+ return result
+
+ def fetch_fmod_file(self, file: FeaturedModFile) -> None:
+ target_path = os.path.join(util.APPDATA_DIR, file.group, file.name)
+ url = file.cacheable_url
+ self._download(target_path, url, {file.hmac_parameter: file.hmac_token})
+
+ def move_from_cache(self, file: FeaturedModFile) -> None:
+ src_dir = os.path.join(util.APPDATA_DIR, file.group)
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
+ if os.path.exists(os.path.join(cache_dir, file.md5)):
+ shutil.move(
+ os.path.join(cache_dir, file.md5),
+ os.path.join(src_dir, file.name),
+ )
+
+ def move_to_cache(
+ self,
+ file: FeaturedModFile,
+ precalculated_md5s: dict[str, str] | None = None,
+ ) -> None:
+ precalculated_md5s = precalculated_md5s or {}
+ src_dir = os.path.join(util.APPDATA_DIR, file.group)
+ cache_dir = os.path.join(util.GAME_CACHE_DIR, file.group)
+ if os.path.exists(os.path.join(src_dir, file.name)):
+ md5 = precalculated_md5s.get(file.md5, util.md5(os.path.join(src_dir, file.name)))
+ shutil.move(
+ os.path.join(src_dir, file.name),
+ os.path.join(cache_dir, md5),
+ )
+ util.setAccessTime(os.path.join(cache_dir, md5))
+
+ @staticmethod
+ def _is_cached(file: FeaturedModFile) -> bool:
+ cached_file = os.path.join(util.GAME_CACHE_DIR, file.group, file.md5)
+ return os.path.isfile(cached_file)
+
+ def ensure_subdirs(self, files: list[FeaturedModFile]) -> None:
+ for file in files:
+ cache = os.path.join(util.GAME_CACHE_DIR, file.group)
+ os.makedirs(cache, exist_ok=True)
+ os.makedirs(util.GAMEDATA_DIR, exist_ok=True)
+
+ @_check_interruption
+ def update_file(
+ self,
+ file: FeaturedModFile,
+ precalculated_md5s: dict[str, str] | None = None,
+ ) -> None:
+ self.move_to_cache(file, precalculated_md5s)
+ if self._is_cached(file):
+ self.move_from_cache(file)
+ else:
+ self.fetch_fmod_file(file)
+
+ @_check_interruption
+ def update_files(self, files: list[FeaturedModFile]) -> None:
+ """
+ Updates the files in the destination
+ subdirectory of the Forged Alliance path.
+ """
+ self.ensure_subdirs(files)
+ md5s = self._calculate_md5s(files)
+
+ to_update = self._filter_files_to_update(files, md5s)
+ total = len(to_update)
+
+ if total == 0:
+ self.mod_progress.emit(ProgressInfo(0, 0, ""))
+
+ for index, file in enumerate(to_update, start=1):
+ self.update_file(file, md5s)
+ self.mod_progress.emit(ProgressInfo(index, total, file.name))
+
+ self.unpack_movies_and_sounds(files)
+ self.patch_fa_exe_if_needed(files)
+
+ @_check_interruption
+ def unpack_movies_and_sounds(self, files: list[FeaturedModFile]) -> None:
+ logger.info("Checking files for movies and sounds")
+
+ total = len(files)
+ for index, file in enumerate(files, start=1):
+ unpack_movies_and_sounds(file)
+ self.extras_progress.emit(ProgressInfo(index, total, file.name))
+
+ def prepare_bin_FAF(self) -> None:
+ """
+ Creates all necessary files in the binFAF folder, which contains
+ a modified copy of all that is in the standard bin folder of
+ Forged Alliance
+ """
+ # now we check if we've got a binFAF folder
+ FABindir = os.path.join(Settings.get("ForgedAlliance/app/path"), "bin")
+ FAFdir = util.BIN_DIR
+
+ # Try to copy without overwriting, but fill in any missing files,
+ # otherwise it might miss some files to update
+ root_src_dir = FABindir
+ root_dst_dir = FAFdir
+
+ for src_dir, _, files in os.walk(root_src_dir):
+ dst_dir = src_dir.replace(root_src_dir, root_dst_dir)
+ os.makedirs(dst_dir, exist_ok=True)
+ total_files = len(files)
+ for index, file in enumerate(files, start=1):
+ src_file = os.path.join(src_dir, file)
+ dst_file = os.path.join(dst_dir, file)
+ if not os.path.exists(dst_file):
+ shutil.copy(src_file, dst_dir)
+ st = os.stat(dst_file)
+ # make all files we were considering writable, because we may
+ # need to patch them
+ os.chmod(dst_file, st.st_mode | stat.S_IWRITE)
+ self.game_progress.emit(ProgressInfo(index, total_files, file))
+
+ def _download(self, target_path: str, url: str, params: dict) -> None:
+ logger.info(f"Updater: Downloading {url}")
+ dler = FileDownload(target_path, self.nam, url, params)
+ dler.blocksize = None
+ dler.progress.connect(self.download_progress.emit)
+ dler.start.connect(self.download_started.emit)
+ dler.finished.connect(self.download_finished.emit)
+ self.dlers.append(dler)
+ dler.run()
+ dler.waitForCompletion()
+ if dler.canceled:
+ raise UpdaterCancellation(dler.error_string())
+ elif dler.failed():
+ raise UpdaterFailure(f"Update failed: {dler.error_sring()}")
+
+ def patch_fa_executable(self, exe_info: FeaturedModFile) -> None:
+ exe_path = os.path.join(util.BIN_DIR, exe_info.name)
+ version = int(self._resolve_base_version(exe_info))
+
+ if version == self.fa_patcher.read_version(exe_path):
+ return
+
+ for attempt in range(10): # after download antimalware can interfere in our update process
+ if self.fa_patcher.patch(exe_path, version):
+ return
+ logger.warning(f"Could not open fa exe for patching. Attempt #{attempt + 1}")
+ self.thread().msleep(500)
+ else:
+ raise UpdaterFailure("Could not update FA exe to the correct version")
+
+ def patch_fa_exe_if_needed(self, files: list[FeaturedModFile]) -> None:
+ for file in files:
+ if file.name == Settings.get("game/exe-name"):
+ self.patch_fa_executable(file)
+ return
+
+ @_check_interruption
+ def update_featured_mod(self, modname: str, modversion: str) -> list[FeaturedModFile]:
+ fmod = self.get_featured_mod_by_name(modname)
+ files = self.get_files_to_update(fmod.xd, modversion)
+ self.update_files(files)
+ return files
+
+ def _resolve_modversion(self) -> str:
+ if self.modversions:
+ return str(max(self.modversions.values()))
+ return "latest"
+
+ def _resolve_base_version(self, exe_info: FeaturedModFile | None = None) -> str:
+ if self.version:
+ return str(self.version)
+ if exe_info:
+ return str(exe_info.version)
+ return "latest"
+
+ def do_update(self) -> None:
+ """ The core function that does most of the actual update work."""
+ try:
+ # Prepare FAF directory & all necessary files
+ self.prepare_bin_FAF()
+ # Update the mod if it's requested
+ if self.featured_mod in ("faf", "fafbeta", "fafdevelop", "ladder1v1"):
+ self.current_mod.emit(ProgressInfo(1, 1, self.featured_mod))
+ self.update_featured_mod(self.featured_mod, self._resolve_base_version())
+ else:
+ # update faf first
+ self.current_mod.emit(ProgressInfo(1, 2, "FAF"))
+ self.update_featured_mod("faf", self._resolve_base_version())
+ # update featured mod then
+ self.current_mod.emit(ProgressInfo(2, 2, self.featured_mod))
+ self.update_featured_mod(self.featured_mod, self._resolve_modversion())
+ except UpdaterCancellation as e:
+ log(f"CANCELLED: {e}", logger)
+ self.result = UpdaterResult.CANCEL
+ except Exception as e:
+ log(f"EXCEPTION: {e}", logger)
+ logger.exception(f"EXCEPTION: {e}")
+ self.result = UpdaterResult.FAILURE
+ else:
+ self.result = UpdaterResult.SUCCESS
+ self.done.emit(self.result)
+
+ def abort(self) -> None:
+ for dler in self.dlers:
+ dler.cancel()
+ self._interruption_requested = True
diff --git a/src/fa/maps.py b/src/fa/maps.py
index c68261fa9..c973e9b87 100644
--- a/src/fa/maps.py
+++ b/src/fa/maps.py
@@ -1,33 +1,29 @@
# system imports
import logging
-import string
-import sys
-from urllib.error import HTTPError
-from PyQt5 import QtCore, QtGui
-import io
-import util
import os
+import shutil
import stat
+import string
import struct
-import shutil
-import urllib.request, urllib.error, urllib.parse
-import zipfile
+import sys
import tempfile
-import re
+import zipfile
+from typing import Callable
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+
# module imports
-import fa
+import util
# local imports
from config import Settings
-from vault.dialogs import downloadVaultAssetNoMsg
+from mapGenerator.mapgenUtils import isGeneratedMap
+from model.game import OFFICIAL_MAPS as maps
+from vaults.dialogs import downloadVaultAssetNoMsg
logger = logging.getLogger(__name__)
route = Settings.get('content/host')
-VAULT_PREVIEW_ROOT = "{}/faf/vault/map_previews/small/".format(route)
-VAULT_DOWNLOAD_ROOT = "{}/faf/vault/".format(route)
-VAULT_COUNTER_ROOT = "{}/faf/vault/map_vault/inc_downloads.php".format(route)
-
-from model.game import OFFICIAL_MAPS as maps
__exist_maps = None
@@ -42,14 +38,15 @@ def isBase(mapname):
def getUserMaps():
maps = []
if os.path.isdir(getUserMapsFolder()):
- maps = os.listdir(getUserMapsFolder())
+ for _dir in os.listdir(getUserMapsFolder()):
+ maps.append(_dir.lower())
return maps
def getDisplayName(filename):
"""
- Tries to return a pretty name for the map (for official maps, it looks up the name)
- For nonofficial maps, it tries to clean up the filename
+ Tries to return a pretty name for the map (for official maps, it looks up
+ the name) For nonofficial maps, it tries to clean up the filename
"""
if str(filename) in maps:
return maps[filename][0]
@@ -61,19 +58,19 @@ def getDisplayName(filename):
return pretty
-def name2link(name):
+def name2link(name: str) -> str:
"""
Returns a quoted link for use with the VAULT_xxxx Urls
TODO: This could be cleaned up a little later.
"""
- return urllib.parse.quote("maps/" + name + ".zip")
+ return Settings.get("vault/map_download_url").format(name=name)
def link2name(link):
"""
Takes a link and tries to turn it into a local mapname
"""
- name = link.rsplit("/")[1].rsplit(".zip")[0]
+ name = link.rsplit("/", 1)[1].rsplit(".zip")[0]
logger.info("Converted link '" + link + "' to name '" + name + "'")
return name
@@ -107,7 +104,7 @@ def isMapFolderValid(folder):
baseName + ".scmap",
baseName + "_save.lua",
baseName + "_scenario.lua",
- baseName + "_script.lua"
+ baseName + "_script.lua",
}
files_present = set(os.listdir(folder))
@@ -166,7 +163,8 @@ def getBaseMapsFolder():
if gamepath:
return os.path.join(gamepath, "maps")
else:
- return "maps" # This most likely isn't the valid maps folder, but it's the best guess.
+ # This most likely isn't the valid maps folder, but it's the best guess
+ return "maps"
def getUserMapsFolder():
@@ -178,10 +176,11 @@ def getUserMapsFolder():
"My Games",
"Gas Powered Games",
"Supreme Commander Forged Alliance",
- "Maps")
+ "Maps",
+ )
-def genPrevFromDDS(sourcename, destname, small=False):
+def genPrevFromDDS(sourcename: str, destname: str, small: bool = False) -> None:
"""
this opens supcom's dds file (format: bgra8888) and saves to png
"""
@@ -194,29 +193,35 @@ def genPrevFromDDS(sourcename, destname, small=False):
img += buf[:3] + buf[4:7] + buf[8:11] + buf[12:15]
file.close()
- size = int((len(img)/3) ** (1.0/2))
+ size = int((len(img) / 3) ** (1.0 / 2))
if small:
imageFile = QtGui.QImage(
img,
size,
size,
- QtGui.QImage.Format_RGB888).rgbSwapped().scaled(
- 100,
- 100,
- transformMode=QtCore.Qt.SmoothTransformation)
+ QtGui.QImage.Format.Format_RGB888,
+ ).rgbSwapped().scaled(
+ 100,
+ 100,
+ transformMode=QtCore.Qt.TransformationMode.SmoothTransformation,
+ )
else:
imageFile = QtGui.QImage(
img,
size,
size,
- QtGui.QImage.Format_RGB888).rgbSwapped()
+ QtGui.QImage.Format.Format_RGB888,
+ ).rgbSwapped()
imageFile.save(destname)
except IOError:
logger.debug('IOError exception in genPrevFromDDS', exc_info=True)
raise
-def __exportPreviewFromMap(mapname, positions=None):
+def export_preview_from_map(
+ mapname: str | None,
+ positions: dict | None = None,
+) -> None | dict[str, None | str | list[str]]:
"""
This method auto-upgrades the maps to have small and large preview images
"""
@@ -238,7 +243,11 @@ def __exportPreviewFromMap(mapname, positions=None):
return previews
mapname = os.path.basename(mapdir).lower()
- mapfilename = os.path.join(mapdir, mapname.split(".")[0]+".scmap")
+ mapname_no_version, *_ = mapname.partition(".")
+ if isGeneratedMap(mapname):
+ mapfilename = os.path.join(mapdir, mapname + ".scmap")
+ else:
+ mapfilename = os.path.join(mapdir, f"{mapname_no_version}.scmap")
mode = os.stat(mapdir)[0]
if not (mode and stat.S_IWRITE):
@@ -248,10 +257,21 @@ def __exportPreviewFromMap(mapname, positions=None):
if not os.path.isdir(mapdir):
os.mkdir(mapdir)
- previewsmallname = os.path.join(mapdir, mapname + ".small.png")
- previewlargename = os.path.join(mapdir, mapname + ".large.png")
- previewddsname = os.path.join(mapdir, mapname + ".dds")
- cachepngname = os.path.join(util.MAP_PREVIEW_DIR, mapname + ".png")
+ def plausible_mapname_preview_name(suffix: str) -> str:
+ casefold_names = (
+ f"{mapname}{suffix}".casefold(),
+ f"{mapname_no_version}{suffix}".casefold(),
+ )
+ for entry in os.listdir(mapdir):
+ plausible_preview = os.path.join(mapdir, entry)
+ if os.path.isfile(plausible_preview) and entry.casefold() in casefold_names:
+ return plausible_preview
+ return suffix
+
+ previewsmallname = plausible_mapname_preview_name(".small.png")
+ previewlargename = plausible_mapname_preview_name(".large.png")
+ previewddsname = plausible_mapname_preview_name(".dds")
+ cachepngname = os.path.join(util.MAP_PREVIEW_SMALL_DIR, mapname + ".png")
logger.debug("Generating preview from user maps for: " + mapname)
logger.debug("Using directory: " + mapdir)
@@ -259,9 +279,9 @@ def __exportPreviewFromMap(mapname, positions=None):
# Unknown / Unavailable mapname?
if not os.path.isfile(mapfilename):
logger.warning(
- "Unable to find the .scmap for: {}, was looking here: {}".format(
- mapname, mapfilename
- ))
+ "Unable to find the .scmap for: {}, was looking here: "
+ "{}".format(mapname, mapfilename),
+ )
return previews
# Small preview already exists?
@@ -303,12 +323,15 @@ def __exportPreviewFromMap(mapname, positions=None):
unk_32 = struct.unpack('i', mapfile.read(4))[0]
unk_16 = struct.unpack('h', mapfile.read(2))[0]
"""
- mapfile.seek(30) # Shortcut. Maybe want to clean out some of the magic numbers some day
+ # Shortcut. Maybe want to clean out some of the magic numbers some day
+ mapfile.seek(30)
+
size = struct.unpack('i', mapfile.read(4))[0]
data = mapfile.read(size)
# version_minor = struct.unpack('i', mapfile.read(4))[0]
mapfile.close()
- # logger.debug("SCMAP version %i.%i" % (version_major, version_minor))
+ # logger.debug("SCMAP version {}.{}".format(version_major,
+ # version_minor))
try:
with open(previewddsname, "wb") as previewfile:
@@ -318,33 +341,41 @@ def __exportPreviewFromMap(mapname, positions=None):
if os.path.isfile(previewddsname):
previews["tozip"].append(previewddsname)
else:
- logger.debug("Failed to make DDS for: " + mapname)
+ logger.debug("Failed to make DDS for: {}".format(mapname))
return previews
except IOError:
pass
if not smallExists:
- logger.debug("Making small preview from DDS for: " + mapname)
+ logger.debug("Making small preview from DDS for: {}".format(mapname))
try:
genPrevFromDDS(previewddsname, previewsmallname, small=True)
previews["tozip"].append(previewsmallname)
shutil.copyfile(previewsmallname, cachepngname)
previews["cache"] = cachepngname
except IOError:
- logger.debug("Failed to make small preview for: " + mapname)
+ logger.debug(
+ "Failed to make small preview for: {}".format(mapname),
+ )
return previews
if not largeExists:
- logger.debug("Making large preview from DDS for: " + mapname)
+ logger.debug("Making large preview from DDS for: {}".format(mapname))
if not isinstance(positions, dict):
- logger.debug("Icon positions were not passed or they were wrong for: " + mapname)
+ logger.debug(
+ "Icon positions were not passed or they were wrong "
+ "for: {}".format(mapname),
+ )
return previews
try:
genPrevFromDDS(previewddsname, previewlargename, small=False)
mapimage = util.THEME.pixmap(previewlargename)
- armyicon = util.THEME.pixmap("vault/map_icons/army.png").scaled(8, 9, 1, 1)
- massicon = util.THEME.pixmap("vault/map_icons/mass.png").scaled(8, 8, 1, 1)
- hydroicon = util.THEME.pixmap("vault/map_icons/hydro.png").scaled(10, 10, 1, 1)
+ armypixmap = util.THEME.pixmap("vaults/map_icons/army.png")
+ masspixmap = util.THEME.pixmap("vaults/map_icons/mass.png")
+ hydropixmap = util.THEME.pixmap("vaults/map_icons/hydro.png")
+ massicon = masspixmap.scaled(8, 8, 1, 1)
+ armyicon = armypixmap.scaled(8, 9, 1, 1)
+ hydroicon = hydropixmap.scaled(10, 10, 1, 1)
painter = QtGui.QPainter()
@@ -355,22 +386,25 @@ def __exportPreviewFromMap(mapname, positions=None):
if "hydro" in positions:
for pos in positions["hydro"]:
target = QtCore.QRectF(
- positions["hydro"][pos][0]-5,
- positions["hydro"][pos][1]-5, 10, 10)
+ positions["hydro"][pos][0] - 5,
+ positions["hydro"][pos][1] - 5, 10, 10,
+ )
source = QtCore.QRectF(0.0, 0.0, 10.0, 10.0)
painter.drawPixmap(target, hydroicon, source)
if "mass" in positions:
for pos in positions["mass"]:
target = QtCore.QRectF(
- positions["mass"][pos][0]-4,
- positions["mass"][pos][1]-4, 8, 8)
+ positions["mass"][pos][0] - 4,
+ positions["mass"][pos][1] - 4, 8, 8,
+ )
source = QtCore.QRectF(0.0, 0.0, 8.0, 8.0)
painter.drawPixmap(target, massicon, source)
if "army" in positions:
for pos in positions["army"]:
target = QtCore.QRectF(
- positions["army"][pos][0]-4,
- positions["army"][pos][1]-4, 8, 9)
+ positions["army"][pos][0] - 4,
+ positions["army"][pos][1] - 4, 8, 9,
+ )
source = QtCore.QRectF(0.0, 0.0, 8.0, 9.0)
painter.drawPixmap(target, armyicon, source)
painter.end()
@@ -382,63 +416,69 @@ def __exportPreviewFromMap(mapname, positions=None):
return previews
-iconExtensions = ["png"] # "jpg" removed to have fewer of those costly 404 misses.
+
+# "jpg" removed to have fewer of those costly 404 misses.
+iconExtensions = ["png"]
def preview(mapname, pixmap=False):
try:
# Try to load directly from cache
for extension in iconExtensions:
- img = os.path.join(util.MAP_PREVIEW_DIR, mapname + "." + extension)
+ img = os.path.join(
+ util.MAP_PREVIEW_SMALL_DIR,
+ mapname + "." + extension,
+ )
if os.path.isfile(img):
logger.log(5, "Using cached preview image for: " + mapname)
return util.THEME.icon(img, False, pixmap)
# Try to find in local map folder
- img = __exportPreviewFromMap(mapname)
-
- if img and 'cache' in img and img['cache'] and os.path.isfile(img['cache']):
+ img = export_preview_from_map(mapname)
+
+ if (
+ img
+ and 'cache' in img
+ and img['cache']
+ and os.path.isfile(img['cache'])
+ ):
logger.debug("Using fresh preview image for: " + mapname)
return util.THEME.icon(img['cache'], False, pixmap)
+ if isGeneratedMap(mapname):
+ return util.THEME.icon("games/generated_map.png")
+
return None
- except:
- logger.error("Error raised in maps.preview(...) for " + mapname)
- logger.error("Map Preview Exception", exc_info=sys.exc_info())
+ except BaseException:
+ logger.debug("Error raised in maps.preview(...) for " + mapname)
+ logger.debug("Map Preview Exception", exc_info=sys.exc_info())
-def downloadMap(name, silent=False):
+def downloadMap(name: str, silent: bool = False) -> bool:
"""
Download a map from the vault with the given name
"""
link = name2link(name)
ret, msg = _doDownloadMap(name, link, silent)
- if not ret:
+ if not ret and msg is None:
name = name.replace(" ", "_")
link = name2link(name)
ret, msg = _doDownloadMap(name, link, silent)
- if not ret:
- msg()
- return ret
-
- # Count the map downloads
- try:
- url = VAULT_COUNTER_ROOT + "?map=" + urllib.parse.quote(link)
- req = urllib.request.Request(url, headers={'User-Agent': "FAF Client"})
- urllib.request.urlopen(req)
- logger.debug("Successfully sent download counter request for: " + url)
- except:
- logger.warning("Request to map download counter failed for: " + url)
- logger.error("Download Count Exception", exc_info=sys.exc_info())
-
- return True
+ if not ret and msg is not None:
+ msg()
+ return ret
-def _doDownloadMap(name, link, silent):
- url = VAULT_DOWNLOAD_ROOT + link
- logger.debug("Getting map from: " + url)
- return downloadVaultAssetNoMsg(url, getUserMapsFolder(), lambda m, d: True,
- name, "map", silent)
+def _doDownloadMap(name: str, link: str, silent: bool) -> tuple[bool, Callable[[], None] | None]:
+ logger.debug(f"Getting map from: {link}")
+ return downloadVaultAssetNoMsg(
+ url=link,
+ target_dir=getUserMapsFolder(),
+ exist_handler=lambda m, d: True,
+ name=name,
+ category="map",
+ silent=silent,
+ )
def processMapFolderForUpload(mapDir, positions):
@@ -446,7 +486,7 @@ def processMapFolderForUpload(mapDir, positions):
Zipping the file and creating thumbnails
"""
# creating thumbnail
- files = __exportPreviewFromMap(mapDir, positions)["tozip"]
+ files = export_preview_from_map(mapDir, positions)["tozip"]
# abort zipping if there is insufficient previews
if len(files) != 3:
logger.debug("Insufficient previews for making an archive.")
@@ -467,7 +507,10 @@ def processMapFolderForUpload(mapDir, positions):
zipped = zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED)
for filename in files:
- zipped.write(filename, os.path.join(os.path.basename(mapDir), os.path.basename(filename)))
+ zipped.write(
+ filename,
+ os.path.join(os.path.basename(mapDir), os.path.basename(filename)),
+ )
temp.flush()
diff --git a/src/fa/mods.py b/src/fa/mods.py
index b77e87f11..773ba4d1d 100644
--- a/src/fa/mods.py
+++ b/src/fa/mods.py
@@ -1,59 +1,69 @@
-from PyQt5 import QtWidgets
-import fa
-import modvault
import logging
+
+from PyQt6 import QtWidgets
+
import config
+from api.sim_mod_updater import SimModFiles
+from vaults.modvault.utils import downloadMod
+from vaults.modvault.utils import getInstalledMods
+from vaults.modvault.utils import setActiveMods
logger = logging.getLogger(__name__)
-def checkMods(mods): # mods is a dictionary of uid-name pairs
+def checkMods(mods: dict[str, str]) -> bool: # mods is a dictionary of uid-name pairs
"""
Assures that the specified mods are available in FA, or returns False.
Also sets the correct active mods in the ingame mod manager.
"""
- logger.info("Updating FA for mods %s" % ", ".join(mods))
- to_download = []
- inst = modvault.getInstalledMods()
- uids = [mod.uid for mod in inst]
- for uid in mods:
- if uid not in uids:
- to_download.append(uid)
+ logger.info("Updating FA for mods {}".format(", ".join(mods)))
+
+ inst = set(mod.uid for mod in getInstalledMods())
+ to_download = {uid: name for uid, name in mods.items() if uid not in inst}
auto = config.Settings.get('mods/autodownload', default=False, type=bool)
if not auto:
- mod_names = ", ".join([mods[uid] for uid in mods])
+ mod_names = ", ".join(mods.values())
msgbox = QtWidgets.QMessageBox()
msgbox.setWindowTitle("Download Mod")
- msgbox.setText("Seems that you don't have mods used in this game. Do you want to download them? " + mod_names + "")
- msgbox.setInformativeText("If you respond 'Yes to All' mods will be downloaded automatically in the future")
- msgbox.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.YesToAll | QtWidgets.QMessageBox.No)
- result = msgbox.exec_()
- if result == QtWidgets.QMessageBox.No:
+ msgbox.setText(
+ "Seems that you don't have mods used in this game. Do "
+ "you want to download them? {}".format(mod_names),
+ )
+ msgbox.setInformativeText(
+ "If you respond 'Yes to All' mods will be "
+ "downloaded automatically in the future",
+ )
+ msgbox.setStandardButtons(
+ QtWidgets.QMessageBox.StandardButton.Yes
+ | QtWidgets.QMessageBox.StandardButton.YesToAll
+ | QtWidgets.QMessageBox.StandardButton.No,
+ )
+ result = msgbox.exec()
+ if result == QtWidgets.QMessageBox.StandardButton.No:
return False
- elif result == QtWidgets.QMessageBox.YesToAll:
+ elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
config.Settings.set('mods/autodownload', True)
- for uid in to_download:
- # Spawn an update for the required mod
- updater = fa.updater.Updater(uid, sim=True)
- result = updater.run()
- if result != fa.updater.Updater.RESULT_SUCCESS:
- logger.warning("Failure getting {}: {}".format(uid, mods[uid]))
+ api_accessor = SimModFiles()
+ for uid, name in to_download.items():
+ url = api_accessor.request_and_get_sim_mod_url_by_id(uid)
+ if not downloadMod(url, name):
+ logger.warning(f"Failure getting {name!r} with uid {uid!r}")
return False
actual_mods = []
- inst = modvault.getInstalledMods()
- uids = {}
- for mod in inst:
- uids[mod.uid] = mod
- for uid in mods:
+ uids = {mod.uid: mod for mod in getInstalledMods()}
+ for uid, name in mods.items():
if uid not in uids:
- QtWidgets.QMessageBox.warning(None, "Mod not Found",
- "%s was apparently not installed correctly. Please check this." % mods[uid])
+ QtWidgets.QMessageBox.warning(
+ None,
+ "Mod not Found",
+ f"{name} was apparently not installed correctly. Please check this.",
+ )
return
actual_mods.append(uids[uid])
- if not modvault.setActiveMods(actual_mods):
+ if not setActiveMods(actual_mods):
logger.warning("Couldn't set the active mods in the game.prefs file")
return False
diff --git a/src/fa/path.py b/src/fa/path.py
index 9d533d47d..8c27dcc79 100644
--- a/src/fa/path.py
+++ b/src/fa/path.py
@@ -1,6 +1,7 @@
+import logging
import os
import sys
-import logging
+
import config
import util
@@ -10,15 +11,22 @@
def steamPath():
try:
import winreg
- steam_key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Software\\Valve\\Steam", 0, (winreg.KEY_WOW64_64KEY + winreg.KEY_ALL_ACCESS))
- return winreg.QueryValueEx(steam_key, "SteamPath")[0].replace("/", "\\")
- except Exception as e:
+ steam_key = winreg.OpenKey(
+ winreg.HKEY_CURRENT_USER,
+ "Software\\Valve\\Steam",
+ 0,
+ (winreg.KEY_WOW64_64KEY + winreg.KEY_ALL_ACCESS),
+ )
+ query_value = winreg.QueryValueEx(steam_key, "SteamPath")
+ return query_value[0].replace("/", "\\")
+ except BaseException:
return None
def writeFAPathLua():
"""
- Writes a small lua file to disk that helps the new SupComDataPath.lua find the actual install of the game
+ Writes a small lua file to disk that helps the new
+ SupComDataPath.lua find the actual install of the game
"""
name = os.path.join(util.APPDATA_DIR, "fa_path.lua")
gamepath_fa = config.Settings.get("ForgedAlliance/app/path", type=str)
@@ -28,33 +36,48 @@ def writeFAPathLua():
with open(name, "w+", encoding='utf-8') as lua:
lua.write(code)
lua.flush()
- os.fsync(lua.fileno()) # Ensuring the file is absolutely, positively on disk.
+ # Ensuring the file is absolutely, positively on disk.
+ os.fsync(lua.fileno())
def typicalForgedAlliancePaths():
"""
- Returns a list of the most probable paths where Supreme Commander: Forged Alliance might be installed
+ Returns a list of the most probable paths where Supreme Commander:
+ Forged Alliance might be installed
"""
pathlist = [
config.Settings.get("ForgedAlliance/app/path", "", type=str),
# Retail path
- os.path.expandvars("%ProgramFiles%\\THQ\\Gas Powered Games\\Supreme Commander - Forged Alliance"),
+ os.path.expandvars(
+ "%ProgramFiles%\\THQ\\Gas Powered Games\\"
+ "Supreme Commander - Forged Alliance",
+ ),
# Direct2Drive Paths
# ... allegedly identical to impulse paths - need to confirm this
# Impulse/GameStop Paths - might need confirmation yet
- os.path.expandvars("%ProgramFiles%\\Supreme Commander - Forged Alliance"),
+ os.path.expandvars(
+ "%ProgramFiles%\\Supreme Commander - Forged Alliance",
+ ),
# Guessed Steam path
- os.path.expandvars("%ProgramFiles%\\Steam\\steamapps\\common\\supreme commander forged alliance")
+ os.path.expandvars(
+ "%ProgramFiles%\\Steam\\steamapps\\common\\"
+ "supreme commander forged alliance",
+ ),
]
# Registry Steam path
steam_path = steamPath()
if steam_path:
- pathlist.append(os.path.join(steam_path, "SteamApps", "common", "Supreme Commander Forged Alliance"))
+ pathlist.append(
+ os.path.join(
+ steam_path, "SteamApps", "common",
+ "Supreme Commander Forged Alliance",
+ ),
+ )
return list(filter(validatePath, pathlist))
@@ -76,14 +99,15 @@ def validatePath(path):
# Reject or fix paths that end with a slash.
# LATER: this can have all sorts of intelligent logic added
- # Suggested: Check if the files are actually the right ones, if not, tell the user what's wrong with them.
+ # Suggested: Check if the files are actually the right ones, if not,
+ # tell the user what's wrong with them.
if path.endswith("/"):
return False
if path.endswith("\\"):
return False
return True
- except:
+ except BaseException:
_, value, _ = sys.exc_info()
logger.error("Path validation failed: " + str(value))
return False
diff --git a/src/fa/play.py b/src/fa/play.py
index 46a422eb3..1e72aaa8d 100644
--- a/src/fa/play.py
+++ b/src/fa/play.py
@@ -1,20 +1,27 @@
-from .game_process import instance
-
-from config import Settings
import util
+from config import Settings
+
+from .game_process import instance
__author__ = 'Thygrrr'
import logging
+
logger = logging.getLogger(__name__)
-def build_argument_list(game_info, port, arguments=None, log_suffix=None):
+def build_argument_list(
+ game_info,
+ port,
+ replayPort,
+ arguments=None,
+ log_suffix=None,
+):
"""
- Compiles an argument list to run the game with POpen style process invocation methods.
- Extends a potentially pre-existing argument list to allow for injection of special parameters
+ Compiles an argument list to run the game with POpen style process
+ invocation methods. Extends a potentially pre-existing argument list
+ to allow for injection of special parameters
"""
- import client
arguments = arguments or []
if '/init' in arguments:
@@ -22,9 +29,9 @@ def build_argument_list(game_info, port, arguments=None, log_suffix=None):
# Init file
arguments.append('/init')
- arguments.append('init_{}.lua'.format(game_info.get('featured_mod', 'faf')))
-
- arguments.append('/numgames {}'.format(client.instance.me.player.number_of_games))
+ arguments.append(
+ 'init_{}.lua'.format(game_info.get('featured_mod', 'faf')),
+ )
# log file
if Settings.get("game/logs", False, type=bool):
@@ -32,17 +39,26 @@ def build_argument_list(game_info, port, arguments=None, log_suffix=None):
if log_suffix is None:
log_file = util.LOG_FILE_GAME
else:
- log_file = (util.LOG_FILE_GAME_PREFIX +
- util.LOG_FILE_GAME_INFIX +
- "{}".format(log_suffix) + ".log")
- arguments.append('"' + log_file + '"')
+ log_file = (
+ util.LOG_FILE_GAME_PREFIX
+ + util.LOG_FILE_GAME_INFIX
+ + "{}".format(log_suffix)
+ + ".log"
+ )
+ arguments.append('"{}"'.format(log_file))
# Disable defunct bug reporter
arguments.append('/nobugreport')
# live replay
arguments.append('/savereplay')
- arguments.append('"gpgnet://localhost/' + str(game_info['uid']) + "/" + str(game_info['recorder']) + '.SCFAreplay"')
+ arguments.append(
+ '"gpgnet://localhost:{}/{}/{}.SCFAreplay"'.format(
+ replayPort,
+ game_info['uid'],
+ game_info['recorder'],
+ ),
+ )
# gpg server emulation
arguments.append('/gpgnet 127.0.0.1:' + str(port))
@@ -50,10 +66,12 @@ def build_argument_list(game_info, port, arguments=None, log_suffix=None):
return arguments
-def run(game_info, port, arguments=None, log_suffix=None):
+def run(game_info, port, replayPort, arguments=None, log_suffix=None):
"""
Launches Forged Alliance with the given arguments
"""
- logger.info("Play received arguments: %s" % arguments)
- arguments = build_argument_list(game_info, port, arguments, log_suffix)
+ logger.info("Play received arguments: {}".format(arguments))
+ arguments = build_argument_list(
+ game_info, port, replayPort, arguments, log_suffix,
+ )
return instance.run(game_info, arguments)
diff --git a/src/fa/replay.py b/src/fa/replay.py
index 01c2ef8e5..359eba922 100644
--- a/src/fa/replay.py
+++ b/src/fa/replay.py
@@ -1,138 +1,206 @@
import json
+import logging
import os
-from PyQt5 import QtCore, QtWidgets
+
+import zstandard
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
+
import fa
+import util
from fa.check import check
from fa.replayparser import replayParser
-import util
-from . import mods
+from util.gameurl import GameUrl
+from util.gameurl import GameUrlType
-import logging
logger = logging.getLogger(__name__)
__author__ = 'Thygrrr'
+def decompressReplayData(fileobj, compressionType):
+ if compressionType == "zstd":
+ decompressor = zstandard.ZstdDecompressor()
+ with decompressor.stream_reader(fileobj) as reader:
+ data = QtCore.QByteArray(reader.read())
+ else:
+ b_data = fileobj.read()
+ data = QtCore.qUncompress(QtCore.QByteArray().fromBase64(b_data))
+ return data
+
+
def replay(source, detach=False):
"""
- Launches FA streaming the replay from the given location. Source can be a QUrl or a string
+ Launches FA streaming the replay from the given location.
+ Source can be a QUrl or a string
"""
logger.info("fa.exe.replay(" + str(source) + ", detach = " + str(detach))
- if fa.instance.available():
- version = None
- featured_mod_versions = None
- arg_string = None
- replay_id = None
- # Convert strings to URLs
- if isinstance(source, str):
- if os.path.isfile(source):
- if source.endswith(".fafreplay"): # the new way of doing things
- replay = open(source, "rt")
+ if not fa.instance.available():
+ return False
+
+ version = None
+ featured_mod_versions = None
+ arg_string = None
+ replay_id = None
+ compression_type = None
+ # Convert strings to URLs
+ if isinstance(source, str):
+ if os.path.isfile(source):
+ if source.endswith(".fafreplay"):
+ with open(source, "rb") as replay:
info = json.loads(replay.readline())
-
- binary = QtCore.qUncompress(QtCore.QByteArray.fromBase64(replay.read().encode('utf-8')))
- logger.info("Extracted " + str(binary.size()) + " bytes of binary data from .fafreplay.")
- replay.close()
+ compression_type = info.get("compression")
+ try:
+ binary = decompressReplayData(replay, compression_type)
+ except Exception as e:
+ logger.error(f"Could not decompress replay: {e}")
+ binary = QtCore.QByteArray()
+ logger.info(
+ "Extracted {} bytes of binary data from "
+ ".fafreplay.".format(binary.size()),
+ )
if binary.size() == 0:
logger.info("Invalid replay")
- QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "Sorry, this replay is corrupted.")
+ QtWidgets.QMessageBox.critical(
+ None,
+ "FA Forever Replay",
+ "Sorry, this replay is corrupted.",
+ )
return False
- scfa_replay = QtCore.QFile(os.path.join(util.CACHE_DIR, "temp.scfareplay"))
- scfa_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate)
- scfa_replay.write(binary)
- scfa_replay.flush()
- scfa_replay.close()
-
- mapname = info.get('mapname', None)
- mod = info['featured_mod']
- replay_id = info['uid']
- featured_mod_versions = info.get('featured_mod_versions', None)
- arg_string = scfa_replay.fileName()
-
- parser = replayParser(arg_string)
- version = parser.getVersion()
-
- elif source.endswith(".scfareplay"): # compatibility mode
- filename = os.path.basename(source)
- if len(filename.split(".")) > 2:
- mod = filename.rsplit(".", 2)[1]
- logger.info("mod guessed from " + source + " is " + str(mod))
- else:
- mod = "faf" # TODO: maybe offer a list of mods for the user.
- logger.warning("no mod could be guessed, using fallback ('faf') ")
-
- mapname = None
- arg_string = source
- parser = replayParser(arg_string)
- version = parser.getVersion()
+ scfa_replay = QtCore.QFile(
+ os.path.join(util.CACHE_DIR, "temp.scfareplay"),
+ )
+ open_mode = (
+ QtCore.QIODevice.OpenModeFlag.WriteOnly
+ | QtCore.QIODevice.OpenModeFlag.Truncate
+ )
+ scfa_replay.open(open_mode)
+ scfa_replay.write(binary)
+ scfa_replay.flush()
+ scfa_replay.close()
+
+ mapname = info.get('mapname')
+ mod = info['featured_mod']
+ replay_id = info['uid']
+ featured_mod_versions = info.get('featured_mod_versions')
+ arg_string = scfa_replay.fileName()
+
+ parser = replayParser(arg_string)
+ version = parser.getVersion()
+ if mapname == "None":
+ mapname = parser.getMapName()
+
+ elif source.endswith(".scfareplay"): # compatibility mode
+ filename = os.path.basename(source)
+ if len(filename.split(".")) > 2:
+ mod = filename.rsplit(".", 2)[1]
+ logger.info(
+ "mod guessed from {} is {}".format(source, mod),
+ )
else:
- QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "Sorry, FAF has no idea how to replay "
- "this file: " + source + "")
-
- logger.info("Replaying " + str(arg_string) + " with mod " + str(mod) + " on map " + str(mapname))
-
- # Wrap up file path in "" to ensure proper parsing by FA.exe
- arg_string = '"' + arg_string + '"'
-
+ # TODO: maybe offer a list of mods for the user.
+ mod = "faf"
+ logger.warning(
+ "no mod could be guessed, using "
+ "fallback ('faf') ",
+ )
+
+ mapname = None
+ arg_string = source
+ parser = replayParser(arg_string)
+ version = parser.getVersion()
else:
- source = QtCore.QUrl(
- source) # Try to interpret the string as an actual url, it may come from the command line
-
- if isinstance(source, QtCore.QUrl):
- url = source
- # Determine if it's a faflive url
- if url.scheme() == "faflive":
- mod = QtCore.QUrlQuery(url).queryItemValue("mod")
- mapname = QtCore.QUrlQuery(url).queryItemValue("map")
- replay_id = url.path().split("/")[0]
- # whip the URL into shape so ForgedAllianceForever.exe understands it
- arg_url = QtCore.QUrl(url)
- arg_url.setScheme("gpgnet")
- arg_url.setQuery(QtCore.QUrlQuery(""))
- arg_string = arg_url.toString()
- else:
- QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "App doesn't know how to play replays from "
- "that scheme: " + url.scheme() + "")
- return False
-
- # We couldn't construct a decent argument format to tell ForgedAlliance for this replay
- if not arg_string:
- QtWidgets.QMessageBox.critical(None, "FA Forever Replay", "App doesn't know how to play replays from that "
- "source: " + str(source) + "")
- return False
-
- # Launch preparation: Start with an empty arguments list
- arguments = ['/replay', arg_string]
-
- # Proper mod loading code
- mod = "faf" if mod == "ladder1v1" else mod
-
- if '/init' not in arguments:
- arguments.append('/init')
- arguments.append("init_" + mod + ".lua")
-
- # Disable defunct bug reporter
- arguments.append('/nobugreport')
+ QtWidgets.QMessageBox.critical(
+ None,
+ "FA Forever Replay",
+ (
+ "Sorry, FAF has no idea how to replay "
+ "this file: {}".format(source)
+ ),
+ )
+
+ logger.info(
+ "Replaying {} with mod {} on map {}"
+ .format(arg_string, mod, mapname),
+ )
+
+ # Wrap up file path in "" to ensure proper parsing by FA.exe
+ arg_string = '"' + arg_string + '"'
- # log file
- arguments.append("/log")
- arguments.append('"' + util.LOG_FILE_REPLAY + '"')
-
- if replay_id:
- arguments.append("/replayid")
- arguments.append(str(replay_id))
-
- # Update the game appropriately
- if not check(mod, mapname, version, featured_mod_versions):
- logger.error("Can't watch replays without an updated Forged Alliance game!")
- return False
-
- if fa.instance.run(None, arguments, detach):
- logger.info("Viewing Replay.")
- return True
else:
- logger.error("Replaying failed. Guru meditation: {}".format(arguments))
+ # Try to interpret the string as an actual url, it may come
+ # from the command line
+ source = QtCore.QUrl(source)
+
+ if isinstance(source, GameUrl):
+ url = source.to_url()
+ # Determine if it's a faflive url
+ if source.game_type == GameUrlType.LIVE_REPLAY:
+ mod = source.mod
+ mapname = source.map
+ replay_id = source.uid
+ # whip the URL into shape so ForgedAllianceForever.exe
+ # understands it
+ url.setScheme("gpgnet")
+ url.setQuery(QtCore.QUrlQuery(""))
+ arg_string = url.toString()
+ else:
+ QtWidgets.QMessageBox.critical(
+ None,
+ "FA Forever Replay",
+ (
+ "App doesn't know how to play replays from "
+ "that scheme: {}".format(url.scheme())
+ ),
+ )
return False
+
+ # We couldn't construct a decent argument format to tell
+ # ForgedAlliance for this replay
+ if not arg_string:
+ QtWidgets.QMessageBox.critical(
+ None,
+ "FA Forever Replay",
+ (
+ "App doesn't know how to play replays from that "
+ "source: {}".format(source)
+ ),
+ )
+ return False
+
+ # Launch preparation: Start with an empty arguments list
+ arguments = ['/replay', arg_string]
+
+ # Proper mod loading code
+ mod = "faf" if mod == "ladder1v1" else mod
+
+ if '/init' not in arguments:
+ arguments.append('/init')
+ arguments.append("init_" + mod + ".lua")
+
+ # Disable defunct bug reporter
+ arguments.append('/nobugreport')
+
+ # log file
+ arguments.append("/log")
+ arguments.append('"' + util.LOG_FILE_REPLAY + '"')
+
+ if replay_id:
+ arguments.append("/replayid")
+ arguments.append(str(replay_id))
+
+ # Update the game appropriately
+ if not check(mod, mapname, version, featured_mod_versions):
+ msg = "Can't watch replays without an updated Forged Alliance game!"
+ logger.error(msg)
+ return False
+
+ if fa.instance.run(None, arguments, detach):
+ logger.info("Viewing Replay.")
+ return True
+ else:
+ logger.error("Replaying failed. Guru meditation: {}".format(arguments))
+ return False
diff --git a/src/fa/replayparser.py b/src/fa/replayparser.py
index 4fa8e45a1..6bc708e1b 100644
--- a/src/fa/replayparser.py
+++ b/src/fa/replayparser.py
@@ -6,11 +6,11 @@ class replayParser:
def __init__(self, filepath):
self.file = filepath
- def __readLine(self, offset, bin):
+ def __readLine(self, offset, bin_):
line = b''
while True:
- char = struct.unpack("s", bin[offset:offset+1])
+ char = struct.unpack("s", bin_[offset:offset + 1])
offset = offset + 1
if char[0] == b'\r':
@@ -28,12 +28,21 @@ def __readLine(self, offset, bin):
return offset, line
def getVersion(self):
- f = open(self.file, 'rb')
- bin = f.read()
- offset = 0
- offset, supcomVersion = self.__readLine(offset, bin)
- f.close()
+ with open(self.file, 'rb') as f:
+ bin_ = f.read()
+ offset = 0
+ offset, supcomVersion = self.__readLine(offset, bin_)
if not supcomVersion.startswith("Supreme Commander v1"):
return None
else:
- return supcomVersion.split(".")[-1]
+ return int(supcomVersion.split(".")[-1])
+
+ def getMapName(self):
+ with open(self.file, 'rb') as f:
+ bin_ = f.read()
+ offset = 45
+ offset, mapname = self.__readLine(offset, bin_)
+ if not mapname.strip().startswith("/maps/"):
+ return 'None'
+ else:
+ return mapname.split('/')[2]
diff --git a/src/fa/replayserver.py b/src/fa/replayserver.py
index 199a698ee..02eceea7c 100644
--- a/src/fa/replayserver.py
+++ b/src/fa/replayserver.py
@@ -1,53 +1,74 @@
+from __future__ import annotations
-from PyQt5 import QtCore, QtNetwork, QtWidgets
-
-import os
-import logging
-import util
-import fa
import json
+import logging
+import os
import time
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
+from PyQt6 import QtWidgets
+
+import fa
+import util
from config import Settings
-INTERNET_REPLAY_SERVER_HOST = Settings.get('replay_server/host')
-INTERNET_REPLAY_SERVER_PORT = Settings.get('replay_server/port')
+GPGNET_HOST = "lobby.faforever.com"
+GPGNET_PORT = 8000
+
+DEFAULT_LIVE_REPLAY = True
-from . import DEFAULT_LIVE_REPLAY
-from . import DEFAULT_RECORD_REPLAY
-class ReplayRecorder(QtCore.QObject):
+class ReplayRecorder(QtCore.QObject):
"""
- This is a simple class that takes all the FA replay data input from its inputSocket, writes it to a file,
- and relays it to an internet server via its relaySocket.
+ This is a simple class that takes all the FA replay data input from
+ its inputSocket, writes it to a file, and relays it to an internet
+ server via its relaySocket.
"""
__logger = logging.getLogger(__name__)
- def __init__(self, parent, local_socket, *args, **kwargs):
+ def __init__(
+ self,
+ parent: ReplayServer,
+ local_socket: QtNetwork.QTcpSocket,
+ *args,
+ **kwargs,
+ ) -> None:
QtCore.QObject.__init__(self, *args, **kwargs)
self.parent = parent
self.inputSocket = local_socket
- self.inputSocket.setSocketOption(QtNetwork.QTcpSocket.KeepAliveOption, 1)
+ self.inputSocket.setSocketOption(QtNetwork.QTcpSocket.SocketOption.KeepAliveOption, 1)
self.inputSocket.readyRead.connect(self.readDatas)
self.inputSocket.disconnected.connect(self.inputDisconnected)
- self.__logger.info("FA connected locally.")
+ self.__logger.info("FA connected locally.")
# Create a file to write the replay data into
self.replayData = QtCore.QByteArray()
self.replayInfo = fa.instance._info
-
+
+ self._host = Settings.get('replay_server/host')
+ self._port = Settings.get('replay_server/port', type=int)
# Open the relay socket to our server
self.relaySocket = QtNetwork.QTcpSocket(self.parent)
- self.relaySocket.connectToHost(INTERNET_REPLAY_SERVER_HOST, INTERNET_REPLAY_SERVER_PORT)
-
- if util.settings.value("fa.live_replay", DEFAULT_LIVE_REPLAY, type=bool):
- if self.relaySocket.waitForConnected(1000): # Maybe make this asynchronous
- self.__logger.debug("internet replay server " + self.relaySocket.peerName() + ":" + str(self.relaySocket.peerPort()))
+ self.relaySocket.connectToHost(self._host, self._port)
+
+ if util.settings.value(
+ "fa.live_replay", DEFAULT_LIVE_REPLAY, type=bool,
+ ):
+ # Maybe make this asynchronous
+ if self.relaySocket.waitForConnected(1000):
+ self.__logger.debug(
+ "internet replay server {}:{}".format(
+ self.relaySocket.peerName(),
+ self.relaySocket.peerPort(),
+ ),
+ )
else:
self.__logger.error("no connection to internet replay server")
def __del__(self):
- # Clean up our socket objects, in accordance to the hint from the Qt docs (recommended practice)
+ # Clean up our socket objects, in accordance to the hint from the Qt
+ # docs (recommended practice)
self.__logger.debug("destructor entered")
self.inputSocket.deleteLater()
self.relaySocket.deleteLater()
@@ -57,7 +78,9 @@ def readDatas(self):
read = self.inputSocket.read(self.inputSocket.bytesAvailable())
if not isinstance(read, bytes):
- self.__logger.warning("Read failure on inputSocket: " + bytes.decode())
+ self.__logger.warning(
+ "Read failure on inputSocket: {}".format(bytes.decode()),
+ )
return
# Convert data into a bytearray for easier processing
@@ -65,11 +88,14 @@ def readDatas(self):
# Record locally
if self.replayData.isEmpty():
- # This prefix means "P"osting replay in the livereplay protocol of FA,
- # this needs to be stripped from the local file
+ # This prefix means "P"osting replay in the livereplay protocol of
+ # FA, this needs to be stripped from the local file
if data.startsWith(b"P/"):
rest = data.indexOf(b"\x00") + 1
- self.__logger.info("Stripping prefix '" + str(data.left(rest - 1)) + "' from replay.")
+ self.__logger.info(
+ "Stripping prefix '{}' from replay."
+ .format(data.left(rest - 1)),
+ )
self.replayData.append(data.right(data.size() - rest))
else:
self.replayData.append(data)
@@ -88,17 +114,27 @@ def done(self):
@QtCore.pyqtSlot()
def inputDisconnected(self):
self.__logger.info("FA disconnected locally.")
-
- # Part of the hardening - ensure all buffered local replay data is read and relayed
+
+ # Part of the hardening - ensure all buffered local replay data is read
+ # and relayed
if self.inputSocket.bytesAvailable():
- self.__logger.info("Relaying remaining bytes:" + str(self.inputSocket.bytesAvailable()))
+ self.__logger.info(
+ "Relaying remaining bytes: {}"
+ .format(self.inputSocket.bytesAvailable()),
+ )
self.readDatas()
-
- # Part of the hardening - ensure successful sending of the rest of the replay to the server
- if self.relaySocket.bytesToWrite():
- self.__logger.info("Waiting for replay transmission to finish: " + str(self.relaySocket.bytesToWrite()) + " bytes")
- progress = QtWidgets.QProgressDialog("Finishing Replay Transmission", "Cancel", 0, 0)
+ # Part of the hardening - ensure successful sending of the rest of the
+ # replay to the server
+ if self.relaySocket.bytesToWrite():
+ self.__logger.info(
+ "Waiting for replay transmission to finish: {} "
+ "bytes".format(self.relaySocket.bytesToWrite()),
+ )
+
+ progress = QtWidgets.QProgressDialog(
+ "Finishing Replay Transmission", "Cancel", 0, 0,
+ )
progress.show()
while self.relaySocket.bytesToWrite() and progress.isVisible():
@@ -107,64 +143,87 @@ def inputDisconnected(self):
progress.close()
self.relaySocket.disconnectFromHost()
-
+
self.writeReplayFile()
-
+
self.done()
def writeReplayFile(self):
# Update info block if possible.
- if fa.instance._info and fa.instance._info['uid'] == self.replayInfo['uid']:
+ if (
+ fa.instance._info
+ and fa.instance._info['uid'] == self.replayInfo['uid']
+ ):
if fa.instance._info.setdefault('complete', False):
self.__logger.info("Found Complete Replay Info")
else:
self.__logger.warning("Replay Info not Complete")
-
+
self.replayInfo = fa.instance._info
-
+
self.replayInfo['game_end'] = time.time()
-
- filename = os.path.join(util.REPLAY_DIR, str(self.replayInfo['uid']) + "-" + self.replayInfo['recorder'] + ".fafreplay")
- self.__logger.info("Writing local replay as " + filename + ", containing " + str(self.replayData.size()) + " bytes of replay data.")
-
+
+ basename = "{}-{}.fafreplay".format(
+ self.replayInfo['uid'], self.replayInfo['recorder'],
+ )
+ filename = os.path.join(util.REPLAY_DIR, basename)
+ self.__logger.info(
+ "Writing local replay as {}, containing {} bytes "
+ "of replay data.".format(filename, self.replayData.size()),
+ )
+
replay = QtCore.QFile(filename)
- replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Text)
+ replay.open(QtCore.QIODevice.OpenModeFlag.WriteOnly | QtCore.QIODevice.OpenModeFlag.Text)
replay.write(json.dumps(self.replayInfo).encode('utf-8'))
replay.write(b'\n')
replay.write(QtCore.qCompress(self.replayData).toBase64())
replay.close()
-
+
class ReplayServer(QtNetwork.QTcpServer):
"""
This is a local listening server that FA can send its replay data to.
- It will instantiate a fresh ReplayRecorder for each FA instance that launches.
+ It will instantiate a fresh ReplayRecorder for each FA instance that
+ launches.
"""
__logger = logging.getLogger(__name__)
def __init__(self, client, *args, **kwargs):
QtNetwork.QTcpServer.__init__(self, *args, **kwargs)
self.recorders = []
- self.client = client
+ self.client = client # type - ClientWindow
self.__logger.debug("initializing...")
self.newConnection.connect(self.acceptConnection)
- def doListen(self,local_port):
+ def doListen(self) -> bool:
while not self.isListening():
- self.listen(QtNetwork.QHostAddress.LocalHost, local_port)
+ self.listen(QtNetwork.QHostAddress.SpecialAddress.LocalHost, 0)
if self.isListening():
- self.__logger.info("listening on address " + self.serverAddress().toString() + ":" + str(self.serverPort()))
+ self.__logger.info(
+ "listening on address {}:{}".format(
+ self.serverAddress().toString(),
+ self.serverPort(),
+ ),
+ )
else:
- self.__logger.error("cannot listen, port probably used by another application: " + str(local_port))
- answer = QtWidgets.QMessageBox.warning(None, "Port Occupied", "FAF couldn't start its local replay "
- "server, which is needed to play Forged "
- "Alliance online. Possible reasons:
"
- "
FAF is already running (most "
- "likely)
another program is "
- "listening on port {port}
"
- .format(port=local_port),
- QtWidgets.QMessageBox.Retry, QtWidgets.QMessageBox.Abort)
- if answer == QtWidgets.QMessageBox.Abort:
+ self.__logger.error(
+ "cannot listen, port probably used by "
+ "another application: {}".format(self.serverPort()),
+ )
+ answer = QtWidgets.QMessageBox.warning(
+ None,
+ "Port Occupied",
+ (
+ "FAF couldn't start its local replay server, which is "
+ "needed to play Forged Alliance online. Possible "
+ "reasons:
FAF is already running (most "
+ "likely)
another program is listening on port "
+ "{}
".format(self.serverPort())
+ ),
+ QtWidgets.QMessageBox.StandardButton.Retry,
+ QtWidgets.QMessageBox.StandardButton.Abort,
+ )
+ if answer == QtWidgets.QMessageBox.StandardButton.Abort:
return False
return True
diff --git a/src/fa/updater.py b/src/fa/updater.py
deleted file mode 100644
index d2b759583..000000000
--- a/src/fa/updater.py
+++ /dev/null
@@ -1,763 +0,0 @@
-
-"""
-This is the FORGED ALLIANCE updater.
-
-It ensures, through communication with faforever.com, that Forged Alliance is properly updated,
-patched, and all required files for a given mod are installed
-
-@author thygrrr
-"""
-import os
-import stat
-import subprocess
-import time
-import shutil
-import logging
-import urllib.request, urllib.error, urllib.parse
-import sys
-import tempfile
-import json
-import ast
-
-import config
-from config import Settings
-import fafpath
-
-from PyQt5 import QtWidgets, QtCore, QtNetwork
-
-import util
-import modvault
-
-
-logger = logging.getLogger(__name__)
-
-# This contains a complete dump of everything that was supplied to logOutput
-debugLog = []
-
-
-# Interface for the user of a connection.
-class ConnectionHandler(object):
- def __init__(self):
- pass
-
- def atConnectionError(self, socketError):
- pass
-
- def atDisconnect(self):
- pass
-
- def atConnectionRead(self):
- pass
-
- def atNewBlock(self, blocksize):
- pass
-
- def atBlockProgress(self, avail, blocksize):
- pass
-
- def atBlockComplete(self, blocksize, block):
- pass
-
-
-class UpdateConnection(object):
- def __init__(self, handler, host, port):
- self.host = host
- self.port = port
- self.handler = handler
-
- self.blockSize = 0
- self.updateSocket = QtNetwork.QTcpSocket()
- self.updateSocket.setSocketOption(QtNetwork.QTcpSocket.KeepAliveOption, 1)
- self.updateSocket.setSocketOption(QtNetwork.QTcpSocket.LowDelayOption, 1)
-
- self.updateSocket.error.connect(self.handleServerError)
- self.updateSocket.readyRead.connect(self.readDataFromServer)
- self.updateSocket.disconnected.connect(self.disconnected)
-
- def connect(self):
- self.updateSocket.connectToHost(self.host, self.port)
-
- def connected(self):
- return self.updateSocket.state() == QtNetwork.QAbstractSocket.ConnectedState
-
- def disconnect(self):
- self.updateSocket.close()
-
- def handleServerError(self, socketError):
- """
- Simple error handler that flags the whole operation as failed, not very graceful but what can you do...
- """
- if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError:
- log("FA Server down: The server is down for maintenance, please try later.")
-
- elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError:
- log("Connection to Host lost. Please check the host name and port settings.")
-
- elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError:
- log("The connection was refused by the peer.")
- else:
- log("The following error occurred: %s." % self.updateSocket.errorString())
-
- self.handler.atConnectionError(socketError)
-
- def disconnected(self):
- # This isn't necessarily an error so we won't change self.result here.
- log("Disconnected from server at " + timestamp())
- self.handler.atDisconnect()
-
- def readDataFromServer(self):
- self.handler.atConnectionRead()
-
- ins = QtCore.QDataStream(self.updateSocket)
- ins.setVersion(QtCore.QDataStream.Qt_4_2)
-
- while not ins.atEnd():
- # Nothing was read yet, commence a new block.
- if self.blockSize == 0:
- # wait for enough bytes to piece together block size information
- if self.updateSocket.bytesAvailable() < 4:
- return
-
- self.blockSize = ins.readUInt32()
- self.handler.atNewBlock(self.blockSize)
-
- avail = self.updateSocket.bytesAvailable()
- # We have an incoming block, wait for enough bytes to accumulate
- if avail < self.blockSize:
- self.handler.atBlockProgress(avail, self.blockSize)
- return # until later, this slot is reentrant
-
- # Enough bytes accumulated. Carry on.
- self.handler.atBlockComplete(self.blockSize, ins)
-
- # Prepare to read the next block
- self.blockSize = 0
-
- def writeToServer(self, action, *args, **kw):
- log(("writeToServer(" + action + ", [" + ', '.join(args) + "])"))
- self.lastData = time.time()
-
- block = QtCore.QByteArray()
- out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite)
- out.setVersion(QtCore.QDataStream.Qt_4_2)
- out.writeUInt32(0)
- out.writeQString(action)
-
- for arg in args:
- if type(arg) is int:
- out.writeInt(arg)
- elif isinstance(arg, str):
- out.writeQString(arg)
- elif type(arg) is float:
- out.writeFloat(arg)
- elif type(arg) is list:
- out.writeQVariantList(arg)
- else:
- log("Uninterpreted Data Type: " + str(type(arg)) + " of value: " + str(arg))
- out.writeQString(str(arg))
-
- out.device().seek(0)
- out.writeUInt32(block.size() - 4)
-
- self.bytesToSend = block.size() - 4
- self.updateSocket.write(block)
-
-
-FormClass, BaseClass = util.THEME.loadUiType("fa/updater/updater.ui")
-
-
-class UpdaterProgressDialog(FormClass, BaseClass):
- def __init__(self, parent):
- BaseClass.__init__(self, parent)
- self.setupUi(self)
- self.logPlainTextEdit.setVisible(False)
- self.adjustSize()
- self.watches = []
-
- @QtCore.pyqtSlot(str)
- def appendLog(self, text):
- self.logPlainTextEdit.appendPlainText(text)
-
- @QtCore.pyqtSlot(QtCore.QObject)
- def addWatch(self, watch):
- self.watches.append(watch)
- watch.finished.connect(self.watchFinished)
-
- @QtCore.pyqtSlot()
- def watchFinished(self):
- for watch in self.watches:
- if not watch.isFinished():
- return
- self.done(QtWidgets.QDialog.Accepted) # equivalent to self.accept(), but clearer
-
-
-def clearLog():
- global debugLog
- debugLog = []
-
-
-def log(string):
- logger.debug(string)
- debugLog.append(str(string))
-
-
-def dumpPlainText():
- return "\n".join(debugLog)
-
-
-def dumpHTML():
- return " ".join(debugLog)
-
-
-# A set of exceptions we use to see what goes wrong during asynchronous data transfer waits
-class UpdaterCancellation(Exception):
- pass
-
-
-class UpdaterFailure(Exception):
- pass
-
-
-class UpdaterTimeout(Exception):
- pass
-
-
-class Updater(QtCore.QObject, ConnectionHandler):
- """
- This is the class that does the actual installation work.
- """
- # Network configuration
- SOCKET = 9001
- HOST = Settings.get('lobby/host')
- TIMEOUT = 20 # seconds
-
- # Return codes to expect from run()
- RESULT_SUCCESS = 0 # Update successful
- RESULT_NONE = -1 # Update operation is still ongoing
- RESULT_FAILURE = 1 # An error occured during updating
- RESULT_CANCEL = 2 # User cancelled the download process
- RESULT_ILLEGAL = 3 # User has the wrong version of FA
- RESULT_BUSY = 4 # Server is currently busy
- RESULT_PASS = 5 # User refuses to update by canceling the wizard
-
- def __init__(self, featured_mod, version=None, modversions=None, sim=False, silent=False, *args, **kwargs):
- """
- Constructor
- """
- QtCore.QObject.__init__(self, *args, **kwargs)
-
- self.filesToUpdate = []
- self.updatedFiles = []
-
- self.connection = UpdateConnection(self, self.HOST, self.SOCKET)
- self.lastData = time.time()
-
- self.featured_mod = featured_mod
- self.version = version
- self.modversions = modversions
-
- self.sim = sim
- self.modpath = None
-
- self.result = self.RESULT_NONE
-
- self.destination = None
-
- self.silent = silent
- self.progress = QtWidgets.QProgressDialog()
- if self.silent:
- self.progress.setCancelButton(None)
- else:
- self.progress.setCancelButtonText("Cancel")
- self.progress.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint)
- self.progress.setAutoClose(False)
- self.progress.setAutoReset(False)
- self.progress.setModal(1)
- self.progress.setWindowTitle("Updating %s" % str(self.featured_mod).upper())
-
- self.bytesToSend = 0
-
- def run(self, *args, **kwargs):
- clearLog()
- log("Update started at " + timestamp())
- log("Using appdata: " + util.APPDATA_DIR)
-
- self.progress.show()
- QtWidgets.QApplication.processEvents()
-
- # Actual network code adapted from previous version
- self.progress.setLabelText("Connecting to update server...")
-
- self.connection.connect()
-
- while not (self.connection.connected()) and self.progress.isVisible():
- QtWidgets.QApplication.processEvents()
-
- if not self.progress.wasCanceled():
- log("Connected to update server at " + timestamp())
-
- self.doUpdate()
-
- self.progress.setLabelText("Cleaning up.")
- self.connection.disconnect()
- self.progress.close()
- else:
- log("Cancelled connecting to server.")
- self.result = self.RESULT_CANCEL
-
- log("Update finished at " + timestamp())
- return self.result
-
- def fetchFile(self, url, toFile):
- try:
- logger.info('Updater: Downloading {}'.format(url))
- progress = QtWidgets.QProgressDialog()
- progress.setCancelButtonText("Cancel")
- progress.setWindowFlags(QtCore.Qt.CustomizeWindowHint | QtCore.Qt.WindowTitleHint)
- progress.setAutoClose(True)
- progress.setAutoReset(False)
-
- req = urllib.request.Request(url, headers={'User-Agent': "FAF Client"})
- downloadedfile = urllib.request.urlopen(req)
- meta = downloadedfile.info()
-
- # Fix for #241, sometimes the server sends an error and no content-length.
- file_size = int(meta.get_all("Content-Length")[0])
- progress.setMinimum(0)
- progress.setMaximum(file_size)
- progress.setModal(1)
- progress.setWindowTitle("Downloading Update")
- label = QtWidgets.QLabel()
- label.setOpenExternalLinks(True)
- progress.setLabel(label)
- progress.setLabelText('Downloading FA file : ' + url + ' File size: ' + str(
- int(file_size / 1024 / 1024)) + ' MiB')
- progress.show()
-
- # Download the file as a series of up to 4 KiB chunks, then uncompress it.
-
- output = tempfile.NamedTemporaryFile(mode='w+b', delete=False)
-
- file_size_dl = 0
- block_sz = 4096
-
- while progress.isVisible():
- QtWidgets.QApplication.processEvents()
- if not progress.isVisible():
- break
- read_buffer = downloadedfile.read(block_sz)
- if not read_buffer:
- break
- file_size_dl += len(read_buffer)
- output.write(read_buffer)
- progress.setValue(file_size_dl)
-
- output.flush()
- os.fsync(output.fileno())
- output.close()
-
- shutil.move(output.name, toFile)
-
- if (progress.value() == file_size) or progress.value() == -1:
- logger.debug("File downloaded successfully.")
- return True
- else:
- QtWidgets.QMessageBox.information(None, "Aborted", "Download not complete.")
- logger.warning("File download not complete.")
- return False
- except:
- logger.error("Updater error: ", exc_info=sys.exc_info())
- QtWidgets.QMessageBox.information(None, "Download Failed", "The file wasn't properly sent by the server. "
- " Try again later.")
- return False
-
- def updateFiles(self, destination, filegroup):
- """
- Updates the files in a given file group, in the destination subdirectory of the Forged Alliance path.
- If existing=True, the existing contents of the directory will be added to the current self.filesToUpdate
- list.
- """
- QtWidgets.QApplication.processEvents()
-
- self.progress.setLabelText("Updating files: " + filegroup)
- self.destination = destination
-
- self.connection.writeToServer("GET_FILES_TO_UPDATE", filegroup)
- self.waitForFileList()
-
- # Ensure our list is unique
- self.filesToUpdate = list(set(self.filesToUpdate))
-
- targetdir = os.path.join(util.APPDATA_DIR, destination)
- if not os.path.exists(targetdir):
- os.makedirs(targetdir)
-
- for fileToUpdate in self.filesToUpdate:
- md5File = util.md5(os.path.join(util.APPDATA_DIR, destination, fileToUpdate))
- if md5File is None:
- if self.version:
- if self.featured_mod == "faf" or self.featured_mod == "ladder1v1" or \
- filegroup == "FAF" or filegroup == "FAFGAMEDATA":
- self.connection.writeToServer("REQUEST_VERSION", destination, fileToUpdate, str(self.version))
- else:
- self.connection.writeToServer("REQUEST_MOD_VERSION", destination, fileToUpdate,
- json.dumps(self.modversions))
- else:
-
- self.connection.writeToServer("REQUEST_PATH", destination, fileToUpdate)
- else:
- if self.version:
- if self.featured_mod == "faf" or self.featured_mod == "ladder1v1" or \
- filegroup == "FAF" or filegroup == "FAFGAMEDATA":
- self.connection.writeToServer("PATCH_TO", destination, fileToUpdate, md5File, str(self.version))
- else:
-
- self.connection.writeToServer("MOD_PATCH_TO", destination, fileToUpdate, md5File,
- json.dumps(self.modversions))
- else:
- self.connection.writeToServer("UPDATE", destination, fileToUpdate, md5File)
-
- self.waitUntilFilesAreUpdated()
-
- def waitForSimModPath(self):
- """
- A simple loop that waits until the server has transmitted a sim mod path.
- """
- self.lastData = time.time()
-
- self.progress.setValue(0)
- self.progress.setMinimum(0)
- self.progress.setMaximum(0)
-
- while self.modpath is None:
- if self.progress.wasCanceled():
- raise UpdaterCancellation("Operation aborted while waiting for sim mod path.")
-
- if self.result != self.RESULT_NONE:
- raise UpdaterFailure("Operation failed while waiting for sim mod path.")
-
- if time.time() - self.lastData > self.TIMEOUT:
- raise UpdaterTimeout("Operation timed out while waiting for sim mod path.")
-
- QtWidgets.QApplication.processEvents()
-
- def waitForFileList(self):
- """
- A simple loop that waits until the server has transmitted a file list.
- """
- self.lastData = time.time()
-
- self.progress.setValue(0)
- self.progress.setMinimum(0)
- self.progress.setMaximum(0)
-
- while len(self.filesToUpdate) == 0:
- if self.progress.wasCanceled():
- raise UpdaterCancellation("Operation aborted while waiting for file list.")
-
- if self.result != self.RESULT_NONE:
- raise UpdaterFailure("Operation failed while waiting for file list.")
-
- if time.time() - self.lastData > self.TIMEOUT:
- raise UpdaterTimeout("Operation timed out while waiting for file list.")
-
- QtWidgets.QApplication.processEvents()
-
- log("Files to update: [" + ', '.join(self.filesToUpdate) + "]")
-
- def waitUntilFilesAreUpdated(self):
- """
- A simple loop that updates the progress bar while the server sends actual file data
- """
- self.lastData = time.time()
-
- self.progress.setValue(0)
- self.progress.setMinimum(0)
- self.progress.setMaximum(0)
-
- while len(self.filesToUpdate) > 0:
- if self.progress.wasCanceled():
- raise UpdaterCancellation("Operation aborted while waiting for data.")
-
- if self.result != self.RESULT_NONE:
- raise UpdaterFailure("Operation failed while waiting for data.")
-
- if time.time() - self.lastData > self.TIMEOUT:
- raise UpdaterTimeout("Connection timed out while waiting for data.")
-
- QtWidgets.QApplication.processEvents()
-
- log("Updates applied successfully.")
-
- def prepareBinFAF(self):
- """
- Creates all necessary files in the binFAF folder, which contains a modified copy of all
- that is in the standard bin folder of Forged Alliance
- """
- self.progress.setLabelText("Preparing binFAF...")
-
- # now we check if we've got a binFAF folder
- FABindir = os.path.join(config.Settings.get("ForgedAlliance/app/path"), 'bin')
- FAFdir = util.BIN_DIR
-
- # Try to copy without overwriting, but fill in any missing files, otherwise it might miss some files to update
- root_src_dir = FABindir
- root_dst_dir = FAFdir
-
- for src_dir, _, files in os.walk(root_src_dir):
- dst_dir = src_dir.replace(root_src_dir, root_dst_dir)
- if not os.path.exists(dst_dir):
- os.mkdir(dst_dir)
- for file_ in files:
- src_file = os.path.join(src_dir, file_)
- dst_file = os.path.join(dst_dir, file_)
- if not os.path.exists(dst_file):
- shutil.copy(src_file, dst_dir)
- st = os.stat(dst_file)
- os.chmod(dst_file, st.st_mode | stat.S_IWRITE) # make all files we were considering writable, because we may need to patch them
-
- def doUpdate(self):
- """ The core function that does most of the actual update work."""
- try:
- if self.sim:
- self.connection.writeToServer("REQUEST_SIM_PATH", self.featured_mod)
- self.waitForSimModPath()
- if self.result == self.RESULT_SUCCESS:
- if modvault.downloadMod(self.modpath):
- self.connection.writeToServer("ADD_DOWNLOAD_SIM_MOD", self.featured_mod)
-
- else:
- # Prepare FAF directory & all necessary files
- self.prepareBinFAF()
-
- # Update the mod if it's requested
- if self.featured_mod == "faf" or self.featured_mod == "ladder1v1": # HACK - ladder1v1 "is" FAF. :-)
- self.updateFiles("bin", "FAF")
- self.updateFiles("gamedata", "FAFGAMEDATA")
- pass
- elif self.featured_mod:
- self.updateFiles("bin", "FAF")
- self.updateFiles("gamedata", "FAFGAMEDATA")
- self.updateFiles("bin", self.featured_mod)
- self.updateFiles("gamedata", self.featured_mod + "Gamedata")
-
- except UpdaterTimeout as e:
- log("TIMEOUT: {}".format(e))
- self.result = self.RESULT_FAILURE
- except UpdaterCancellation as e:
- log("CANCELLED: {}".format(e))
- self.result = self.RESULT_CANCEL
- except Exception as e:
- log("EXCEPTION: {}".format(e))
- self.result = self.RESULT_FAILURE
- else:
- self.result = self.RESULT_SUCCESS
- finally:
- self.connection.disconnect()
-
- # Hide progress dialog if it's still showing.
- self.progress.close()
-
- # Integrated handlers for the various things that could go wrong
- if self.result == self.RESULT_CANCEL:
- pass # The user knows damn well what happened here.
- elif self.result == self.RESULT_PASS:
- QtWidgets.QMessageBox.information(QtWidgets.QApplication.activeWindow(), "Installation Required",
- "You can't play without a legal version of Forged Alliance.")
- elif self.result == self.RESULT_BUSY:
- QtWidgets.QMessageBox.information(QtWidgets.QApplication.activeWindow(), "Server Busy",
- "The Server is busy preparing new patch files. Try again later.")
- elif self.result == self.RESULT_FAILURE:
- failureDialog()
-
- # If nothing terribly bad happened until now,
- # the operation is a success and/or the client can display what's up.
- return self.result
-
- def handleAction(self, bytecount, action, stream):
- """
- Process server responses by interpreting its intent and then acting upon it
- """
- log("handleAction(%s) - %d bytes" % (action, bytecount))
-
- if action == "PATH_TO_SIM_MOD":
- path = stream.readQString()
- self.modpath = path
- self.result = self.RESULT_SUCCESS
- return
-
- elif action == "SIM_MOD_NOT_FOUND":
- log("Error: Unknown sim mod requested.")
- self.modpath = ""
- self.result = self.RESULT_FAILURE
- return
-
- elif action == "LIST_FILES_TO_UP":
- # Used to be an eval() here, this is a safe backwards-compatible fix
- listStr = str(stream.readQString())
- self.filesToUpdate = ast.literal_eval(listStr)
-
- if self.filesToUpdate is None:
- self.filesToUpdate = []
- return
-
- elif action == "UNKNOWN_APP":
- log("Error: Unknown app/mod requested.")
- self.result = self.RESULT_FAILURE
- return
-
- elif action == "THIS_PATCH_IS_IN_CREATION EXCEPTION":
- log("Error: Patch is in creation.")
- self.result = self.RESULT_BUSY
- return
-
- elif action == "VERSION_PATCH_NOT_FOUND":
- response = stream.readQString()
- log("Error: Patch version %s not found for %s." % (self.version, response))
- self.connection.writeToServer("REQUEST_VERSION", self.destination, response, self.version)
- return
-
- elif action == "VERSION_MOD_PATCH_NOT_FOUND":
- response = stream.readQString()
- log("Error: Patch version %s not found for %s." % (str(self.modversions), response))
- self.connection.writeToServer("REQUEST_MOD_VERSION", self.destination, response, json.dumps(self.modversions))
- return
-
- elif action == "PATCH_NOT_FOUND":
- response = stream.readQString()
- log("Error: Patch not found for %s." % response)
- self.connection.writeToServer("REQUEST", self.destination, response)
-
- return
-
- elif action == "UP_TO_DATE":
- response = stream.readQString()
- log("file : " + response)
- log("%s is up to date." % response)
- self.filesToUpdate.remove(str(response))
- return
-
- elif action == "ERROR_FILE":
- response = stream.readQString()
- log("ERROR: File not found on server : %s." % response)
- self.filesToUpdate.remove(str(response))
- self.result = self.RESULT_FAILURE
- return
-
- elif action == "SEND_FILE_PATH":
- path = stream.readQString()
- fileToCopy = stream.readQString()
- url = stream.readQString()
-
- toFile = os.path.join(util.APPDATA_DIR, str(path), str(fileToCopy))
- self.fetchFile(url, toFile)
- self.filesToUpdate.remove(str(fileToCopy))
- self.updatedFiles.append(str(fileToCopy))
-
- elif action == "SEND_FILE":
- path = stream.readQString()
-
- # HACK for feature/new-patcher
- path = util.LUA_DIR if path == "bin" else path
-
- fileToCopy = stream.readQString()
- size = stream.readInt()
- fileDatas = stream.readRawData(size)
-
- toFile = os.path.join(util.APPDATA_DIR, str(path), str(fileToCopy))
-
- writeFile = QtCore.QFile(toFile)
-
- if writeFile.open(QtCore.QIODevice.WriteOnly):
- writeFile.write(fileDatas)
- writeFile.close()
- else:
- logger.warning("%s is not writeable in in %s. Skipping." % (
- fileToCopy, path)) # This may or may not be desirable behavior
-
- log("%s is copied in %s." % (fileToCopy, path))
- self.filesToUpdate.remove(str(fileToCopy))
- self.updatedFiles.append(str(fileToCopy))
-
- elif action == "SEND_PATCH_URL":
- destination = str(stream.readQString())
- fileToUpdate = str(stream.readQString())
- url = str(stream.readQString())
-
- toFile = os.path.join(util.CACHE_DIR, "temp.patch")
- #
- if self.fetchFile(url, toFile):
- completePath = os.path.join(util.APPDATA_DIR, destination, fileToUpdate)
- self.applyPatch(completePath, toFile)
-
- log("%s/%s is patched." % (destination, fileToUpdate))
- self.filesToUpdate.remove(str(fileToUpdate))
- self.updatedFiles.append(str(fileToUpdate))
- else:
- log("Failed to update file :'(")
- else:
- log("Unexpected server command received: " + action)
- self.result = self.RESULT_FAILURE
-
- def applyPatch(self, original, patch):
- toFile = os.path.join(util.CACHE_DIR, "patchedFile")
- # applying delta
- if sys.platform == 'win32':
- xdelta = os.path.join(fafpath.get_libdir(), "xdelta3.exe")
- else:
- xdelta = "xdelta3"
- subprocess.call([xdelta, '-d', '-f', '-s', original, patch, toFile], stdout=subprocess.PIPE)
- shutil.copy(toFile, original)
- os.remove(toFile)
- os.remove(patch)
-
- # Connection handler methods start here
-
- def atConnectionError(self, error):
- self.result = self.RESULT_FAILURE
-
- def atConnectionRead(self):
- self.lastData = time.time() # Keep resetting that timeout counter
-
- def atNewBlock(self, blockSize):
- if blockSize > 65536:
- self.progress.setLabelText("Downloading...")
- self.progress.setValue(0)
- self.progress.setMaximum(blockSize)
- else:
- self.progress.setValue(0)
- self.progress.setMinimum(0)
- self.progress.setMaximum(0)
-
- # Update our Gui at least once before proceeding
- # (we might be receiving a huge file and this is not the first time we get here)
- self.lastData = time.time()
- QtWidgets.QApplication.processEvents()
-
- def atBlockProgress(self, avail, blockSize):
- self.progress.setValue(avail)
-
- def atBlockComplete(self, blockSize, block):
- self.progress.setValue(blockSize)
- # Update our Gui at least once before proceeding (we might have to write a big file)
- self.lastData = time.time()
- QtWidgets.QApplication.processEvents()
-
- action = block.readQString()
- self.handleAction(blockSize, action, block)
- self.progress.setValue(0)
- self.progress.setMinimum(0)
- self.progress.setMaximum(0)
- self.progress.reset()
-
-
-def timestamp():
- return time.strftime("%Y-%m-%d %H:%M:%S")
-
-
-# This is a pretty rough port of the old installer wizard. It works, but will need some work later
-def failureDialog():
- """
- The dialog that shows the user the log if something went wrong.
- """
- raise Exception(dumpPlainText())
diff --git a/src/fa/upnp.py b/src/fa/upnp.py
deleted file mode 100644
index 3ef1b77cc..000000000
--- a/src/fa/upnp.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Created on Mar 22, 2012
-
-@author: thygrrr
-"""
-import logging
-import sys
-import util
-import platform
-logger = logging.getLogger(__name__)
-
-UPNP_APP_NAME = "Forged Alliance Forever"
-# Fields in mappingPort
-# UpnpPort.Description
-# UpnpPort.ExternalPort
-# UpnpPort.ExternalIPAddress
-# UpnpPort.InternalClient
-# UpnpPort.InternalPort
-# UpnpPort.Protocol
-# UpnpPort.Enabled
-
-
-def dumpMapping(mappingPort):
- logger.info("-> %s mapping of %s:%d to %s:%d" % (mappingPort.Protocol, mappingPort.InternalClient,
- mappingPort.InternalPort, mappingPort.ExternalIPAddress,
- mappingPort.ExternalPort))
-
-if platform.system() == "Windows":
- def createPortMapping(ip, port, protocol="UDP"):
- logger.info("UPnP mapping {}:{}".format(ip, port))
- try:
- import win32com.client
- NATUPnP = win32com.client.Dispatch("HNetCfg.NATUPnP")
- mappingPorts = NATUPnP.StaticPortMappingCollection
-
- if mappingPorts:
- mappingPorts.Add(port, protocol, port, ip, True, UPNP_APP_NAME)
- for mappingPort in mappingPorts:
- if mappingPort.Description == UPNP_APP_NAME:
- dumpMapping(mappingPort)
- else:
- logger.error("Couldn't get StaticPortMappingCollection")
- except:
- logger.error("Exception in UPnP createPortMapping.",
- exc_info=sys.exc_info())
-
- def removePortMappings():
- logger.info("Removing UPnP port mapping.")
- try:
- import win32com.client
- NATUPnP = win32com.client.Dispatch("HNetCfg.NATUPnP")
- mappingPorts = NATUPnP.StaticPortMappingCollection
-
- if mappingPorts:
- if mappingPorts.Count:
- for mappingPort in mappingPorts:
- if mappingPort.Description == UPNP_APP_NAME:
- dumpMapping(mappingPort)
- mappingPorts.Remove(mappingPort.ExternalPort, mappingPort.Protocol)
- else:
- logger.info("No mappings found / collection empty.")
- else:
- logger.error("Couldn't get StaticPortMappingCollection")
- except:
- logger.error("Exception in UPnP removePortMappings.", exc_info=sys.exc_info())
-else:
- def createPortMapping(ip, port, protocol='UDP'):
- logger.info("FIXME: Create a UPNP mapper for platform != Windows")
-
- def removePortMappings():
- logger.info("FIXME: Create a UPNP mapper for platform != Windows")
diff --git a/src/fa/utils.py b/src/fa/utils.py
new file mode 100644
index 000000000..f45e1ef52
--- /dev/null
+++ b/src/fa/utils.py
@@ -0,0 +1,54 @@
+import binascii
+import logging
+import os
+import zipfile
+
+from api.models.FeaturedModFile import FeaturedModFile
+from util import APPDATA_DIR
+
+logger = logging.getLogger(__name__)
+
+
+def crc32(filepath: str) -> int | None:
+ try:
+ with open(filepath, "rb") as stream:
+ return binascii.crc32(stream.read())
+ except Exception as e:
+ logger.exception(f"CRC check for {filepath!r} fail! Details: {e}")
+ return None
+
+
+def unpack_movies_and_sounds(file: FeaturedModFile) -> None:
+ """
+ Unpacks movies and sounds (based on path in zipfile) to the corresponding
+ folder. Movies must be unpacked for FA to be able to play them.
+ This is a hack needed because the game updater can only handle bin and
+ gamedata.
+ """
+ # construct dirs
+ gd = os.path.join(APPDATA_DIR, "gamedata")
+
+ origpath = os.path.join(gd, file.name)
+
+ if os.path.exists(origpath) and zipfile.is_zipfile(origpath):
+ try:
+ zf = zipfile.ZipFile(origpath)
+ except Exception as e:
+ logger.exception(f"Failed to open Game File {origpath!r}: {e}")
+ return
+
+ for zi in zf.infolist():
+ movie_or_sound = (
+ zi.filename.startswith("movies")
+ or zi.filename.startswith("sounds")
+ )
+ if movie_or_sound and not zi.is_dir():
+ tgtpath = os.path.join(APPDATA_DIR, zi.filename)
+ # copy only if file is different - check first if file
+ # exists, then if size is changed, then crc
+ if (
+ not os.path.exists(tgtpath)
+ or os.stat(tgtpath).st_size != zi.file_size
+ or crc32(tgtpath) != zi.CRC
+ ):
+ zf.extract(zi, APPDATA_DIR)
diff --git a/src/fa/wizards.py b/src/fa/wizards.py
index 69f618031..2a68b328a 100644
--- a/src/fa/wizards.py
+++ b/src/fa/wizards.py
@@ -1,26 +1,43 @@
-from PyQt5 import QtWidgets, QtCore
-from fa.path import validatePath, typicalForgedAlliancePaths
+from __future__ import annotations
+
+import os
+from typing import TYPE_CHECKING
+
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
import util
+from fa.path import typicalForgedAlliancePaths
+from fa.path import validatePath
+
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+
__author__ = 'Thygrrr'
class UpgradePage(QtWidgets.QWizardPage):
- def __init__(self, parent=None):
+ def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
super(UpgradePage, self).__init__(parent)
self.setTitle("Specify Forged Alliance folder")
- self.setPixmap(QtWidgets.QWizard.WatermarkPixmap, util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"))
+ self.setPixmap(
+ QtWidgets.QWizard.WizardPixmap.WatermarkPixmap,
+ util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"),
+ )
layout = QtWidgets.QVBoxLayout()
- self.label = QtWidgets.QLabel("FAF needs a version of Supreme Commander: Forged Alliance to launch games and "
- "replays.
Please choose the installation you wish to use. "
- " The following versions are equally supported:
3596(Retail "
- "version)
3599 (Retail patch)
3603beta (GPGnet beta patch)
"
- "
1.6.6 (Steam Version)
FAF doesn't modify your existing files. "
- " Select folder:")
+ self.label = QtWidgets.QLabel(
+ "FAF needs a version of Supreme Commander: Forged Alliance to "
+ "launch games and replays.
Please choose the "
+ "installation you wish to use.
The following versions"
+ " are equally supported:
3596(Retail version)
"
+ "
3599 (Retail patch)
3603beta (GPGnet beta patch)
"
+ "
1.6.6 (Steam Version)
FAF doesn't modify your "
+ "existing files.
Select folder:",
+ )
self.label.setWordWrap(True)
layout.addWidget(self.label)
@@ -42,30 +59,35 @@ def __init__(self, parent=None):
self.setCommitPage(True)
def comboChanged(self):
+ tgt_dir = self.comboBox.currentText()
+ if not validatePath(tgt_dir):
+ # User picked some subdirectory (most likely bin)
+ parent = os.path.dirname(tgt_dir)
+ if validatePath(parent):
+ self.comboBox.setCurrentText(parent)
self.completeChanged.emit()
@QtCore.pyqtSlot()
- def showChooser(self):
- path = QtWidgets.QFileDialog.getExistingDirectory(self, "Select Forged Alliance folder",
- self.comboBox.currentText(),
- QtWidgets.QFileDialog.DontResolveSymlinks |
- QtWidgets.QFileDialog.ShowDirsOnly)
+ def showChooser(self) -> None:
+ path = QtWidgets.QFileDialog.getExistingDirectory(
+ self,
+ "Select Forged Alliance folder",
+ self.comboBox.currentText(),
+ (
+ QtWidgets.QFileDialog.Option.DontResolveSymlinks
+ | QtWidgets.QFileDialog.Option.ShowDirsOnly
+ ),
+ )
if path:
self.comboBox.insertItem(0, path)
self.comboBox.setCurrentIndex(0)
self.completeChanged.emit()
def isComplete(self, *args, **kwargs):
- if validatePath(self.comboBox.currentText()):
- return True
- else:
- return False
+ return validatePath(self.comboBox.currentText())
def validatePage(self, *args, **kwargs):
- if validatePath(self.comboBox.currentText()):
- return True
- else:
- return False
+ return validatePath(self.comboBox.currentText())
class Wizard(QtWidgets.QWizard):
@@ -73,28 +95,33 @@ class Wizard(QtWidgets.QWizard):
The actual Wizard which walks the user through the install.
"""
- def __init__(self, client, *args, **kwargs):
+ def __init__(self, client: ClientWindow, *args, **kwargs) -> None:
QtWidgets.QWizard.__init__(self, client, *args, **kwargs)
- self.client = client
+ self.client = client # type - ClientWindow
self.upgrade = UpgradePage()
self.addPage(self.upgrade)
- self.setWizardStyle(QtWidgets.QWizard.ModernStyle)
+ self.setWizardStyle(QtWidgets.QWizard.WizardStyle.ModernStyle)
self.setWindowTitle("Forged Alliance Game Path")
- self.setPixmap(QtWidgets.QWizard.WatermarkPixmap, util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"))
+ self.setPixmap(
+ QtWidgets.QWizard.WizardPixmap.WatermarkPixmap,
+ util.THEME.pixmap("fa/updater/forged_alliance_watermark.png"),
+ )
- self.setOption(QtWidgets.QWizard.NoBackButtonOnStartPage, True)
+ self.setOption(QtWidgets.QWizard.WizardOption.NoBackButtonOnStartPage, True)
def accept(self):
- util.settings.setValue("ForgedAlliance/app/path", self.upgrade.comboBox.currentText())
+ util.settings.setValue(
+ "ForgedAlliance/app/path", self.upgrade.comboBox.currentText(),
+ )
QtWidgets.QWizard.accept(self)
-def constructPathChoices(combobox, validated_choices):
+def constructPathChoices(combobox: QtWidgets.QComboBox, validated_choices: list[str]) -> None:
"""
Creates a combobox with all potentially valid paths for FA on this system
"""
combobox.clear()
for path in validated_choices:
- if combobox.findText(path, QtCore.Qt.MatchFixedString) == -1:
- combobox.addItem(path)
+ if combobox.findText(path, QtCore.Qt.MatchFlag.MatchFixedString) == -1:
+ combobox.addItem(path)
diff --git a/src/fafpath.py b/src/fafpath.py
index c6c1c4c1c..ece91f082 100644
--- a/src/fafpath.py
+++ b/src/fafpath.py
@@ -50,10 +50,14 @@ def get_libdir():
"""
if run_from_frozen():
# lib dir should be where our executable lives
- return os.path.join(os.path.dirname(sys.executable), "lib")
+ return os.path.join(os.path.dirname(sys.executable), "natives")
elif run_from_unix_install():
# Everything should be in PATH
return None
else:
# We are most likely running from source
- return os.path.join(get_srcdir(), "lib")
+ return os.path.join(get_srcdir(), "natives")
+
+
+def get_java_path():
+ return os.path.join(get_libdir(), "ice-adapter", "jre", "bin", "java.exe")
diff --git a/src/games/__init__.py b/src/games/__init__.py
index 94f250b2a..771706343 100644
--- a/src/games/__init__.py
+++ b/src/games/__init__.py
@@ -1,6 +1,10 @@
import logging
-from fa import factions
-logger = logging.getLogger(__name__)
# For use by other modules
from ._gameswidget import GamesWidget
+
+__all__ = (
+ "GamesWidget",
+)
+
+logger = logging.getLogger(__name__)
diff --git a/src/games/_gameswidget.py b/src/games/_gameswidget.py
index 9a79f9b02..9d1a2b057 100644
--- a/src/games/_gameswidget.py
+++ b/src/games/_gameswidget.py
@@ -1,77 +1,127 @@
-from functools import partial
-import random
+import logging
-from PyQt5 import QtWidgets
-from PyQt5.QtGui import QDesktopServices
-from PyQt5.QtCore import QUrl, pyqtSlot
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtCore import pyqtSlot
+from PyQt6.QtGui import QColor
+from PyQt6.QtGui import QCursor
+import fa
import util
+from api.featured_mod_api import FeaturedModApiConnector
from config import Settings
-from games.moditem import ModItem, mod_invisible
+from games.automatchframe import MatchmakerQueue
from games.gamemodel import CustomGameFilterModel
-from fa.factions import Factions
-import fa
-
-import logging
+from games.moditem import ModItem
+from games.moditem import mod_invisible
+from model.chat.channel import PARTY_CHANNEL_SUFFIX
logger = logging.getLogger(__name__)
FormClass, BaseClass = util.THEME.loadUiType("games/games.ui")
+class Party:
+ def __init__(self, owner_id=-1, owner=None):
+ self.owner_id = owner_id
+ self.members = [owner] if owner else []
+
+ @property
+ def memberCount(self):
+ return len(self.memberList)
+
+ @property
+ def memberList(self):
+ return self.members
+
+ def addMember(self, member):
+ self.memberList.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
+
+
+class PartyMember:
+ def __init__(self, id_=-1, factions=None):
+ self.id_ = id_
+ self.factions = ["uef", "cybran", "aeon", "seraphim"]
+
+
class GamesWidget(FormClass, BaseClass):
hide_private_games = Settings.persisted_property(
- "play/hidePrivateGames", default_value=False, type=bool)
+ "play/hidePrivateGames",
+ default_value=False,
+ type=bool,
+ )
sort_games_index = Settings.persisted_property(
- "play/sortGames", default_value=0, type=int) # Default is by player count
- sub_factions = Settings.persisted_property(
- "play/subFactions", default_value=[False, False, False, False])
-
- def __init__(self, client, game_model, me, gameview_builder, game_launcher):
- BaseClass.__init__(self)
+ "play/sortGames",
+ default_value=0,
+ type=int,
+ )
+
+ matchmaker_search_info = pyqtSignal(dict)
+ match_found_message = pyqtSignal(dict)
+ stop_search_ranked_game = pyqtSignal()
+ party_updated = pyqtSignal()
+
+ def __init__(
+ self,
+ client,
+ game_model,
+ me,
+ gameview_builder,
+ game_launcher,
+ ):
+ BaseClass.__init__(self, client)
self.setupUi(self)
self._me = me
- self.client = client
+ self.client = client # type - ClientWindow
self.mods = {}
self._game_model = CustomGameFilterModel(self._me, game_model)
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.game_double_clicked.connect(self.gameDoubleClicked)
- # Ranked search UI
- self._ranked_icons = {
- Factions.AEON: self.rankedAeon,
- Factions.CYBRAN: self.rankedCybran,
- Factions.SERAPHIM: self.rankedSeraphim,
- Factions.UEF: self.rankedUEF,
- }
- self.rankedAeon.setIcon(util.THEME.icon("games/automatch/aeon.png"))
- self.rankedCybran.setIcon(util.THEME.icon("games/automatch/cybran.png"))
- self.rankedSeraphim.setIcon(util.THEME.icon("games/automatch/seraphim.png"))
- self.rankedUEF.setIcon(util.THEME.icon("games/automatch/uef.png"))
-
- # Fixup ini file type loss
- self.sub_factions = [True if x == 'true' else False for x in self.sub_factions]
-
- self.searchProgress.hide()
-
- # Ranked search state variables
- self.searching = False
- self.race = None
+ self.matchFoundQueueName = ""
self.ispassworded = False
-
- self.generateSelectSubset()
-
- self.client.lobby_info.modInfo.connect(self.processModInfo)
-
- self.client.gameEnter.connect(self.stopSearchRanked)
- self.client.viewingReplay.connect(self.stopSearchRanked)
-
- self.sortGamesComboBox.addItems(['By Players', 'By avg. Player Rating', 'By Map', 'By Host', 'By Age'])
- self.sortGamesComboBox.currentIndexChanged.connect(self.sortGamesComboChanged)
+ self.party = None
+
+ self.client.matchmaker_info.connect(self.handleMatchmakerInfo)
+ self.client.game_enter.connect(self.stopSearch)
+ 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
@@ -86,170 +136,80 @@ def __init__(self, client, game_model, me, gameview_builder, game_launcher):
self.hideGamesWithPw.setChecked(self.hide_private_games)
self.modList.itemDoubleClicked.connect(self.hostGameClicked)
+ self.teamList.itemPressed.connect(self.teamListItemClicked)
- self.updatePlayButton()
+ self.hidePartyInfo()
+ self.leaveButton.clicked.connect(self.leave_party)
- @pyqtSlot(dict)
- def processModInfo(self, message):
- """
- Slot that interprets and propagates mod_info messages into the mod list
- """
- mod = message['name']
- old_mod = self.mods.get(mod, None)
- self.mods[mod] = ModItem(message)
-
- if old_mod:
- if mod in mod_invisible:
- del mod_invisible[mod]
- for i in range(0, self.modList.count()):
- if self.modList.item(i) == old_mod:
- self.modList.takeItem(i)
- continue
-
- if message["publish"]:
- self.modList.addItem(self.mods[mod])
- else:
- mod_invisible[mod] = self.mods[mod]
-
- self.client.replays.modList.addItem(message["name"])
-
- @pyqtSlot(int)
- def togglePrivateGames(self, state):
- self.hide_private_games = state
- self._game_model.hide_private_games = state
+ self.apiConnector.requestData()
- def selectFaction(self, enabled, factionID=0):
- logger.debug('selectFaction: enabled={}, factionID={}'.format(enabled, factionID))
- if len(self.sub_factions) < factionID:
- logger.warning('selectFaction: len(self.sub_factions) < factionID, aborting')
- return
+ self.searching = {"ladder1v1": False}
+ self.matchmakerShortcuts = []
- logger.debug('selectFaction: selected was {}'.format(self.sub_factions))
- self.sub_factions[factionID-1] = enabled
+ self.matchmakerFramesInitialized = False
- Settings.set("play/subFactions", self.sub_factions)
- logger.debug('selectFaction: selected is {}'.format(self.sub_factions))
+ def refreshMods(self):
+ self.apiConnector.requestData()
- if self.searching:
- self.stopSearchRanked()
+ def onAuthorized(self, me):
+ if not self.mods:
+ self.refreshMods()
+ if self.party is None:
+ self.party = Party(me.id, PartyMember(me.id))
+ if not self.matchmakerFramesInitialized:
+ self.client.lobby_connection.send(dict(command="matchmaker_info"))
- self.updatePlayButton()
+ def onLogOut(self):
+ self.stopSearch()
+ self.party = None
+ while self.matchmakerQueues.widget(0) is not None:
+ self.matchmakerQueues.widget(0).deleteLater()
+ self.matchmakerQueues.removeTab(0)
+ for shortcut in self.matchmakerShortcuts:
+ shortcut.setEnabled(False)
+ shortcut.deleteLater()
+ self.matchmakerShortcuts.clear()
+ self.matchmakerFramesInitialized = False
- def startSubRandomRankedSearch(self):
+ @pyqtSlot(dict)
+ def process_mod_info(self, message: dict) -> None:
"""
- This is a wrapper around startRankedSearch where a faction will be chosen based on the selected checkboxes
+ Slot that interprets and propagates mod_info messages into the mod list
"""
- if self.searching:
- self.stopSearchRanked()
- else:
- factionSubset = []
-
- if self.rankedUEF.isChecked():
- factionSubset.append("uef")
- if self.rankedCybran.isChecked():
- factionSubset.append("cybran")
- if self.rankedAeon.isChecked():
- factionSubset.append("aeon")
- if self.rankedSeraphim.isChecked():
- factionSubset.append("seraphim")
-
- l = len(factionSubset)
- if l in [0, 4]:
- self.startSearchRanked(Factions.RANDOM)
+ for featured_mod in message["values"]:
+ mod = featured_mod.name
+ old_mod = self.mods.get(mod, None)
+ self.mods[mod] = ModItem(featured_mod)
+
+ if old_mod:
+ if mod in mod_invisible:
+ del mod_invisible[mod]
+ for i in range(0, self.modList.count()):
+ if self.modList.item(i) == old_mod:
+ self.modList.takeItem(i)
+ for i in range(self.client.replays.modList.count()):
+ if self.client.replays.modList.itemText(i) == old_mod.mod:
+ self.client.replays.modList.removeItem(i)
+
+ if featured_mod.visible:
+ self.modList.addItem(self.mods[mod])
else:
- # chooses a random factionstring from factionsubset and converts it to a Faction
- self.startSearchRanked(Factions.from_name(
- factionSubset[random.randint(0, l - 1)]))
-
- def startViewLadderMapsPool(self):
- QDesktopServices.openUrl(QUrl(Settings.get("MAPPOOL_URL")))
-
- def generateSelectSubset(self):
- if self.searching: # you cannot search for a match while changing/creating the UI
- self.stopSearchRanked()
-
- self.rankedPlay.clicked.connect(self.startSubRandomRankedSearch)
- self.rankedPlay.show()
- self.laddermapspool.clicked.connect(self.startViewLadderMapsPool)
- self.labelRankedHint.show()
- for faction, icon in list(self._ranked_icons.items()):
- try:
- icon.clicked.disconnect()
- except TypeError:
- pass
-
- icon.setChecked(self.sub_factions[faction.value-1])
- icon.clicked.connect(partial(self.selectFaction, factionID=faction.value))
-
- def updatePlayButton(self):
- if self.searching:
- s = "Stop search"
- else:
- c = self.sub_factions.count(True)
- if c in [0, 4]: # all or none selected
- s = "Play as random!"
- else:
- s = "Play!"
+ mod_invisible[mod] = self.mods[mod]
- self.rankedPlay.setText(s)
+ self.client.replays.modList.addItem(mod)
- def startSearchRanked(self, race):
- if race == Factions.RANDOM:
- race = Factions.get_random_faction()
-
- if fa.instance.running():
- QtWidgets.QMessageBox.information(
- None, "ForgedAllianceForever.exe", "FA is already running.")
- self.stopSearchRanked()
- return
+ @pyqtSlot(int)
+ def togglePrivateGames(self, state):
+ self.hide_private_games = state
+ self._game_model.hide_private_games = state
- if not fa.check.check("ladder1v1"):
- self.stopSearchRanked()
- logger.error("Can't play ranked without successfully updating Forged Alliance.")
- return
-
- if self.searching:
- logger.info("Switching Ranked Search to Race " + str(race))
- self.race = race
- self.client.lobby_connection.send(dict(command="game_matchmaking", mod="ladder1v1", state="settings",
- faction=self.race.value))
- else:
- # Experimental UPnP Mapper - mappings are removed on app exit
- if self.client.useUPnP:
- self.client.lobby_connection.set_upnp(self.client.gamePort)
-
- logger.info("Starting Ranked Search as " + str(race) +
- ", port: " + str(self.client.gamePort))
- self.searching = True
- self.race = race
- self.searchProgress.setVisible(True)
- self.labelAutomatch.setText("Searching...")
- self.updatePlayButton()
- self.client.search_ranked(faction=self.race.value)
-
- @pyqtSlot()
- def stopSearchRanked(self, *args):
- if self.searching:
- logger.debug("Stopping Ranked Search")
- self.client.lobby_connection.send(dict(command="game_matchmaking", mod="ladder1v1", state="stop"))
- self.searching = False
-
- self.updatePlayButton()
- self.searchProgress.setVisible(False)
- self.labelAutomatch.setText("1 vs 1 Automatch")
-
- @pyqtSlot(bool)
- def toggle_search(self, enabled, race=None):
- """
- Handler called when a ladder search button is pressed. They're really checkboxes, and the
- state flag is used to decide whether to start or stop the search.
- :param state: The checkedness state of the search checkbox that was pushed
- :param player_faction: The faction corresponding to that checkbox
- """
- if enabled and not self.searching:
- self.startSearchRanked(race)
- else:
- self.stopSearchRanked()
+ def stopSearch(self):
+ self.searching = {"ladder1v1": False}
+ self.client.labelAutomatchInfo.setText("")
+ self.client.labelAutomatchInfo.hide()
+ if self.matchFoundQueueName:
+ self.matchFoundQueueName = ""
+ self.stop_search_ranked_game.emit()
def gameDoubleClicked(self, game):
"""
@@ -258,15 +218,29 @@ def gameDoubleClicked(self, game):
if not fa.instance.available():
return
- self.stopSearchRanked() # Actually a workaround
+ if (
+ self.party is not None
+ and self.party.memberCount > 1
+ and not self.leave_party()
+ ):
+ return
+ self.stopSearch()
if not fa.check.game(self.client):
return
- if fa.check.check(game.featured_mod, mapname=game.mapname, version=None, sim_mods=game.sim_mods):
+ if fa.check.check(
+ game.featured_mod, mapname=game.mapname,
+ version=None, sim_mods=game.sim_mods,
+ ):
if game.password_protected:
passw, ok = QtWidgets.QInputDialog.getText(
- self.client, "Passworded game", "Enter password :", QtWidgets.QLineEdit.Normal, "")
+ self.client,
+ "Passworded game",
+ "Enter password :",
+ QtWidgets.QLineEdit.Normal,
+ "",
+ )
if ok:
self.client.join_game(uid=game.uid, password=passw)
else:
@@ -279,9 +253,191 @@ def hostGameClicked(self, item):
"""
if not fa.instance.available():
return
- self.stopSearchRanked()
+
+ if (
+ self.party is not None
+ and self.party.memberCount > 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
+ item.setSelected(False)
+
+ if (
+ QtWidgets.QApplication.mouseButtons() == Qt.MouseButton.RightButton
+ and self.party.owner_id == self._me.id
+ ):
+ self.teamList.setCurrentItem(item)
+ playerLogin = item.data(0)
+ playerId = self.client.players[playerLogin].id
+ menu = QtWidgets.QMenu(self)
+ actionKick = QtWidgets.QAction("Kick from party", menu)
+ actionKick.triggered.connect(
+ lambda: self.kickPlayerFromParty(playerId),
+ )
+ menu.addAction(actionKick)
+ menu.popup(QCursor.pos())
+
+ def updateParty(self, message):
+ players_ids = []
+ for member in message["members"]:
+ players_ids.append(member["player"])
+
+ old_owner = self.client.players[self.party.owner_id]
+ new_owner = self.client.players[message["owner"]]
+ if (
+ old_owner.id != new_owner.id
+ or self._me.id not in players_ids
+ or len(message["members"]) < 2
+ ):
+ self.client._chatMVC.connection.part(
+ "#{}{}".format(old_owner.login, PARTY_CHANNEL_SUFFIX),
+ )
+
+ new_party = Party()
+ if len(message["members"]) > 1 and self._me.id in players_ids:
+ 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"]),
+ )
+ else:
+ new_party.owner_id = self._me.id
+ new_party.addMember(PartyMember(id_=self._me.id))
+
+ if self.party != new_party:
+ self.stopSearch()
+ self.party = new_party
+ if self.party.memberCount > 1:
+ self.client._chatMVC.connection.join(
+ "#{}{}".format(new_owner.login, PARTY_CHANNEL_SUFFIX),
+ )
+ self.updateTeamList()
+
+ self.updatePartyInfoFrame()
+ self.party_updated.emit()
+
+ def showPartyInfo(self):
+ self.partyInfo.show()
+
+ def hidePartyInfo(self):
+ self.partyInfo.hide()
+
+ def updatePartyInfoFrame(self):
+ if self.party.memberCount > 1:
+ self.showPartyInfo()
+ else:
+ self.hidePartyInfo()
+
+ def updateTeamList(self):
+ self.teamList.clear()
+ for member_id in self.party.memberIds:
+ if member_id != self._me.id:
+ item = QtWidgets.QListWidgetItem(
+ self.client.players[member_id].login,
+ )
+ if member_id == self.party.owner_id:
+ item.setIcon(util.THEME.icon("chat/rank/partyleader.png"))
+ else:
+ item.setIcon(util.THEME.icon("chat/rank/newplayer.png"))
+ self.teamList.addItem(item)
+
+ def accept_party_invite(self, sender_id):
+ self.stopSearch()
+ logger.info("Accepting paryt invite from {}".format(sender_id))
+ msg = {
+ 'command': 'accept_party_invite',
+ 'sender_id': sender_id,
+ }
+ self.client.lobby_connection.send(msg)
+
+ def kickPlayerFromParty(self, playerId):
+ login = self.client.players[playerId].login
+ result = QtWidgets.QMessageBox.question(
+ self, "Kick Player: {}".format(login),
+ "Are you sure you want to kick {} from party?".format(login),
+ QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No,
+ )
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
+ self.stopSearch()
+ msg = {
+ 'command': 'kick_player_from_party',
+ 'kicked_player_id': playerId,
+ }
+ self.client.lobby_connection.send(msg)
+
+ def leave_party(self):
+ result = QtWidgets.QMessageBox.question(
+ self, "Leaving Party", "Are you sure you want to leave party?",
+ QtWidgets.QMessageBox.StandardButton.Yes, QtWidgets.QMessageBox.StandardButton.No,
+ )
+ if result == QtWidgets.QMessageBox.StandardButton.Yes:
+ msg = {
+ 'command': 'leave_party',
+ }
+ self.client.lobby_connection.send(msg)
+
+ if self.isInGame(self._me.id):
+ self.client.players[self._me.id]._currentGame = None
+ return True
+ else:
+ return False
+
+ def handleMatchmakerSearchInfo(self, message):
+ self.matchmaker_search_info.emit(message)
+
+ 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
+ else:
+ return True
+
+ def handleMatchmakerInfo(self, message):
+ # there were cases when ladder info came earlier than the answer
+ # to client's matchmaker_info request, so probably it will need to be
+ # fully hardcoded when everything comes out, but for now just
+ # need to be sure that there are at least 2 queues in message
+ if (
+ not self.matchmakerFramesInitialized
+ and len(message.get("queues", {})) > 1
+ ):
+ logger.info("Initializing matchmaker queue frames")
+ queues = message.get("queues", {})
+ queues.sort(key=lambda queue: queue["team_size"])
+ for index, queue in enumerate(queues):
+ self.matchmakerQueues.insertTab(
+ index,
+ MatchmakerQueue(
+ self, self.client,
+ queue["queue_name"], queue["team_size"],
+ ),
+ "&{teamSize} vs {teamSize}".format(
+ teamSize=queue["team_size"],
+ ),
+ )
+ for index in range(self.matchmakerQueues.tabBar().count()):
+ self.matchmakerQueues.tabBar().setTabTextColor(
+ index, QColor("silver"),
+ )
+ self.matchmakerFramesInitialized = True
diff --git a/src/games/automatchframe.py b/src/games/automatchframe.py
new file mode 100644
index 000000000..2a2aa75ea
--- /dev/null
+++ b/src/games/automatchframe.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import logging
+from functools import partial
+from typing import TYPE_CHECKING
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+
+import fa
+import util
+from api.matchmaker_queue_api import MatchmakerQueueApiConnector
+from config import Settings
+from fa.factions import Factions
+
+FormClass, BaseClass = util.THEME.loadUiType("games/automatchframe.ui")
+
+logger = logging.getLogger(__name__)
+
+if TYPE_CHECKING:
+ from client._clientwindow import ClientWindow
+ from games._gameswidget import GamesWidget
+
+
+class MatchmakerQueue(FormClass, BaseClass):
+
+ def __init__(
+ self,
+ games: GamesWidget,
+ client: ClientWindow,
+ queueName: str,
+ teamSize: int,
+ ) -> None:
+ BaseClass.__init__(self, games)
+ self.setupUi(self)
+
+ self.queueName = queueName
+ self.teamSize = teamSize
+ self.subFactions = Settings.get(
+ "play/{}Factions".format(self.queueName),
+ default=[False] * 4,
+ type=bool,
+ )
+ self.games = games
+ self.client = client
+ self.client.matchmaker_info.connect(self.handleQueueInfo)
+ self.games.matchmaker_search_info.connect(self.handleSearchInfo)
+ self.games.match_found_message.connect(self.handleMatchFound)
+ self.games.stop_search_ranked_game.connect(self.stopSearchRanked)
+ self.games.party_updated.connect(self.handlePartyUpdate)
+
+ self._rankedIcons = {
+ Factions.AEON: self.rankedAeon,
+ Factions.CYBRAN: self.rankedCybran,
+ Factions.SERAPHIM: self.rankedSeraphim,
+ Factions.UEF: self.rankedUEF,
+ }
+ self.rankedUEF.setIcon(util.THEME.icon("games/automatch/uef.png"))
+ self.rankedAeon.setIcon(util.THEME.icon("games/automatch/aeon.png"))
+ self.rankedCybran.setIcon(
+ util.THEME.icon("games/automatch/cybran.png"),
+ )
+ self.rankedSeraphim.setIcon(
+ util.THEME.icon("games/automatch/seraphim.png"),
+ )
+
+ self.searching = False
+ self.updatePlayButton()
+
+ self.rankedPlay.clicked.connect(self.startSearchRanked)
+ self.rankedPlay.show()
+ self.mapsPool.clicked.connect(self.startViewMapsPool)
+
+ self.setFactionIcons(self.subFactions)
+
+ keys = (
+ QtCore.Qt.Key.Key_1, QtCore.Qt.Key.Key_2, QtCore.Qt.Key.Key_3, QtCore.Qt.Key.Key_4,
+ )
+ self.shortcut = QtGui.QShortcut(
+ QtGui.QKeySequence(QtCore.Qt.Key.Key_Control + keys[self.teamSize - 1]),
+ self.client,
+ self.startSearchRanked,
+ )
+ self.games.matchmakerShortcuts.append(self.shortcut)
+
+ self.matchmakerTimer = QtCore.QTimer()
+ self.matchmakerTimer.timeout.connect(self.updateMatchmakerTimer)
+ self.secondsToAutomatch = 0
+
+ self.ratingType = ""
+ self.apiConnector = MatchmakerQueueApiConnector()
+ self.apiConnector.data_ready.connect(self.handleApiQueueInfo)
+ self.apiConnector.requestData({"include": "leaderboard"})
+
+ title = self.queueName.replace("_", " ").capitalize()
+ self.automatchTitle.setText(title)
+
+ def setFactionIcons(self, subFactions):
+ for faction, icon in self._rankedIcons.items():
+ try:
+ icon.clicked.disconnect()
+ except TypeError:
+ pass
+ icon.setChecked(subFactions[faction.value - 1])
+ icon.clicked.connect(
+ partial(self.selectFaction, factionID=faction.value),
+ )
+
+ def handleApiQueueInfo(self, message):
+ for queue in message.get("values", {}):
+ if queue["technicalName"] == self.queueName:
+ self.ratingType = queue["ratingType"]
+
+ def handleQueueInfo(self, message):
+ for queue in message.get("queues", {}):
+ if queue["queue_name"] == self.queueName:
+ self.labelInQueue.setText(
+ "In Queue: {}".format(queue["num_players"]),
+ )
+ self.secondsToAutomatch = int(queue["queue_pop_time_delta"])
+ self.updateLabelMatchingIn()
+ self.matchmakerTimer.start(1 * 1000)
+
+ def handleSearchInfo(self, message):
+ if message["queue_name"] == self.queueName:
+ self.searching = message["state"] == "start"
+ self.games.searching[self.queueName] = self.searching
+ self.updatePlayButton()
+
+ def handleMatchFound(self, message):
+ if message.get("queue_name", "") == self.queueName:
+ # clear but do not cancel search
+ self.searching = False
+ self.games.searching[self.queueName] = False
+ self.updatePlayButton()
+
+ def updateMatchmakerTimer(self):
+ if self.secondsToAutomatch > 0:
+ self.secondsToAutomatch -= 1
+ self.updateLabelMatchingIn()
+
+ def updateLabelMatchingIn(self):
+ minutes, seconds = divmod(self.secondsToAutomatch, 60)
+ self.labelMatchingIn.setText(
+ "Matching In: {:02}:{:02}".format(int(minutes), int(seconds)),
+ )
+
+ def startSearchRanked(self):
+ if (
+ self.games.party.memberCount > self.teamSize
+ or self.games.party.owner_id != self.client.me.id
+ ):
+ return
+
+ if self.searching:
+ self.stopSearchRanked()
+ return
+
+ if not any(self.games.searching.values()):
+ if fa.instance.running():
+ QtWidgets.QMessageBox.information(
+ self.client,
+ "ForgedAllianceForever.exe",
+ "FA is already running.",
+ )
+ self.stopSearchRanked()
+ return
+
+ if not fa.check.check("ladder1v1"):
+ self.stopSearchRanked()
+ logger.error(
+ "Can't play ranked without successfully "
+ "updating Forged Alliance.",
+ )
+ return
+
+ logger.debug(
+ "Starting Ranked Search. Queue: {}".format(self.queueName),
+ )
+ self.client.search_ranked(queue_name=self.queueName)
+
+ def stopSearchRanked(self):
+ if self.searching:
+ logger.debug("Stopping Ranked Search")
+ self.client.lobby_connection.send(
+ dict(
+ command="game_matchmaking",
+ queue_name=self.queueName,
+ state="stop",
+ ),
+ )
+ self.searching = False
+ self.games.searching[self.queueName] = False
+ self.updatePlayButton()
+
+ def handlePartyUpdate(self):
+ if (
+ self.games.party.memberCount > self.teamSize
+ or self.games.party.owner_id != self.client.me.id
+ ):
+ self.rankedPlay.setEnabled(False)
+ else:
+ self.rankedPlay.setEnabled(True)
+
+ def updatePlayButton(self):
+ index = self.games.matchmakerQueues.indexOf(self)
+ if self.searching:
+ s = "Stop search"
+ self.searchProgress.show()
+ self.games.matchmakerQueues.tabBar().setTabTextColor(
+ index, QtGui.QColor("orange"),
+ )
+ else:
+ c = self.subFactions.count(True)
+ if c in [0, 4]:
+ s = "Play as random!"
+ else:
+ s = "Play!"
+ self.searchProgress.hide()
+ self.games.matchmakerQueues.tabBar().setTabTextColor(
+ index, QtGui.QColor("silver"),
+ )
+ self.rankedPlay.setText(s)
+
+ def startViewMapsPool(self):
+ if self.client.me.id is None:
+ QtGui.QDesktopServices.openUrl(
+ QtCore.QUrl(Settings.get("MAPPOOL_URL")),
+ )
+ else:
+ rating = self.client.me.player.rating_estimate(self.ratingType)
+ self.client.mapvault.requestMapPool(self.queueName, rating)
+ self.client.mainTabs.setCurrentIndex(
+ self.client.mainTabs.indexOf(self.client.vaultsTab),
+ )
+ self.client.topTabs.setCurrentIndex(0)
+
+ def selectFaction(self, enabled, factionID=0):
+ if len(self.subFactions) < factionID:
+ return
+ self.subFactions[factionID - 1] = enabled
+ Settings.set(
+ "play/{}Factions".format(self.queueName), self.subFactions,
+ )
+ self.updatePlayButton()
diff --git a/src/games/gameitem.py b/src/games/gameitem.py
index 32c3546dc..0d89a848b 100644
--- a/src/games/gameitem.py
+++ b/src/games/gameitem.py
@@ -1,6 +1,12 @@
+import html
import os
+
+import jinja2
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+
import util
-from PyQt5 import QtCore, QtWidgets, QtGui
from fa import maps
@@ -70,52 +76,66 @@ def paint(self, painter, option, index):
def _draw_clear_option(self, painter, option):
option.icon = QtGui.QIcon()
option.text = ""
- option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem,
- option, painter, option.widget)
+ option.widget.style().drawControl(
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
+ )
def _draw_icon_shadow(self, painter, option):
- painter.fillRect(option.rect.left() + self.ICON_SHADOW_OFFSET,
- option.rect.top() + self.ICON_SHADOW_OFFSET,
- self.ICON_RECT,
- self.ICON_RECT,
- self.SHADOW_COLOR)
+ painter.fillRect(
+ option.rect.left() + self.ICON_SHADOW_OFFSET,
+ option.rect.top() + self.ICON_SHADOW_OFFSET,
+ self.ICON_RECT,
+ self.ICON_RECT,
+ self.SHADOW_COLOR,
+ )
def _draw_icon(self, painter, option, icon):
- rect = option.rect.adjusted(self.ICON_CLIP_TOP_LEFT,
- self.ICON_CLIP_TOP_LEFT,
- self.ICON_CLIP_BOTTOM_RIGHT,
- self.ICON_CLIP_BOTTOM_RIGHT)
- icon.paint(painter, rect, QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
+ rect = option.rect.adjusted(
+ self.ICON_CLIP_TOP_LEFT,
+ self.ICON_CLIP_TOP_LEFT,
+ self.ICON_CLIP_BOTTOM_RIGHT,
+ self.ICON_CLIP_BOTTOM_RIGHT,
+ )
+ alignment_flags = QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop
+ icon.paint(painter, rect, alignment_flags)
def _draw_frame(self, painter, option):
pen = QtGui.QPen()
pen.setWidth(self.FRAME_THICKNESS)
pen.setBrush(self.FRAME_COLOR)
- pen.setCapStyle(QtCore.Qt.RoundCap)
+ pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
- painter.drawRect(option.rect.left() + self.ICON_CLIP_TOP_LEFT,
- option.rect.top() + self.ICON_CLIP_TOP_LEFT,
- self.ICON_RECT,
- self.ICON_RECT)
+ painter.drawRect(
+ option.rect.left() + self.ICON_CLIP_TOP_LEFT,
+ option.rect.top() + self.ICON_CLIP_TOP_LEFT,
+ self.ICON_RECT,
+ self.ICON_RECT,
+ )
def _draw_text(self, painter, option, text):
left_off = self.ICON_RECT + self.TEXT_OFFSET
top_off = self.TEXT_OFFSET
right_off = self.TEXT_RIGHT_MARGIN
bottom_off = 0
- painter.translate(option.rect.left() + left_off,
- option.rect.top() + top_off)
- clip = QtCore.QRectF(0,
- 0,
- option.rect.width() - left_off - right_off,
- option.rect.height() - top_off - bottom_off)
+ painter.translate(
+ option.rect.left() + left_off,
+ option.rect.top() + top_off,
+ )
+ clip = QtCore.QRectF(
+ 0,
+ 0,
+ option.rect.width() - left_off - right_off,
+ option.rect.height() - top_off - bottom_off,
+ )
html = QtGui.QTextDocument()
html.setHtml(text)
html.drawContents(painter, clip)
def sizeHint(self, option, index):
- return QtCore.QSize(self.ICON_SIZE + self.TEXT_WIDTH + self.PADDING,
- self.ICON_SIZE)
+ return QtCore.QSize(
+ self.ICON_SIZE + self.TEXT_WIDTH + self.PADDING,
+ self.ICON_SIZE,
+ )
class GameTooltipFilter(QtCore.QObject):
@@ -124,7 +144,7 @@ def __init__(self, formatter):
self._formatter = formatter
def eventFilter(self, obj, event):
- if event.type() == QtCore.QEvent.ToolTip:
+ if event.type() == QtCore.QEvent.Type.ToolTip:
return self._handle_tooltip(obj, event)
else:
return super().eventFilter(obj, event)
@@ -154,24 +174,25 @@ def _featured_mod(self, game):
def _host_color(self, game):
hostid = game.host_player.id if game.host_player is not None else -1
- return self._colors.getUserColor(hostid)
+ return self._colors.get_user_color(hostid)
def text(self, data):
game = data.game
+ players = game.num_players - len(game.observers)
formatting = {
"color": self._host_color(game),
"mapslots": game.max_players,
- "mapdisplayname": game.mapdisplayname,
- "title": game.title,
- "host": game.host,
- "players": game.num_players,
- "playerstring": "player" if game.num_players == 1 else "players",
- "avgrating": int(game.average_rating)
+ "mapdisplayname": html.escape(game.mapdisplayname),
+ "title": html.escape(game.title),
+ "host": html.escape(game.host),
+ "players": players,
+ "playerstring": "player" if players == 1 else "players",
+ "avgrating": int(game.average_rating),
}
if self._featured_mod(game):
return self.FORMATTER_FAF.format(**formatting)
else:
- formatting["mod"] = game.featured_mod
+ formatting["mod"] = html.escape(game.featured_mod)
return self.FORMATTER_MOD.format(**formatting)
def icon(self, data):
@@ -194,122 +215,60 @@ def needed_map_preview(self, data):
return name
def _game_teams(self, game):
- teams = {index: [game.to_player(name) if game.is_connected(name)
- else name for name in team]
- for index, team in game.playing_teams.items()}
+ teams = {
+ index: [
+ game.to_player(name)
+ if game.is_connected(name)
+ else name
+ for name in team
+ ]
+ for index, team in game.playing_teams.items()
+ }
# Sort teams into a list
# TODO - I believe there's a convention where team 1 is 'no team'
- teamlist = [indexed_team for indexed_team in teams.items()]
- teamlist.sort()
+ teamlist = sorted([indexed_team for indexed_team in teams.items()])
teamlist = [team for index, team in teamlist]
return teamlist
def _game_observers(self, game):
- return [game.to_player(name) for name in game.observers
- if game.is_connected(name)]
+ return [
+ game.to_player(name)
+ for name in game.observers
+ if game.is_connected(name)
+ ]
def tooltip(self, data):
game = data.game
teams = self._game_teams(game)
observers = self._game_observers(game)
- return self._tooltip_formatter.format(teams, observers, game.sim_mods)
+ title = game.title
+ title = title.replace("<", "<")
+ title = title.replace(">", ">")
+ return self._tooltip_formatter.format(
+ title, teams, observers, game.sim_mods,
+ )
class GameTooltipFormatter:
- TIP_FORMAT = str(util.THEME.readfile("games/formatters/tool.qthtml"))
def __init__(self, me):
self._me = me
-
- def _teams_tooltip(self, teams):
- versus_string = (
- "
"
- "VS"
- "
")
-
- def alignment(teams):
- for i, team in enumerate(teams):
- if i == 0:
- yield 'left', team
- elif i == len(teams) - 1:
- yield 'right', team
- else:
- yield 'middle', team
-
- team_tables = [self._team_table(team, align)
- for align, team in alignment(teams)]
- return versus_string.join(team_tables)
-
- def _team_table(self, team, align):
- team_table_start = "
"
- team_table_end = "
"
- rows = [self._player_table_row(player, align) for player in team]
- return team_table_start + "".join(rows) + team_table_end
-
- def _player_table_row(self, player, align):
- if isinstance(player, str):
- country = "
"
- else:
- country = "
{country_icon}
"
- pname = ("
"
- "{player}"
- "
")
- order = [pname, country] if align == 'right' else [country, pname]
- player_row = "
{}{}
".format(*order)
-
- if isinstance(player, str):
- return player_row.format(alignment=align, player=player)
- else:
- return player_row.format(
- country_icon=self._country_icon_fmt(player),
- alignment=align,
- player=self._player_fmt(player))
-
- def _country_icon_fmt(self, player):
- icon_path_fmt = os.path.join("chat", "countries", "{}.png")
- icon_path = icon_path_fmt.format(player.country.lower())
+ template_abs_path = os.path.join(
+ util.COMMON_DIR, "games", "gameitem.qthtml",
+ )
+ with open(template_abs_path, "r") as templatefile:
+ self._template = jinja2.Template(templatefile.read())
+
+ def format(self, title, teams, observers, mods):
+ icon_path = os.path.join("chat", "countries/")
icon_abs_path = os.path.join(util.COMMON_DIR, icon_path)
- return "".format(icon_abs_path)
-
- def _player_fmt(self, player):
- if player == self._me.player:
- pformat = "{}"
- else:
- pformat = "{}"
- player_string = pformat.format(player.login)
- if player.rating_deviation < 200: # FIXME: magic number
- player_string += " ({})".format(player.rating_estimate())
- return player_string
-
- def _observers_tooltip(self, observers):
- if not observers:
- return ""
-
- observer_fmt = "{country_icon} {observer}"
-
- observer_strings = [observer_fmt.format(
- country_icon=self._country_icon_fmt(observer),
- observer=observer.login)
- for observer in observers]
- return "Observers: " + ", ".join(observer_strings)
-
- def _mods_tooltip(self, mods):
- if not mods:
- return ""
- return " With: " + " ".join(mods.values())
-
- def format(self, teams, observers, mods):
- teamtip = self._teams_tooltip(teams)
- obstip = self._observers_tooltip(observers)
- modtip = self._mods_tooltip(mods)
-
- if modtip:
- modtip = " " + modtip
-
- return self.TIP_FORMAT.format(teams=teamtip,
- observers=obstip,
- mods=modtip)
+ return self._template.render(
+ title=title, teams=teams,
+ mods=mods.values(), observers=observers,
+ me=self._me.player,
+ iconpath=icon_abs_path,
+ )
class GameViewBuilder:
diff --git a/src/games/gamemodel.py b/src/games/gamemodel.py
index d2b78f07e..69c07471c 100644
--- a/src/games/gamemodel.py
+++ b/src/games/gamemodel.py
@@ -1,77 +1,35 @@
-from PyQt5.QtCore import QAbstractListModel, Qt, QSortFilterProxyModel, QModelIndex
-from .gamemodelitem import GameModelItem
from enum import Enum
+from PyQt6.QtCore import QSortFilterProxyModel
+from PyQt6.QtCore import Qt
+
from games.moditem import mod_invisible
from model.game import GameState
+from util.qt_list_model import QtListModel
+from .gamemodelitem import GameModelItem
-class GameModel(QAbstractListModel):
- def __init__(self, me, preview_dler, gameset=None):
- QAbstractListModel.__init__(self)
- self._me = me
- self._preview_dler = preview_dler
- self._gameitems = {}
- self._itemlist = [] # For queries
+class GameModel(QtListModel):
+ def __init__(self, me, preview_dler, gameset=None):
+ builder = GameModelItem.builder(me, preview_dler)
+ QtListModel.__init__(self, builder)
self._gameset = gameset
if self._gameset is not None:
- self._gameset.newGame.connect(self.add_game)
+ self._gameset.added.connect(self.add_game)
self._gameset.newClosedGame.connect(self.remove_game)
-
for game in self._gameset.values():
self.add_game(game)
- def rowCount(self, parent):
- if parent.isValid():
- return 0
- return len(self._itemlist)
-
- def data(self, index, role):
- if not index.isValid() or index.row() >= len(self._itemlist):
- return None
- if role != Qt.DisplayRole:
- return None
- return self._itemlist[index.row()]
-
- # TODO - insertion and removal are O(n). Server bandwidth would probably
- # become a bigger issue if number of games increased too much though.
-
def add_game(self, game):
- assert game.uid not in self._gameitems
-
- next_index = len(self._itemlist)
- self.beginInsertRows(QModelIndex(), next_index, next_index)
-
- item = GameModelItem(game, self._me, self._preview_dler)
- item.updated.connect(self._at_item_updated)
-
- self._gameitems[game.uid] = item
- self._itemlist.append(item)
-
- self.endInsertRows()
+ self._add_item(game, game.uid)
def remove_game(self, game):
- assert game.uid in self._gameitems
-
- item = self._gameitems[game.uid]
- item_index = self._itemlist.index(item)
- self.beginRemoveRows(QModelIndex(), item_index, item_index)
-
- item.updated.disconnect(self._at_item_updated)
- del self._gameitems[game.uid]
- self._itemlist.pop(item_index)
- self.endRemoveRows()
+ self._remove_item(game.uid)
def clear_games(self):
- for data in list(self._gameitems.values()):
- self.remove_game(data.game)
-
- def _at_item_updated(self, item):
- item_index = self._itemlist.index(item)
- index = self.index(item_index, 0)
- self.dataChanged.emit(index, index)
+ self._clear_items()
class GameSortModel(QSortFilterProxyModel):
@@ -90,8 +48,8 @@ def __init__(self, me, model):
self.sort(0)
def lessThan(self, leftIndex, rightIndex):
- left = self.sourceModel().data(leftIndex, Qt.DisplayRole).game
- right = self.sourceModel().data(rightIndex, Qt.DisplayRole).game
+ 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]
@@ -105,7 +63,10 @@ def lessThan(self, leftIndex, rightIndex):
def _lt_friend(self, left, right):
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._me.isFriend(hostl) and not self._me.isFriend(hostr)
+ return (
+ self._me.relations.model.is_friend(hostl)
+ and not self._me.relations.model.is_friend(hostr)
+ )
def _lt_type(self, left, right):
stype = self._sort_type
diff --git a/src/games/gamemodelitem.py b/src/games/gamemodelitem.py
index 20ae3dcf4..5eab67fa3 100644
--- a/src/games/gamemodelitem.py
+++ b/src/games/gamemodelitem.py
@@ -1,5 +1,7 @@
-from PyQt5.QtCore import QObject, pyqtSignal
-from downloadManager import PreviewDownloadRequest
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+from downloadManager import DownloadRequest
from fa import maps
@@ -14,23 +16,31 @@ def __init__(self, game, me, preview_dler):
QObject.__init__(self)
self.game = game
- self.game.gameUpdated.connect(self._game_updated)
+ self.game.updated.connect(self._game_updated)
self._me = me
- self._me.relationsUpdated.connect(self._check_host_relation_changed)
+ self._me.relations.trackers.players.updated.connect(
+ self._host_relation_changed,
+ )
+ self._me.clan_changed.connect(self._host_relation_changed)
self._preview_dler = preview_dler
- self._preview_dl_request = PreviewDownloadRequest()
+ self._preview_dl_request = DownloadRequest()
self._preview_dl_request.done.connect(self._at_preview_downloaded)
+ @classmethod
+ def builder(cls, me, preview_dler):
+ def build(game):
+ return cls(game, me, preview_dler)
+ return build
+
def _game_updated(self):
self.updated.emit(self)
self._download_preview_if_needed()
- def _check_host_relation_changed(self, players):
+ def _host_relation_changed(self):
# This should never happen bar server screwups.
if self.game.host_player is None:
return
- if self.game.host_player.id in players:
- self.updated.emit(self)
+ self.updated.emit(self)
def _download_preview_if_needed(self):
if self.game.mapname is None:
diff --git a/src/games/hostgamewidget.py b/src/games/hostgamewidget.py
index e9a44d2c6..99c1dabf0 100644
--- a/src/games/hostgamewidget.py
+++ b/src/games/hostgamewidget.py
@@ -1,13 +1,17 @@
-from PyQt5 import QtCore
-import modvault
+import logging
+
+from PyQt6 import QtCore
-from fa import maps
-import util
import fa.check
-from model.game import Game, GameState, GameVisibility
+import games.mapgenoptionsdialog as MapGenDialog
+import util
+import vaults.modvault.utils
+from fa import maps
from games.gamemodel import GameModel
+from model.game import Game
+from model.game import GameState
+from model.game import GameVisibility
-import logging
logger = logging.getLogger(__name__)
FormClass, BaseClass = util.THEME.loadUiType("games/host.ui")
@@ -46,37 +50,50 @@ def _build_hosted_game(self, main_mod, mapname=None):
map_file_path="", # Mock
teams={1: [host]},
featured_mod=main_mod,
- featured_mod_versions={},
sim_mods={},
password_protected=False, # Filled in later
- visibility=(GameVisibility.FRIENDS if friends_only
- else GameVisibility.PUBLIC)
- )
+ visibility=(
+ GameVisibility.FRIENDS
+ if friends_only
+ else GameVisibility.PUBLIC
+ ),
+ )
def host_game(self, title, main_mod, mapname=None):
game = self._build_hosted_game(main_mod, mapname)
self._game_widget.setup(title, game)
- return self._game_widget.exec_()
+
+ mapname = util.settings.value("fa.games/gamemap", None)
+ if mapname is not None:
+ self._game_widget.set_map(mapname)
+
+ return self._game_widget.exec()
def _launch_game(self, game, password, mods):
- # Make sure the binaries are all up to date, and abort if the update fails or is cancelled.
+ # Make sure the binaries are all up to date, and abort if the update
+ # fails or is cancelled.
if not fa.check.game(self._client):
return
- # Ensure all mods are up-to-date, and abort if the update process fails.
+ # Ensure all mods are up-to-date, and abort if the update process
+ # fails.
if not fa.check.check(game.featured_mod):
return
- if (game.featured_mod == "coop"
- and not fa.check.map_(game.mapname, force=True)):
+ if (
+ game.featured_mod == "coop"
+ and not fa.check.map_(game.mapname, force=True)
+ ):
return
- modvault.utils.setActiveMods(mods, True, False)
+ vaults.modvault.utils.setActiveMods(mods, True, False)
- self._client.host_game(title=game.title,
- mod=game.featured_mod,
- visibility=game.visibility.value,
- mapname=game.mapname,
- password=password)
+ self._client.host_game(
+ title=game.title,
+ mod=game.featured_mod,
+ visibility=game.visibility.value,
+ mapname=game.mapname,
+ password=password,
+ )
class HostGameWidget(FormClass, BaseClass):
@@ -86,20 +103,28 @@ def __init__(self, client, gameview_builder, preview_model):
BaseClass.__init__(self, client)
self.setupUi(self)
- self.client = client
+ self.client = client # type - ClientWindow
self.game = None
self._preview_model = preview_model
- self.game_preview_logic = gameview_builder(preview_model,
- self.gamePreview)
+ self.game_preview_logic = gameview_builder(
+ preview_model, self.gamePreview,
+ )
+ self.mods = {}
- self.setStyleSheet(self.client.styleSheet())
+ util.THEME.stylesheets_reloaded.connect(self.load_stylesheet)
+ self.load_stylesheet()
self.mapList.currentIndexChanged.connect(self.map_changed)
self.hostButton.released.connect(self.hosting)
+ self.generateButton.released.connect(self.generateMap)
self.titleEdit.textChanged.connect(self.update_text)
self.passCheck.toggled.connect(self.update_pass_check)
self.radioFriends.toggled.connect(self.update_visibility)
+ def load_stylesheet(self):
+ self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
+
def setup(self, title, game):
+ self._reset()
self.game = game
self.password = util.settings.value("fa.games/password", "")
@@ -109,18 +134,57 @@ def setup(self, title, game):
self.passEdit.setText(self.password)
self.passCheck.setChecked(self.game.password_protected)
self.radioFriends.setChecked(
- self.game.visibility == GameVisibility.FRIENDS)
+ self.game.visibility == GameVisibility.FRIENDS,
+ )
- self._preview_model.clear_games()
self._preview_model.add_game(self.game)
+ self.setupMapList()
+
+ # this makes it so you can select every non-ui_only mod
+ for mod in vaults.modvault.utils.getInstalledMods():
+ if mod.ui_only:
+ continue
+ self.mods[mod.totalname] = mod
+ self.modList.addItem(mod.totalname)
+
+ names = [
+ mod.totalname
+ for mod in vaults.modvault.utils.getActiveMods(
+ uimods=False,
+ temporary=False,
+ )
+ ]
+ logger.debug("Active Mods detected: {}".format(str(names)))
+ for name in names:
+ ml = self.modList.findItems(name, QtCore.Qt.MatchFlag.MatchExactly.MatchExactly)
+ logger.debug("found item: {}".format(ml[0].text()))
+ if ml:
+ ml[0].setSelected(True)
+
+ def _reset(self):
+ self._preview_model.clear_games()
+ self.mapList.clear()
+ self.mods.clear()
+ self.modList.clear()
+
+ def setupMapList(self):
+ '''
+ Need this as separate function so it can be called after generateMap()
+ '''
+ self.mapList.clear()
+
+ game = self.game
i = 0
index = 0
if game.featured_mod != "coop":
allmaps = {}
for map_ in list(maps.maps.keys()) + maps.getUserMaps():
allmaps[map_] = maps.getDisplayName(map_)
- for (map_, name) in sorted(iter(allmaps.items()), key=lambda x: x[1]):
+ for (map_, name) in sorted(
+ iter(allmaps.items()),
+ key=lambda x: x[1],
+ ):
if map_ == game.mapname:
index = i
self.mapList.addItem(name, map_)
@@ -129,21 +193,11 @@ def setup(self, title, game):
else:
self.mapList.hide()
- self.mods = {}
- # this makes it so you can select every non-ui_only mod
- for mod in modvault.utils.getInstalledMods():
- if mod.ui_only:
- continue
- self.mods[mod.totalname] = mod
- self.modList.addItem(mod.totalname)
-
- names = [mod.totalname for mod in modvault.utils.getActiveMods(uimods=False, temporary=False)]
- logger.debug("Active Mods detected: %s" % str(names))
- for name in names:
- ml = self.modList.findItems(name, QtCore.Qt.MatchExactly)
- logger.debug("found item: %s" % ml[0].text())
- if ml:
- ml[0].setSelected(True)
+ def set_map(self, mapname):
+ for i in range(self.mapList.count()):
+ if self.mapList.itemData(i) == mapname:
+ self.mapList.setCurrentIndex(i)
+ return
def update_text(self, text):
self.game.update(title=text.strip())
@@ -152,8 +206,13 @@ def update_pass_check(self, checked):
self.game.update(password_protected=checked)
def update_visibility(self, friends):
- self.game.update(visibility=(GameVisibility.FRIENDS if friends
- else GameVisibility.PUBLIC))
+ self.game.update(
+ visibility=(
+ GameVisibility.FRIENDS
+ if friends
+ else GameVisibility.PUBLIC
+ ),
+ )
def map_changed(self, index):
mapname = self.mapList.itemData(index)
@@ -170,9 +229,12 @@ def hosting(self):
self.save_last_hosted_settings(password)
- modnames = [str(moditem.text()) for moditem in self.modList.selectedItems()]
+ modnames = [
+ str(moditem.text())
+ for moditem in self.modList.selectedItems()
+ ]
mods = [self.mods[modstr] for modstr in modnames]
- modvault.utils.setActiveMods(mods, True, False)
+ vaults.modvault.utils.setActiveMods(mods, True, False)
self.launch.emit(self.game, password, mods)
self.done(1)
@@ -189,6 +251,11 @@ def save_last_hosted_settings(self, password):
util.settings.setValue("password", self.password)
util.settings.endGroup()
+ @QtCore.pyqtSlot()
+ def generateMap(self):
+ dialog = MapGenDialog.MapGenDialog(self)
+ dialog.exec()
+
def build_launcher(playerset, me, client, view_builder, map_preview_dler):
model = GameModel(me, map_preview_dler)
diff --git a/src/games/mapgenoptionsdialog.py b/src/games/mapgenoptionsdialog.py
new file mode 100644
index 000000000..faac64ab3
--- /dev/null
+++ b/src/games/mapgenoptionsdialog.py
@@ -0,0 +1,304 @@
+from enum import Enum
+
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
+
+import config
+import util
+
+FormClass, BaseClass = util.THEME.loadUiType("games/mapgen.ui")
+
+
+class MapStyle(Enum):
+ RANDOM = "RANDOM"
+ DEFAULT = "DEFAULT"
+ ONE_ISLAND = "ONE_ISLAND"
+ BIG_ISLANDS = "BIG_ISLANDS"
+ SMALL_ISLANDS = "SMALL_ISLANDS"
+ CENTER_LAKE = "CENTER_LAKE"
+ VALLEY = "VALLEY"
+ DROP_PLATEAU = "DROP_PLATEAU"
+ LITTLE_MOUNTAIN = "LITTLE_MOUNTAIN"
+ MOUNTAIN_RANGE = "MOUNTAIN_RANGE"
+ LAND_BRIDGE = "LAND_BRIDGE"
+ LOW_MEX = "LOW_MEX"
+ FLOODED = "FLOODED"
+
+ def getMapStyle(index):
+ return list(MapStyle)[index]
+
+
+class MapGenDialog(FormClass, BaseClass):
+ def __init__(self, parent, *args, **kwargs):
+ BaseClass.__init__(self, *args, **kwargs)
+
+ self.setupUi(self)
+
+ util.THEME.stylesheets_reloaded.connect(self.load_stylesheet)
+ self.load_stylesheet()
+
+ self.parent = parent
+
+ self.generationType.currentIndexChanged.connect(
+ self.generationTypeChanged,
+ )
+ self.numberOfSpawns.currentIndexChanged.connect(
+ self.numberOfSpawnsChanged,
+ )
+ self.mapSize.valueChanged.connect(self.mapSizeChanged)
+ self.mapStyle.currentIndexChanged.connect(self.mapStyleChanged)
+ self.generateMapButton.clicked.connect(self.generateMap)
+ self.saveMapGenSettingsButton.clicked.connect(self.saveMapGenPrefs)
+ self.resetMapGenSettingsButton.clicked.connect(self.resetMapGenPrefs)
+
+ self.random_buttons = [
+ self.landRandomDensity,
+ self.plateausRandomDensity,
+ self.mountainsRandomDensity,
+ self.rampsRandomDensity,
+ self.mexRandomDensity,
+ self.reclaimRandomDensity,
+ ]
+ self.sliders = [
+ self.landDensity,
+ self.plateausDensity,
+ self.mountainsDensity,
+ self.rampsDensity,
+ self.mexDensity,
+ self.reclaimDensity,
+ ]
+
+ self.option_frames = [
+ self.landOptions,
+ self.plateausOptions,
+ self.mountainsOptions,
+ self.rampsOptions,
+ self.mexOptions,
+ self.reclaimOptions,
+ ]
+
+ for random_button in self.random_buttons:
+ random_button.setChecked(
+ config.Settings.get(
+ "mapGenerator/{}".format(random_button.objectName()),
+ type=bool,
+ default=True,
+ ),
+ )
+ random_button.toggled.connect(self.configOptionFrames)
+
+ for slider in self.sliders:
+ slider.setValue(
+ config.Settings.get(
+ "mapGenerator/{}".format(slider.objectName()),
+ type=int,
+ default=0,
+ ),
+ )
+
+ self.generation_type = "casual"
+ self.number_of_spawns = 2
+ self.map_size = 256
+ self.map_style = MapStyle.RANDOM
+ self.generationType.setCurrentIndex(
+ config.Settings.get(
+ "mapGenerator/generationTypeIndex", type=int, default=0,
+ ),
+ )
+ self.numberOfSpawns.setCurrentIndex(
+ config.Settings.get(
+ "mapGenerator/numberOfSpawnsIndex", type=int, default=0,
+ ),
+ )
+ self.mapSize.setValue(
+ config.Settings.get(
+ "mapGenerator/mapSize", type=float, default=5.0,
+ ),
+ )
+ self.mapStyle.setCurrentIndex(
+ config.Settings.get(
+ "mapGenerator/mapStyleIndex", type=int, default=0,
+ ),
+ )
+
+ self.configOptionFrames()
+
+ def load_stylesheet(self):
+ self.setStyleSheet(util.THEME.readstylesheet("client/client.css"))
+
+ def keyPressEvent(self, event):
+ if (
+ event.key() == QtCore.Qt.Key.Key_Enter
+ or event.key() == QtCore.Qt.Key.Key_Return
+ ):
+ return
+ QtWidgets.QDialog.keyPressEvent(self, event)
+
+ @QtCore.pyqtSlot(int)
+ def numberOfSpawnsChanged(self, index):
+ self.number_of_spawns = 2 * (index + 1)
+
+ @QtCore.pyqtSlot(float)
+ def mapSizeChanged(self, value):
+ if (value % 1.25):
+ # nearest to multiple of 1.25
+ value = ((value + 0.625) // 1.25) * 1.25
+ self.mapSize.blockSignals(True)
+ self.mapSize.setValue(value)
+ self.mapSize.blockSignals(False)
+ self.map_size = int(value * 51.2)
+
+ @QtCore.pyqtSlot(int)
+ def generationTypeChanged(self, index):
+ if index == -1 or index == 0:
+ self.generation_type = "casual"
+ elif index == 1:
+ self.generation_type = "tournament"
+ elif index == 2:
+ self.generation_type = "blind"
+ elif index == 3:
+ self.generation_type = "unexplored"
+
+ if index == -1 or index == 0:
+ self.mapStyle.setEnabled(True)
+ self.mapStyle.setCurrentIndex(
+ config.Settings.get(
+ "mapGenerator/mapStyleIndex", type=int, default=0,
+ ),
+ )
+ else:
+ self.mapStyle.setEnabled(False)
+ self.mapStyle.setCurrentIndex(0)
+
+ self.checkRandomButtons()
+
+ @QtCore.pyqtSlot(int)
+ def mapStyleChanged(self, index):
+ if index == -1 or index == 0:
+ self.map_style = MapStyle.RANDOM
+ else:
+ self.map_style = MapStyle.getMapStyle(index)
+
+ self.checkRandomButtons()
+
+ @QtCore.pyqtSlot()
+ def checkRandomButtons(self):
+ for random_button in self.random_buttons:
+ if (
+ self.generation_type != "casual"
+ or self.map_style != MapStyle.RANDOM
+ ):
+ random_button.setEnabled(False)
+ random_button.setChecked(True)
+ else:
+ random_button.setEnabled(True)
+ random_button.setChecked(
+ config.Settings.get(
+ "mapGenerator/{}".format(random_button.objectName()),
+ type=bool,
+ default=True,
+ ),
+ )
+
+ @QtCore.pyqtSlot()
+ def configOptionFrames(self):
+ for random_button in self.random_buttons:
+ option_frame = self.option_frames[
+ self.random_buttons.index(random_button)
+ ]
+ if random_button.isChecked():
+ option_frame.setEnabled(False)
+ else:
+ option_frame.setEnabled(True)
+
+ @QtCore.pyqtSlot()
+ def saveMapGenPrefs(self):
+ config.Settings.set(
+ "mapGenerator/generationTypeIndex",
+ self.generationType.currentIndex(),
+ )
+ config.Settings.set(
+ "mapGenerator/mapSize",
+ self.mapSize.value(),
+ )
+ config.Settings.set(
+ "mapGenerator/numberOfSpawnsIndex",
+ self.numberOfSpawns.currentIndex(),
+ )
+ config.Settings.set(
+ "mapGenerator/mapStyleIndex",
+ self.mapStyle.currentIndex(),
+ )
+ for random_button in self.random_buttons:
+ config.Settings.set(
+ "mapGenerator/{}".format(random_button.objectName()),
+ random_button.isChecked(),
+ )
+ for slider in self.sliders:
+ config.Settings.set(
+ "mapGenerator/{}".format(slider.objectName()), slider.value(),
+ )
+ self.done(1)
+
+ @QtCore.pyqtSlot()
+ def resetMapGenPrefs(self):
+ self.generationType.setCurrentIndex(0)
+ self.mapSize.setValue(5.0)
+ self.numberOfSpawns.setCurrentIndex(0)
+ self.mapStyle.setCurrentIndex(0)
+
+ for random_button in self.random_buttons:
+ random_button.setChecked(True)
+ for slider in self.sliders:
+ slider.setValue(0)
+
+ @QtCore.pyqtSlot()
+ def generateMap(self):
+ map_ = self.parent.client.map_generator.generateMap(
+ args=self.setArguments(),
+ )
+ if map_:
+ self.parent.setupMapList()
+ self.parent.set_map(map_)
+ self.saveMapGenPrefs()
+
+ def setArguments(self):
+ args = []
+ args.append("--map-size")
+ args.append(str(self.map_size))
+ args.append("--spawn-count")
+ args.append(str(self.number_of_spawns))
+
+ if self.map_style != MapStyle.RANDOM:
+ args.append("--style")
+ args.append(self.map_style.value)
+ else:
+ if self.generation_type == "tournament":
+ args.append("--tournament-style")
+ elif self.generation_type == "blind":
+ args.append("--blind")
+ elif self.generation_type == "unexplored":
+ args.append("--unexplored")
+
+ slider_args = [
+ ["--land-density", None],
+ ["--plateau-density", None],
+ ["--mountain-density", None],
+ ["--ramp-density", None],
+ ["--mex-density", None],
+ ["--reclaim-density", None],
+ ]
+ for index, slider in enumerate(self.sliders):
+ if slider.isEnabled():
+ if slider == self.landDensity:
+ value = float(1 - (slider.value() / 127))
+ else:
+ value = float(slider.value() / 127)
+ slider_args[index][1] = value
+
+ for arg_key, arg_value in slider_args:
+ if arg_value is not None:
+ args.append(arg_key)
+ args.append(str(arg_value))
+
+ return args
diff --git a/src/games/moditem.py b/src/games/moditem.py
index b61b9bf0a..7142083f1 100644
--- a/src/games/moditem.py
+++ b/src/games/moditem.py
@@ -1,8 +1,12 @@
-from PyQt5 import QtWidgets, QtGui
-import util
-import client
import os
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+
+import client
+import util
+from api.models.FeaturedMod import FeaturedMod
+
# Maps names of featured mods to ModItem objects.
mods = {}
@@ -15,27 +19,26 @@
class ModItem(QtWidgets.QListWidgetItem):
- def __init__(self, message, *args, **kwargs):
- QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
+ def __init__(self, mod_info: FeaturedMod, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
- self.mod = message["name"]
- self.order = message.get("order", 0)
- self.name = message["fullname"]
+ self.mod = mod_info.name
+ self.order = mod_info.order
+ self.name = mod_info.fullname
# Load Icon and Tooltip
- tip = message["desc"]
- self.setToolTip(tip)
+ self.setToolTip(mod_info.description)
- icon = util.THEME.icon(os.path.join("games/mods/", self.mod + ".png"))
+ icon = util.THEME.icon(os.path.join("games/mods/", f"{self.mod}.png"))
if icon.isNull():
icon = util.THEME.icon("games/mods/default.png")
self.setIcon(icon)
if self.mod in mod_crucial:
- color = client.instance.player_colors.getColor("self")
+ color = client.instance.player_colors.get_color("self")
else:
- color = client.instance.player_colors.getColor("player")
-
+ color = client.instance.player_colors.get_color("player")
+
self.setForeground(QtGui.QColor(color))
self.setText(self.name)
@@ -53,6 +56,6 @@ def __lt__(self, other):
if self.order == other.order:
# Default: Alphabetical
- return self.name.lower() < other.mod.lower()
+ return self.name.lower() < other.name.lower()
return self.order < other.order
diff --git a/src/mapGenerator/__init__.py b/src/mapGenerator/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mapGenerator/mapgenManager.py b/src/mapGenerator/mapgenManager.py
new file mode 100644
index 000000000..baef51ea8
--- /dev/null
+++ b/src/mapGenerator/mapgenManager.py
@@ -0,0 +1,189 @@
+import logging
+import os
+import random
+
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
+from PyQt6.QtCore import QUrl
+from PyQt6.QtCore import pyqtSignal
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
+
+import util
+from config import Settings
+from fa.maps import getUserMapsFolder
+from mapGenerator.mapgenProcess import MapGeneratorProcess
+from mapGenerator.mapgenUtils import generatedMapPattern
+from vaults.dialogs import download_file
+
+logger = logging.getLogger(__name__)
+
+RELEASE_URL = "https://github.com/FAForever/Neroxis-Map-Generator/releases/"
+RELEASE_VERSION_PATH = "download/{version}/NeroxisGen_{version}.jar"
+GENERATOR_JAR_NAME = "MapGenerator_{}.jar"
+
+
+class MapGeneratorManager(QObject):
+ version_received = pyqtSignal()
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.latestVersion = None
+
+ self.currentVersion = Settings.get('mapGenerator/version', "0", str)
+
+ def generateMap(self, mapname=None, args=None):
+ if mapname is None:
+ '''
+ Requests latest version once per session
+ '''
+ if self.currentVersion == "0" or not self.latestVersion:
+ self.checkUpdates()
+
+ if (
+ self.latestVersion
+ and self.versionController(self.latestVersion)
+ ):
+ # mapgen is up-to-date
+ self.currentVersion = self.latestVersion
+ Settings.set('mapGenerator/version', self.currentVersion)
+
+ # if not "0", use older version, otherwise we don't have any
+ # generator at all
+ elif self.currentVersion == "0":
+ return False
+ version = self.currentVersion
+ args = args
+ else:
+ matcher = generatedMapPattern.match(mapname)
+ version = matcher.group(1)
+ args = ['--map-name', mapname]
+
+ actualPath = self.versionController(version)
+
+ if actualPath:
+ auto = Settings.get(
+ 'mapGenerator/autostart', default=False, type=bool,
+ )
+ if not auto and mapname is not None:
+ msgbox = QtWidgets.QMessageBox()
+ msgbox.setWindowTitle("Generate map")
+ msgbox.setText(
+ "It looks like you don't have the map being used by this "
+ "lobby. Do you want to generate it? {}"
+ .format(mapname),
+ )
+ msgbox.setInformativeText(
+ "Map generation is a CPU intensive task and may take some "
+ "time.",
+ )
+ msgbox.setStandardButtons(
+ QtWidgets.QMessageBox.StandardButton.Yes
+ | QtWidgets.QMessageBox.StandardButton.YesToAll
+ | QtWidgets.QMessageBox.StandardButton.No,
+ )
+ result = msgbox.exec()
+ if result == QtWidgets.QMessageBox.StandardButton.No:
+ return False
+ elif result == QtWidgets.QMessageBox.StandardButton.YesToAll:
+ Settings.set('mapGenerator/autostart', True)
+
+ mapsFolder = getUserMapsFolder()
+ if not os.path.exists(mapsFolder):
+ os.makedirs(mapsFolder)
+
+ # Start generator with progress bar
+ self.generatorProcess = MapGeneratorProcess(
+ actualPath, mapsFolder, args,
+ )
+
+ map_ = self.generatorProcess.mapname
+ # Check if map exists or generator failed
+ if os.path.isdir(os.path.join(mapsFolder, map_)):
+ return map_
+ else:
+ return False
+ else:
+ return False
+
+ def generateRandomMap(self):
+ '''
+ Called when user click "generate map" in host widget.
+ Prepares seed and requests latest version once per session
+ '''
+
+ if self.currentVersion == "0" or not self.latestVersion:
+ self.checkUpdates()
+
+ if (
+ self.latestVersion
+ and self.versionController(self.latestVersion)
+ ):
+ # mapgen is up-to-date
+ self.currentVersion = self.latestVersion
+ Settings.set('mapGenerator/version', self.currentVersion)
+
+ # if not "0", use older version, otherwise we don't have any
+ # generator at all
+ elif self.currentVersion == "0":
+ return False
+
+ seed = random.randint(-9223372036854775808, 9223372036854775807)
+ mapName = "neroxis_map_generator_{}_{}".format(
+ self.currentVersion, seed,
+ )
+
+ return self.generateMap(mapName)
+
+ def versionController(self, version: str) -> str:
+ name = GENERATOR_JAR_NAME.format(version)
+ file_path = os.path.join(util.MAPGEN_DIR, name)
+
+ # Check if required version is already in folder
+ if os.path.isdir(util.MAPGEN_DIR):
+ for infile in os.listdir(util.MAPGEN_DIR):
+ if infile.lower() == name.lower():
+ return file_path
+
+ # Download from github if not
+ url = RELEASE_URL + RELEASE_VERSION_PATH.format(version=version)
+ if download_file(url, util.MAPGEN_DIR, name, "map generator", silent=False):
+ return file_path
+ return ""
+
+ def checkUpdates(self) -> None:
+ '''
+ Not downloading anything here.
+ Just requesting latest version and return the number
+ '''
+ self.manager = QNetworkAccessManager()
+ self.manager.finished.connect(self.on_request_finished)
+
+ request = QNetworkRequest(QUrl(RELEASE_URL).resolved(QUrl("latest")))
+ self.manager.get(request)
+
+ progress = QtWidgets.QProgressDialog()
+ progress.setCancelButtonText("Cancel")
+ progress.setWindowFlags(Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint)
+ progress.setAutoClose(False)
+ progress.setAutoReset(False)
+ progress.setMinimum(0)
+ progress.setMaximum(0)
+ progress.setValue(0)
+ progress.setModal(1)
+ progress.setWindowTitle("Looking for updates")
+ progress.show()
+
+ loop = QEventLoop()
+ self.version_received.connect(loop.quit)
+ loop.exec()
+ progress.close()
+
+ def on_request_finished(self, reply: QNetworkReply) -> None:
+ redirect_url = reply.url()
+ if "releases/tag/" in redirect_url.toString():
+ self.latestVersion = redirect_url.fileName()
+ self.version_received.emit()
diff --git a/src/mapGenerator/mapgenProcess.py b/src/mapGenerator/mapgenProcess.py
new file mode 100644
index 000000000..8eb56db90
--- /dev/null
+++ b/src/mapGenerator/mapgenProcess.py
@@ -0,0 +1,125 @@
+import logging
+import re
+
+from PyQt6.QtCore import QEventLoop
+from PyQt6.QtCore import QProcess
+from PyQt6.QtCore import Qt
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtWidgets import QMessageBox
+from PyQt6.QtWidgets import QProgressDialog
+
+import fafpath
+from config import setup_file_handler
+
+from . import mapgenUtils
+
+logger = logging.getLogger(__name__)
+# Separate log file for map generator
+generatorLogger = logging.getLogger(__name__)
+generatorLogger.propagate = False
+generatorLogger.addHandler(setup_file_handler('map_generator.log'))
+
+
+class MapGeneratorProcess(object):
+ def __init__(self, gen_path, out_path, args):
+ self._progress = QProgressDialog()
+ self._progress.setWindowTitle("Generating map, please wait...")
+ self._progress.setCancelButtonText("Cancel")
+ self._progress.setWindowFlags(
+ Qt.WindowType.CustomizeWindowHint | Qt.WindowType.WindowTitleHint,
+ )
+ self._progress.setAutoReset(False)
+ self._progress.setModal(1)
+ self._progress.setMinimum(0)
+ self._progress.setMaximum(30)
+ self._progress.canceled.connect(self.close)
+ self.progressCounter = 1
+
+ self.map_generator_process = QProcess()
+ self.map_generator_process.setWorkingDirectory(out_path)
+ self.map_generator_process.readyReadStandardOutput.connect(
+ self.on_log_ready,
+ )
+ self.map_generator_process.readyReadStandardError.connect(
+ self.on_error_ready,
+ )
+ self.map_generator_process.finished.connect(self.on_exit)
+ self.map_name = None
+
+ self.java_path = fafpath.get_java_path()
+ self.args = ["-jar", gen_path]
+ self.args.extend(args)
+
+ logger.info(
+ "Starting map generator with {} {}"
+ .format(self.java_path, " ".join(self.args)),
+ )
+ generatorLogger.info(">>> --------------------- MapGenerator Launch")
+
+ self.map_generator_process.start(self.java_path, self.args)
+
+ if not self.map_generator_process.waitForStarted(5000):
+ logger.error("error starting the map generator process")
+ QMessageBox.critical(
+ None, "Map generator error",
+ "The map generator did not start.",
+ )
+ else:
+ self._progress.show()
+ self._running = True
+ self.waitForCompletion()
+
+ @property
+ def mapname(self):
+ return str(self.map_name)
+
+ def on_log_ready(self):
+ standard_output = self.map_generator_process.readAllStandardOutput()
+ data = standard_output.data().decode('utf8').split('\n')
+ for line in data:
+ if (
+ re.match(mapgenUtils.generatedMapPattern, line)
+ and self.map_name is None
+ ):
+ self.map_name = line.strip()
+ if line != '':
+ generatorLogger.info(line.strip())
+ # Kinda fake progress bar. Better than nothing :)
+ if len(line) > 4:
+ self._progress.setLabelText(line[:25] + "...")
+ self.progressCounter += 1
+ self._progress.setValue(self.progressCounter)
+
+ def on_error_ready(self):
+ standard_error = str(self.map_generator_process.readAllStandardError())
+ for line in standard_error.splitlines():
+ generatorLogger.error("Error: " + line)
+ self.close()
+ QMessageBox.critical(
+ None,
+ "Map generator error",
+ "Something went wrong. Probably because of bad combination of "
+ "generator options. Please retry with different options",
+ )
+
+ def on_exit(self, code, status):
+ self._progress.reset()
+ self._running = False
+ generatorLogger.info("<<< --------------------- MapGenerator Shutdown")
+
+ def close(self):
+ if self.map_generator_process.state() == QProcess.ProcessState.Running:
+ logger.info("Waiting for map generator process shutdown")
+ if not self.map_generator_process.waitForFinished(300):
+ if self.map_generator_process.state() == QProcess.ProcessState.Running:
+ logger.error("Terminating map generator process")
+ self.map_generator_process.terminate()
+ if not self.map_generator_process.waitForFinished(300):
+ logger.error("Killing map generator process")
+ self.map_generator_process.kill()
+
+ def waitForCompletion(self) -> None:
+ '''Copied from downloadManager. I hope it's ok :)'''
+ waitFlag = QEventLoop.ProcessEventsFlag.WaitForMoreEvents
+ while self._running:
+ QApplication.processEvents(waitFlag)
diff --git a/src/mapGenerator/mapgenUtils.py b/src/mapGenerator/mapgenUtils.py
new file mode 100644
index 000000000..d6160fe0c
--- /dev/null
+++ b/src/mapGenerator/mapgenUtils.py
@@ -0,0 +1,14 @@
+import re
+
+versionPattern = re.compile("\\d\\d?\\d?\\.\\d\\d?\\d?\\.\\d\\d?\\d?")
+generatedMapPattern = re.compile(
+ "neroxis_map_generator_({})_(.*)".format(versionPattern.pattern),
+)
+
+
+def isGeneratedMap(name):
+ '''
+ Can't even place it in mapgenManager file outside object as separate
+ function without getting import errors on start
+ '''
+ return re.match(generatedMapPattern, name)
diff --git a/src/model/chat/__init__.py b/src/model/chat/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/model/chat/channel.py b/src/model/chat/channel.py
new file mode 100644
index 000000000..25c4d0942
--- /dev/null
+++ b/src/model/chat/channel.py
@@ -0,0 +1,103 @@
+from enum import Enum
+
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+from model.modelitem import ModelItem
+from model.transaction import transactional
+
+PARTY_CHANNEL_SUFFIX = "'sParty"
+
+
+class ChannelType(Enum):
+ PUBLIC = 1
+ PRIVATE = 2
+
+
+class ChannelID:
+ def __init__(self, type_, name):
+ self.type = type_
+ self.name = name
+
+ def __eq__(self, other):
+ return self.type == other.type and self.name == other.name
+
+ def __hash__(self):
+ return hash((self.name, self.type))
+
+ @classmethod
+ def private_cid(cls, name):
+ return cls(ChannelType.PRIVATE, name)
+
+
+class Lines(QObject):
+ added = pyqtSignal()
+ removed = pyqtSignal(int)
+
+ def __init__(self):
+ QObject.__init__(self)
+ self._lines = []
+
+ def add_line(self, line):
+ self._lines.append(line)
+ self.added.emit()
+
+ def remove_lines(self, number):
+ number = min(number, len(self))
+ if number < 0:
+ raise ValueError
+ if number == 0:
+ return
+ del self._lines[0:number]
+ self.removed.emit(number)
+
+ def __getitem__(self, n):
+ return self._lines[n]
+
+ def __iter__(self):
+ return iter(self._lines)
+
+ def __len__(self):
+ return len(self._lines)
+
+
+class Channel(ModelItem):
+ added_chatter = pyqtSignal(object)
+ removed_chatter = pyqtSignal(object)
+
+ def __init__(self, id_, lines, topic, is_base=False):
+ ModelItem.__init__(self)
+ self.add_field("topic", topic)
+ self.add_field("is_base", is_base)
+ self.lines = lines
+ self.id = id_
+ self.chatters = {}
+
+ @property
+ def id_key(self):
+ return self.id
+
+ def copy(self):
+ return Channel(self.id, self.lines, **self.field_dict)
+
+ @transactional
+ def update(self, **kwargs):
+ _transaction = kwargs.pop("_transaction")
+
+ old = self.copy()
+ ModelItem.update(self, **kwargs)
+ self.emit_update(old, _transaction)
+
+ @transactional
+ def set_topic(self, topic, _transaction=None):
+ self.update(topic=topic, _transaction=_transaction)
+
+ @transactional
+ def add_chatter(self, cc, _transaction=None):
+ self.chatters[cc.id_key] = cc
+ _transaction.emit(self.added_chatter, cc)
+
+ @transactional
+ def remove_chatter(self, cc, _transaction=None):
+ del self.chatters[cc.id_key]
+ _transaction.emit(self.removed_chatter, cc)
diff --git a/src/model/chat/channelchatter.py b/src/model/chat/channelchatter.py
new file mode 100644
index 000000000..73cc89175
--- /dev/null
+++ b/src/model/chat/channelchatter.py
@@ -0,0 +1,34 @@
+from model.modelitem import ModelItem
+from model.transaction import transactional
+
+
+class ChannelChatter(ModelItem):
+ MOD_ELEVATIONS = "~&@%+"
+
+ def __init__(self, channel, chatter, elevation):
+ ModelItem.__init__(self)
+ self.channel = channel
+ self.chatter = chatter
+ self.add_field("elevation", elevation)
+
+ @property
+ def id_key(self):
+ return (self.channel.id_key, self.chatter.id_key)
+
+ def copy(self):
+ return ChannelChatter(self.channel, self.chatter, **self.field_dict)
+
+ @transactional
+ def update(self, **kwargs):
+ _transaction = kwargs.pop("_transaction")
+ old = self.copy()
+ ModelItem.update(self, **kwargs)
+ self.emit_update(old, _transaction)
+
+ @transactional
+ def set_elevation(self, value, _transaction=None):
+ self.update(elevation=value, _transaction=_transaction)
+
+ def is_mod(self):
+ e = self.elevation
+ return e != '' and e in self.MOD_ELEVATIONS
diff --git a/src/model/chat/channelchatterset.py b/src/model/chat/channelchatterset.py
new file mode 100644
index 000000000..d1c76e62f
--- /dev/null
+++ b/src/model/chat/channelchatterset.py
@@ -0,0 +1,79 @@
+from model.modelitemset import ModelItemSet
+from model.transaction import transactional
+
+
+class ChannelChatterset(ModelItemSet):
+ def __init__(self):
+ ModelItemSet.__init__(self)
+
+ @transactional
+ def set_item(self, key, cc, _transaction=None):
+ ModelItemSet.set_item(self, key, cc, _transaction)
+ self.emit_added(cc, _transaction)
+
+ @transactional
+ def del_item(self, key, _transaction=None):
+ chatter = ModelItemSet.del_item(self, key, _transaction)
+ if chatter is None:
+ return
+ self.emit_removed(chatter, _transaction)
+
+
+class ChatterChannelIndex:
+ def __init__(self):
+ self._by_channel = {}
+ self._by_chatter = {}
+
+ def ccs_by_chatter(self, chatter):
+ return self._by_chatter.setdefault(chatter.id_key, set())
+
+ def ccs_by_channel(self, channel):
+ return self._by_channel.setdefault(channel.id_key, set())
+
+ def add_cc(self, cc):
+ self.ccs_by_chatter(cc.chatter).add(cc)
+ self.ccs_by_channel(cc.channel).add(cc)
+
+ def remove_cc(self, cc):
+ chat_ccs = self.ccs_by_chatter(cc.chatter)
+ chat_ccs.remove(cc)
+ if not chat_ccs:
+ del self._by_chatter[cc.chatter.id_key]
+
+ chan_ccs = self.ccs_by_channel(cc.channel)
+ chan_ccs.remove(cc)
+ if not chan_ccs:
+ del self._by_channel[cc.channel.id_key]
+
+
+class ChannelChatterRelation:
+ def __init__(self, channels, chatters, channelchatters):
+ self._channels = channels
+ self._chatters = chatters
+ self._channelchatters = channelchatters
+ self._index = ChatterChannelIndex()
+
+ self._channelchatters.before_added.connect(self._new_cc)
+ self._channelchatters.before_removed.connect(self._removed_cc)
+ self._chatters.before_removed.connect(self._removed_chatter)
+ self._channels.before_removed.connect(self._removed_channel)
+
+ def _new_cc(self, cc, _transaction=None):
+ self._index.add_cc(cc)
+ cc.channel.add_chatter(cc, _transaction)
+ cc.chatter.add_channel(cc, _transaction)
+
+ def _removed_cc(self, cc, _transaction=None):
+ self._index.remove_cc(cc)
+ cc.channel.remove_chatter(cc, _transaction)
+ cc.chatter.remove_channel(cc, _transaction)
+
+ def _removed_chatter(self, chatter, _transaction):
+ ccs = set(self._index.ccs_by_chatter(chatter))
+ for cc in ccs:
+ self._channelchatters.del_item(cc.id_key, _transaction)
+
+ def _removed_channel(self, channel, _transaction):
+ ccs = set(self._index.ccs_by_channel(channel))
+ for cc in ccs:
+ self._channelchatters.del_item(cc.id_key, _transaction)
diff --git a/src/model/chat/channelset.py b/src/model/chat/channelset.py
new file mode 100644
index 000000000..1fc62394e
--- /dev/null
+++ b/src/model/chat/channelset.py
@@ -0,0 +1,30 @@
+from model.chat.channel import ChannelType
+from model.modelitemset import ModelItemSet
+from model.transaction import transactional
+
+
+class Channelset(ModelItemSet):
+
+ def __init__(self, base_channels):
+ ModelItemSet.__init__(self)
+ self.base_channels = base_channels
+
+ @classmethod
+ def build(cls, base_channels, **kwargs):
+ return cls(base_channels)
+
+ @transactional
+ def set_item(self, key, value, _transaction=None):
+ value.is_base = (
+ key.type == ChannelType.PUBLIC
+ and key.name in self.base_channels
+ )
+ ModelItemSet.set_item(self, key, value, _transaction)
+ self.emit_added(value, _transaction)
+
+ @transactional
+ def del_item(self, key, _transaction=None):
+ channel = ModelItemSet.del_item(self, key, _transaction)
+ if channel is None:
+ return
+ self.emit_removed(channel, _transaction)
diff --git a/src/model/chat/chat.py b/src/model/chat/chat.py
new file mode 100644
index 000000000..7af356e8a
--- /dev/null
+++ b/src/model/chat/chat.py
@@ -0,0 +1,47 @@
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import pyqtSignal
+
+from model.chat.channelchatterset import ChannelChatterRelation
+from model.chat.channelchatterset import ChannelChatterset
+from model.chat.channelset import Channelset
+from model.chat.chatterset import Chatterset
+
+
+class Chat(QObject):
+ new_server_message = pyqtSignal(str)
+ connect_event = pyqtSignal()
+ disconnect_event = pyqtSignal()
+
+ def __init__(self, channelset, chatterset, channelchatterset, cc_relation):
+ QObject.__init__(self)
+ self.channels = channelset
+ self.chatters = chatterset
+ self.channelchatters = channelchatterset
+ self._cc_relation = cc_relation
+ self._connected = False
+
+ @classmethod
+ def build(cls, playerset, **kwargs):
+ channels = Channelset.build(**kwargs)
+ chatters = Chatterset(playerset)
+ channelchatters = ChannelChatterset()
+ cc_relation = ChannelChatterRelation(
+ channels, chatters, channelchatters,
+ )
+ return cls(channels, chatters, channelchatters, cc_relation)
+
+ def add_server_message(self, msg):
+ self.new_server_message.emit(msg)
+
+ # Does not affect model contents, only tells if the user is connected.
+ @property
+ def connected(self):
+ return self._connected
+
+ @connected.setter
+ def connected(self, value):
+ self._connected = value
+ if self._connected:
+ self.connect_event.emit()
+ else:
+ self.disconnect_event.emit()
diff --git a/src/model/chat/chatline.py b/src/model/chat/chatline.py
new file mode 100644
index 000000000..6116a951c
--- /dev/null
+++ b/src/model/chat/chatline.py
@@ -0,0 +1,103 @@
+import time
+from enum import Enum
+
+from util.magic_dict import MagicDict
+
+
+# Notices differ from messages in that notices in public channels are visible
+# only to the user. Due to that, it's important to be able to tell the
+# difference between the two.
+class ChatLineType(Enum):
+ MESSAGE = 0
+ NOTICE = 1
+ ACTION = 2
+ INFO = 3
+ ANNOUNCEMENT = 4
+ RAW = 5
+
+
+class ChatLine:
+ def __init__(self, sender, text, type_, timestamp=None):
+ self.sender = sender
+ self.text = text
+ if timestamp is None:
+ timestamp = time.time()
+ self.time = timestamp
+ self.type = type_
+
+
+class ChatLineMetadata:
+ def __init__(self, line, meta):
+ self.line = line
+ self.meta = meta
+
+
+class ChatLineMetadataBuilder:
+ def __init__(self, me, user_relations):
+ self._me = me
+ self._user_relations = user_relations
+
+ @classmethod
+ def build(cls, me, user_relations, **kwargs):
+ return cls(me, user_relations)
+
+ def get_meta(self, channel, line):
+ if line.sender is None:
+ cc = None
+ else:
+ key = (channel.id_key, line.sender)
+ cc = channel.chatters.get(key, None)
+ chatter = None
+ player = None
+ if cc is not None:
+ chatter = cc.chatter
+ player = chatter.player
+
+ meta = MagicDict()
+ self._chatter_metadata(meta, cc)
+ self._player_metadata(meta, player)
+ self._relation_metadata(meta, chatter, player)
+ self._mention_metadata(line, meta)
+ return ChatLineMetadata(line, meta)
+
+ def _chatter_metadata(self, meta, cc):
+ if cc is None:
+ return
+ cmeta = meta.put("chatter")
+ cmeta.is_mod = cc.is_mod()
+ cmeta.name = cc.chatter.name
+
+ def _player_metadata(self, meta, player):
+ if player is None:
+ return
+ pmeta = meta.put("player")
+ pmeta.clan = player.clan
+ pmeta.id = player.id
+ self._avatar_metadata(pmeta, player.avatar)
+
+ def _relation_metadata(self, meta, chatter, player):
+ me = self._me
+ name = None if chatter is None else chatter.name
+ id_ = None if player is None else player.id
+ meta.is_friend = self._user_relations.is_friend(id_, name)
+ meta.is_foe = self._user_relations.is_foe(id_, name)
+ meta.is_me = me.player is not None and me.player.login == name
+ meta.is_clannie = me.is_clannie(id_)
+
+ def _mention_metadata(self, line, meta):
+ meta.mentions_me = (
+ self._me.login is not None
+ and self._me.login in line.text
+ and line.sender != self._me.login
+ )
+
+ def _avatar_metadata(self, pmeta, avatar):
+ if avatar is None:
+ return
+ tip = avatar.get("tooltip", "")
+ url = avatar.get("url", None)
+
+ ameta = pmeta.put("avatar")
+ ameta.tip = tip
+ if url is not None:
+ ameta.url = url
diff --git a/src/model/chat/chatter.py b/src/model/chat/chatter.py
new file mode 100644
index 000000000..7c191108c
--- /dev/null
+++ b/src/model/chat/chatter.py
@@ -0,0 +1,63 @@
+from PyQt6.QtCore import pyqtSignal
+
+from model.modelitem import ModelItem
+from model.transaction import transactional
+
+
+class Chatter(ModelItem):
+ newPlayer = pyqtSignal(object, object, object)
+ added_channel = pyqtSignal(object)
+ removed_channel = pyqtSignal(object)
+
+ def __init__(self, name, hostname):
+ ModelItem.__init__(self)
+ self.add_field("name", name)
+ self.add_field("hostname", hostname)
+ self._player = None
+ self.channels = {}
+
+ @property
+ def id_key(self):
+ return self.name
+
+ def copy(self):
+ return Chatter(**self.field_dict)
+
+ @transactional
+ def update(self, **kwargs):
+ _transaction = kwargs.pop("_transaction")
+ olduser = self.copy()
+ ModelItem.update(self, **kwargs)
+ self.emit_update(olduser, _transaction)
+
+ @property
+ def player(self):
+ return self._player
+
+ @transactional
+ def set_player(self, val, _transaction=None):
+ oldplayer = self._player
+ self._player = val
+ _transaction.emit(self.newPlayer, self, val, oldplayer)
+
+ @player.setter
+ def player(self, val):
+ # CAVEAT: this will emit signals immediately!
+ self.set_player(val)
+
+ @transactional
+ def add_channel(self, cc, _transaction=None):
+ self.channels[cc.id_key] = cc
+ _transaction.emit(self.added_channel, cc)
+
+ @transactional
+ def remove_channel(self, cc, _transaction=None):
+ del self.channels[cc.id_key]
+ _transaction.emit(self.removed_channel, cc)
+
+ def is_base_channel_mod(self):
+ return any(
+ cc.is_mod()
+ for cc in self.channels.values()
+ if cc.channel.is_base
+ )
diff --git a/src/model/chat/chatterset.py b/src/model/chat/chatterset.py
new file mode 100644
index 000000000..0686803df
--- /dev/null
+++ b/src/model/chat/chatterset.py
@@ -0,0 +1,56 @@
+from model.chat.chatter import Chatter
+from model.modelitemset import ModelItemSet
+from model.transaction import transactional
+
+
+class Chatterset(ModelItemSet):
+ def __init__(self, playerset):
+ ModelItemSet.__init__(self)
+ self._playerset = playerset
+ playerset.before_added.connect(self._at_player_added)
+ playerset.before_removed.connect(self._at_player_removed)
+
+ @transactional
+ def set_item(self, key, value, _transaction=None):
+ if not isinstance(key, str) or not isinstance(value, Chatter):
+ raise TypeError
+ ModelItemSet.set_item(self, key, value, _transaction)
+
+ # Don't put newly added element's signal in the transaction
+ if value.id_key in self._playerset:
+ value.player = self._playerset[value.id_key]
+
+ value.before_updated.connect(self._at_user_updated)
+ self.emit_added(value, _transaction)
+
+ @transactional
+ def del_item(self, key, _transaction=None):
+ chatter = ModelItemSet.del_item(self, key, _transaction)
+ if chatter is None:
+ return
+ chatter.before_updated.disconnect(self._at_user_updated)
+ self.emit_removed(chatter, _transaction)
+
+ def _at_player_added(self, player, _transaction=None):
+ if player.login in self:
+ self[player.login].set_player(player, _transaction)
+
+ def _at_player_removed(self, player, _transaction=None):
+ if player.login in self:
+ self[player.login].set_player(None, _transaction)
+
+ def _at_user_updated(self, user, olduser, _transaction=None):
+ if user.name != olduser.name:
+ self._handle_rename(user, olduser, _transaction)
+
+ def _handle_rename(self, user, olduser, _transaction=None):
+ # We should never rename to an existing user, but let's handle it
+ if user.name in self:
+ self.del_item(user.name, _transaction)
+
+ if olduser.name in self._items:
+ del self._items[olduser.name]
+ self._items[user.name] = user
+
+ newplayer = self._playerset.get(user.name)
+ user.set_player(newplayer, _transaction)
diff --git a/src/model/game.py b/src/model/game.py
index 7b068daa2..0a575b869 100644
--- a/src/model/game.py
+++ b/src/model/game.py
@@ -1,10 +1,16 @@
-from PyQt5.QtCore import QObject, pyqtSignal, QUrl, QUrlQuery, QTimer
-
-from enum import Enum
-from decorators import with_logger
+import html
+import string
import time
+from enum import Enum
-import string
+from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import pyqtSignal
+
+from decorators import with_logger
+from model.modelitem import ModelItem
+from model.transaction import transactional
+from util.gameurl import GameUrl
+from util.gameurl import GameUrlType
class GameState(Enum):
@@ -20,7 +26,7 @@ class GameVisibility(Enum):
@with_logger
-class Game(QObject):
+class Game(ModelItem):
"""
Represents a game happening on the server. Updates for the game state are
sent from the server, identified by game uid. Updates are propagated with
@@ -31,57 +37,53 @@ class Game(QObject):
shouldn't be updated or ended again. Update and game end are propagated
with signals.
"""
- gameUpdated = pyqtSignal(object, object)
+ before_replay_available = pyqtSignal(object, object)
liveReplayAvailable = pyqtSignal(object)
- connectedPlayerAdded = pyqtSignal(object, object)
- connectedPlayerRemoved = pyqtSignal(object, object)
-
ingamePlayerAdded = pyqtSignal(object, object)
ingamePlayerRemoved = pyqtSignal(object, object)
OBSERVER_TEAMS = ['-1', 'null']
LIVE_REPLAY_DELAY_SECS = 60 * 5
- SENTINEL = object()
-
- def __init__(self,
- playerset,
- uid,
- state,
- launched_at,
- num_players,
- max_players,
- title,
- host,
- mapname,
- map_file_path,
- teams,
- featured_mod,
- featured_mod_versions,
- sim_mods,
- password_protected,
- visibility):
-
- QObject.__init__(self)
+ def __init__(
+ self,
+ playerset,
+ uid,
+ state,
+ launched_at,
+ num_players,
+ max_players,
+ title,
+ host,
+ mapname,
+ map_file_path,
+ teams,
+ featured_mod,
+ sim_mods,
+ password_protected,
+ visibility,
+ **kwargs,
+ ):
+
+ ModelItem.__init__(self)
self._playerset = playerset
self.uid = uid
- self.state = None
- self.launched_at = None
- self.num_players = None
- self.max_players = None
- self.title = None
- self.host = None
- self.mapname = None
- self.map_file_path = None
- self.teams = None
- self.featured_mod = None
- self.featured_mod_versions = None
- self.sim_mods = None
- self.password_protected = None
- self.visibility = None
+ self.add_field("state", state)
+ self.add_field("launched_at", launched_at)
+ self.add_field("num_players", num_players)
+ self.add_field("max_players", max_players)
+ self.add_field("title", title)
+ self.add_field("host", host)
+ self.add_field("mapname", mapname)
+ self.add_field("map_file_path", map_file_path)
+ self.add_field("teams", teams)
+ self.add_field("featured_mod", featured_mod)
+ self.add_field("sim_mods", sim_mods)
+ self.add_field("password_protected", password_protected)
+ self.add_field("visibility", visibility)
self._aborted = False
self._live_replay_timer = QTimer()
@@ -89,159 +91,83 @@ def __init__(self,
self._live_replay_timer.setInterval(self.LIVE_REPLAY_DELAY_SECS * 1000)
self._live_replay_timer.timeout.connect(self._emit_live_replay)
self.has_live_replay = False
+ self._check_live_replay_timer()
- self._update(state, launched_at, num_players, max_players, title,
- host, mapname, map_file_path, teams, featured_mod,
- featured_mod_versions, sim_mods, password_protected,
- visibility)
+ @property
+ def id_key(self):
+ return self.uid
def copy(self):
- s = self
- return Game(s._playerset, s.uid, s.state, s.launched_at, s.num_players,
- s.max_players, s.title, s.host, s.mapname, s.map_file_path,
- s.teams, s.featured_mod, s.featured_mod_versions,
- s.sim_mods, s.password_protected, s.visibility)
+ old = Game(self._playerset, self.uid, **self.field_dict)
+ old._aborted = self._aborted
+ old.has_live_replay = self.has_live_replay
+ return old
- def update(self, *args, **kwargs):
+ @transactional
+ def update(self, **kwargs):
if self._aborted:
return
- old = self.copy()
- self._update(*args, **kwargs)
- self.gameUpdated.emit(self, old)
-
- def _update(self,
- state=SENTINEL,
- launched_at=SENTINEL,
- num_players=SENTINEL,
- max_players=SENTINEL,
- title=SENTINEL,
- host=SENTINEL,
- mapname=SENTINEL,
- map_file_path=SENTINEL,
- teams=SENTINEL,
- featured_mod=SENTINEL,
- featured_mod_versions=SENTINEL,
- sim_mods=SENTINEL,
- password_protected=SENTINEL,
- visibility=SENTINEL,
- uid=SENTINEL, # For convenience
- ):
-
- def changed(item):
- return item is not self.SENTINEL
-
- if changed(launched_at):
- self.launched_at = launched_at
- if changed(state):
- self.state = state
- if changed(num_players):
- self.num_players = num_players
- if changed(max_players):
- self.max_players = max_players
- if changed(title):
- self.title = title
- if changed(host):
- self.host = host
- if changed(mapname):
- self.mapname = mapname
- if changed(map_file_path):
- self.map_file_path = map_file_path
-
- # Dict of : [list of player names]
- if changed(teams):
- self.teams = teams
-
- # Actually a game mode like faf, coop, ladder etc.
- if changed(featured_mod):
- self.featured_mod = featured_mod
-
- # Featured mod versions for this game used to update FA before joining
- # TODO - investigate if this is actually necessary
- if changed(featured_mod_versions):
- self.featured_mod_versions = featured_mod_versions
-
- # Dict of mod uid: mod version for each mod used by the game
- if changed(sim_mods):
- self.sim_mods = sim_mods
- if changed(password_protected):
- self.password_protected = password_protected
- if changed(visibility):
- self.visibility = visibility
+ _transaction = kwargs.pop("_transaction")
+ old = self.copy()
+ ModelItem.update(self, **kwargs)
self._check_live_replay_timer()
-
- def _check_live_replay_timer(self):
- if (self.state != GameState.PLAYING or
- self._live_replay_timer.isActive() or
- self.launched_at is None):
+ self.emit_update(old, _transaction)
+
+ def _check_live_replay_timer(self) -> None:
+ if (
+ self.state != GameState.PLAYING
+ or self._live_replay_timer.isActive()
+ or self.launched_at is None
+ ):
return
if self.has_live_replay:
return
- time_elapsed = time.time() - self.launched_at
+ time_elapsed = round(time.time() - self.launched_at, 0)
time_to_replay = max(self.LIVE_REPLAY_DELAY_SECS - time_elapsed, 0)
- self._live_replay_timer.start(time_to_replay * 1000)
+ self._live_replay_timer.start(int(time_to_replay * 1000))
- def _emit_live_replay(self):
+ @transactional
+ def _emit_live_replay(self, _transaction=None):
if self.state != GameState.PLAYING:
return
self.has_live_replay = True
- self.liveReplayAvailable.emit(self)
+ _transaction.emit(self.liveReplayAvailable, self)
+ self.before_replay_available.emit(self, _transaction)
def closed(self):
return self.state == GameState.CLOSED or self._aborted
# Used when the server confuses us whether the game is valid anymore.
- def abort_game(self):
+ @transactional
+ def abort_game(self, _transaction=None):
if self.closed():
return
old = self.copy()
self.state = GameState.CLOSED
self._aborted = True
- self.gameUpdated.emit(self, old)
+ self.emit_update(old, _transaction)
def to_dict(self):
- return {
- "uid": self.uid,
- "state": self.state.name,
- "launched_at": self.launched_at,
- "num_players": self.num_players,
- "max_players": self.max_players,
- "title": self.title,
- "host": self.host,
- "mapname": self.mapname,
- "map_file_path": self.map_file_path,
- "teams": self.teams,
- "featured_mod": self.featured_mod,
- "featured_mod_versions": self.featured_mod_versions,
- "sim_mods": self.sim_mods,
- "password_protected": self.password_protected,
- "visibility": self.visibility.name,
- "command": "game_info" # For compatibility
- }
+ data = self.field_dict
+ data["uid"] = self.uid
+ data["state"] = data["state"].name
+ data["visibility"] = data["visibility"].name
+ data["command"] = "game_info" # For compatibility
+ return data
def url(self, player_id):
if self.state == GameState.CLOSED:
return None
-
- url = QUrl()
- url.setHost("lobby.faforever.com")
- query = QUrlQuery()
- query.addQueryItem("map", self.mapname)
- query.addQueryItem("mod", self.featured_mod)
-
if self.state == GameState.OPEN:
- url.setScheme("fafgame")
- url.setPath("/" + str(player_id))
- query.addQueryItem("uid", str(self.uid))
+ gtype = GameUrlType.OPEN_GAME
else:
- url.setScheme("faflive")
- url.setPath("/" + str(self.uid) + "/" + str(player_id) + ".SCFAreplay")
+ gtype = GameUrlType.LIVE_REPLAY
- url.setQuery(query)
- return url
+ return GameUrl(gtype, self.mapname, self.featured_mod, self.uid, player_id, self.sim_mods)
# Utility functions start here.
@@ -249,9 +175,11 @@ def is_connected(self, name):
return name in self._playerset
def is_ingame(self, name):
- return (not self.closed()
- and self.is_connected(name)
- and self._playerset[name].currentGame == self)
+ return (
+ not self.closed()
+ and self.is_connected(name)
+ and self._playerset[name].currentGame == self
+ )
def to_player(self, name):
if not self.is_connected(name):
@@ -268,16 +196,22 @@ def players(self):
def observers(self):
if self.teams is None:
return []
- return [name for tname, team in self.teams.items()
- if tname in self.OBSERVER_TEAMS
- for name in team]
+ return [
+ name
+ for tname, team in self.teams.items()
+ if tname in self.OBSERVER_TEAMS
+ for name in team
+ ]
@property
def playing_teams(self):
if self.teams is None:
return {}
- return {n: t for n, t in self.teams.items()
- if n not in self.OBSERVER_TEAMS}
+ return {
+ n: t
+ for n, t in self.teams.items()
+ if n not in self.OBSERVER_TEAMS
+ }
@property
def playing_players(self):
@@ -290,16 +224,30 @@ def host_player(self):
except KeyError:
return None
+ @transactional
+ def ingame_player_added(self, player, _transaction=None):
+ _transaction.emit(self.ingamePlayerAdded, self, player)
+
+ @transactional
+ def ingame_player_removed(self, player, _transaction=None):
+ _transaction.emit(self.ingamePlayerRemoved, self, player)
+
@property
def average_rating(self):
- players = [name for team in self.playing_teams.values()
- for name in team]
- players = [self.to_player(name) for name in players
- if self.is_connected(name)]
+ players = [
+ name
+ for team in self.playing_teams.values()
+ for name in team
+ ]
+ players = [
+ self.to_player(name)
+ for name in players
+ if self.is_connected(name)
+ ]
if not players:
return 0
else:
- return sum([p.rating_estimate() for p in players]) / len(players)
+ return sum([p.global_estimate for p in players]) / len(players)
@property
def mapdisplayname(self):
@@ -325,6 +273,8 @@ def message_to_game_args(m):
try:
m['state'] = GameState(m['state'])
m['visibility'] = GameVisibility(m['visibility'])
+ # Server sends HTML-escaped names, which is needlessly confusing
+ m['title'] = html.unescape(m['title'])
except (KeyError, ValueError):
return False
diff --git a/src/model/gameset.py b/src/model/gameset.py
index c62a52c29..acdfc85ff 100644
--- a/src/model/gameset.py
+++ b/src/model/gameset.py
@@ -1,11 +1,13 @@
-from PyQt5.QtCore import QObject, pyqtSignal
-from decorators import with_logger
+from PyQt6.QtCore import pyqtSignal
+from decorators import with_logger
from model import game
+from model.modelitemset import ModelItemSet
+from model.transaction import transactional
@with_logger
-class Gameset(QObject):
+class Gameset(ModelItemSet):
"""
Keeps track of currently active games. Removes games that closed. Reports
creation and state change of games. Gives access to active games.
@@ -14,148 +16,138 @@ class Gameset(QObject):
send a game state for a uid, send a state that closes it, then send a state
with the same uid again, and it will be reported as a new game.
"""
- newGame = pyqtSignal(object)
-
newLobby = pyqtSignal(object)
newLiveGame = pyqtSignal(object)
newClosedGame = pyqtSignal(object)
newLiveReplay = pyqtSignal(object)
def __init__(self, playerset):
- QObject.__init__(self)
- self.games = {}
+ ModelItemSet.__init__(self)
self._playerset = playerset
- self._idx = PlayerGameIndex(playerset)
-
- def __getitem__(self, uid):
- return self.games[uid]
-
- def __contains__(self, uid):
- return uid in self.games
-
- def __iter__(self):
- return iter(self.games)
-
- def keys(self):
- return self.games.keys()
-
- def values(self):
- return self.games.values()
- def items(self):
- return self.games.items()
-
- def get(self, item, default=None):
- try:
- return self[item]
- except KeyError:
- return default
-
- def __setitem__(self, key, value):
+ @transactional
+ def set_item(self, key, value, _transaction=None):
if not isinstance(key, int) or not isinstance(value, game.Game):
raise TypeError
-
- if key in self or value.closed():
+ if value.closed():
raise ValueError
- if key != value.uid:
- raise ValueError
-
- self.games[key] = value
- # We should be the first ones to connect to the signal
- value.gameUpdated.connect(self._at_game_update)
- value.liveReplayAvailable.connect(self._at_live_replay)
- self._at_game_update(value, None)
- self.newGame.emit(value)
- self._logger.debug("Added game, uid {}".format(value.uid))
-
- def clear(self):
+ ModelItemSet.set_item(self, key, value, _transaction)
+ value.before_updated.connect(self._at_game_update)
+ value.before_replay_available.connect(self._at_live_replay)
+ self._at_game_update(value, None, _transaction)
+ self._logger.debug("Added game, uid {}".format(value.id_key))
+ self.emit_added(value, _transaction)
+
+ @transactional
+ def del_item(self, key, _transaction=None):
+ g = ModelItemSet.del_item(self, key, _transaction)
+ if g is None:
+ return
+
+ g.before_updated.disconnect(self._at_game_update)
+ g.before_replay_available.disconnect(self._at_live_replay)
+ self._logger.debug("Removed game, uid {}".format(g.id_key))
+ self.emit_removed(g, _transaction)
+
+ @transactional
+ def clear(self, _transaction=None):
# Abort_game removes g from dict, so 'for g in values()' complains
- for g in list(self.games.values()):
- g.abort_game()
+ for g in list(self._items.values()):
+ g.abort_game(_transaction)
- def _at_game_update(self, new, old):
+ def _at_game_update(self, new, old, _transaction=None):
if new.closed():
- self._remove_game(new)
- self._idx.at_game_update(new, old)
+ self.del_item(new.id_key, _transaction)
if old is None or new.state != old.state:
- self._new_state(new)
+ self._new_state(new, _transaction)
- def _new_state(self, g):
- self._logger.debug("New game state {}, uid {}".format(g.state, g.uid))
+ def _new_state(self, g, _transaction=None):
+ self._logger.debug(
+ "New game state {}, uid {}".format(g.state, g.id_key),
+ )
if g.state == game.GameState.OPEN:
- self.newLobby.emit(g)
+ _transaction.emit(self.newLobby, g)
elif g.state == game.GameState.PLAYING:
- self.newLiveGame.emit(g)
+ _transaction.emit(self.newLiveGame, g)
elif g.state == game.GameState.CLOSED:
- self.newClosedGame.emit(g)
-
- def _at_live_replay(self, game):
- self.newLiveReplay.emit(game)
+ _transaction.emit(self.newClosedGame, g)
- def _remove_game(self, g):
- try:
- g = self.games[g.uid]
- g.gameUpdated.disconnect(self._at_game_update)
- g.liveReplayAvailable.disconnect(self._at_live_replay)
- del self.games[g.uid]
- self._logger.debug("Removed game, uid {}".format(g.uid))
- except KeyError:
- pass
+ def _at_live_replay(self, game, _transaction=None):
+ _transaction.emit(self.newLiveReplay, game)
class PlayerGameIndex:
# Helper class that keeps track of player / game relationship and helps
# assign games to players that reconnected.
- def __init__(self, playerset):
+ def __init__(self, gameset, playerset):
self._playerset = playerset
+ self._gameset = gameset
+ self._playerset.before_added.connect(self._on_player_added)
+ self._playerset.before_removed.connect(self._on_player_removed)
+ self._gameset.before_added.connect(self._on_game_added)
+ self._gameset.before_removed.connect(self._on_game_removed)
+
self._idx = {}
- self._playerset.playerAdded.connect(self._on_player_added)
- self._playerset.playerRemoved.connect(self._on_player_removed)
- # Called by gameset
- def at_game_update(self, new, old):
- old_closed = old is None or old.closed()
+ def player_game(self, pname):
+ return self._idx.get(pname)
+
+ def _on_game_added(self, game, _transaction=None):
+ game.before_updated.connect(self._at_game_update)
+ for p in game.players:
+ self._set_relation(p, game, _transaction)
- news = set() if new.closed() else set(new.players)
- olds = set() if old_closed else set(old.players)
+ def _on_game_removed(self, game, _transaction=None):
+ game.before_updated.disconnect(self._at_game_update)
+ for p in game.players:
+ self._remove_relation(p, game, _transaction)
- removed = [p for p in olds - news
- if p in self._idx and self._idx[p] == new]
+ def _at_game_update(self, new, old, _transaction=None):
+ news = set() if new.closed() else set(new.players)
+ olds = set() if old.closed() else set(old.players)
+ removed = olds - news
added = news - olds
-
- # Player games are part of state, so update all first before signals
- signals = []
for p in removed:
- signals.append(self._set_player_game_defer_signal(p, None))
+ self._remove_relation(p, new, _transaction)
for p in added:
- signals.append(self._set_player_game_defer_signal(p, new))
+ self._set_relation(p, new, _transaction)
- for s in signals:
- s()
+ def _remove_relation(self, pname, game, _transaction=None):
+ if pname not in self._idx:
+ return
+ if self.player_game(pname) != game:
+ return
- def _set_player_game_defer_signal(self, pname, game):
- oldgame = self._idx.get(pname)
- if not self._should_update_player_game(game, oldgame):
- return lambda: None
+ player = self._playerset.get(pname)
+ del self._idx[pname]
- if game is None:
- if pname in self._idx:
- del self._idx[pname]
- else:
- self._idx[pname] = game
+ if player is not None:
+ player.set_currentGame(None, _transaction)
+ game.ingame_player_removed(player, _transaction)
- if pname in self._playerset:
- player = self._playerset[pname]
- return player.set_current_game_defer_signal(game)
- else:
- return lambda: None
+ def _set_relation(self, pname, game, _transaction=None):
+ oldgame = self.player_game(pname)
+ if not self._player_did_change_game(game, oldgame):
+ return
- def _should_update_player_game(self, new, old):
+ player = self._playerset.get(pname)
+ self._idx[pname] = game
+
+ if player is not None:
+ player.set_currentGame(game, _transaction)
+ if oldgame is not None:
+ oldgame.ingame_player_removed(player, _transaction)
+ game.ingame_player_added(player, _transaction)
+
+ def _player_did_change_game(self, new, old):
# Removing or setting new game should always happen
if new is None or old is None:
return True
+ if new.id_key == old.id_key:
+ return False
+
# Games should be not closed now
# Lobbies always take precedence - if there are 2 at once, tough luck
if new.state == game.GameState.OPEN:
@@ -170,16 +162,13 @@ def _should_update_player_game(self, new, old):
return True
return new.launched_at > old.launched_at
- def player_game(self, pname):
- return self._idx.get(pname)
-
- def _on_player_added(self, player):
+ def _on_player_added(self, player, _transaction=None):
pgame = self.player_game(player.login)
if pgame is not None:
- player.currentGame = pgame
- pgame.connectedPlayerAdded.emit(pgame, player)
+ player.set_currentGame(pgame, _transaction)
+ pgame.ingame_player_added(player, _transaction)
- def _on_player_removed(self, player):
+ def _on_player_removed(self, player, _transaction=None):
pgame = self.player_game(player.login)
if pgame is not None:
- pgame.connectedPlayerRemoved.emit(pgame, player)
+ pgame.ingame_player_removed(player, _transaction)
diff --git a/src/model/ircuser.py b/src/model/ircuser.py
index 7991675e4..e18ef4289 100644
--- a/src/model/ircuser.py
+++ b/src/model/ircuser.py
@@ -1,54 +1,62 @@
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import pyqtSignal
+from model.modelitem import ModelItem
+from model.transaction import transactional
-class IrcUser(QObject):
- updated = pyqtSignal(object, object)
+
+class IrcUser(ModelItem):
newPlayer = pyqtSignal(object, object, object)
def __init__(self, name, hostname):
- QObject.__init__(self)
-
- self.name = name
+ ModelItem.__init__(self)
self.elevation = {}
- self.hostname = hostname
+ self.add_field("name", name)
+ self.add_field("hostname", hostname)
self._player = None
+ @property
+ def id_key(self):
+ return self.name
+
def copy(self):
- old = IrcUser(self.name, self.hostname)
+ old = IrcUser(**self.field_dict)
for channel in self.elevation:
old.set_elevation(channel, self.elevation[channel])
return old
- def update(self, name=None, hostname=None):
+ @transactional
+ def update(self, **kwargs):
+ _transaction = kwargs.pop("_transaction")
olduser = self.copy()
+ ModelItem.update(self, **kwargs)
+ self.emit_update(olduser, _transaction)
- if name is not None:
- self.name = name
- if hostname is not None:
- self.hostname = hostname
-
- self.updated.emit(self, olduser)
-
- def set_elevation(self, channel, elevation):
+ @transactional
+ def set_elevation(self, channel, elevation, _transaction=None):
olduser = self.copy()
if elevation is None:
if channel in self.elevation:
del self.elevation[channel]
else:
self.elevation[channel] = elevation
- self.updated.emit(self, olduser)
+ self.emit_update(olduser, _transaction)
@property
def player(self):
return self._player
- @player.setter
- def player(self, val):
+ @transactional
+ def set_player(self, val, _transaction=None):
oldplayer = self._player
self._player = val
- self.newPlayer.emit(self, val, oldplayer)
+ _transaction.emit(self.newPlayer, self, val, oldplayer)
+
+ @player.setter
+ def player(self, val):
+ # CAVEAT: this will emit signals immediately!
+ self.set_player(val)
def is_mod(self, channel):
if channel not in self.elevation:
diff --git a/src/model/ircuserset.py b/src/model/ircuserset.py
index 22161b643..c39417539 100644
--- a/src/model/ircuserset.py
+++ b/src/model/ircuserset.py
@@ -1,101 +1,53 @@
-from PyQt5.QtCore import QObject, pyqtSignal
+from model.ircuser import IrcUser
+from model.modelitemset import ModelItemSet
+from model.transaction import transactional
-class IrcUserset(QObject):
- userAdded = pyqtSignal(object)
- userRemoved = pyqtSignal(object)
-
+class IrcUserset(ModelItemSet):
def __init__(self, playerset):
- QObject.__init__(self)
- self._users = {}
+ ModelItemSet.__init__(self)
self._playerset = playerset
- playerset.playerAdded.connect(self._at_player_added)
- playerset.playerRemoved.connect(self._at_player_removed)
-
- def __getitem__(self, item):
- return self._users[item]
-
- def __len__(self):
- return len(self._users)
-
- def __iter__(self):
- return iter(self._users)
-
- # We need to define the below things - QObject
- # doesn't allow for Mapping mixin
- def keys(self):
- return self._users.keys()
-
- def values(self):
- return self._users.values()
-
- def items(self):
- return self._users.items()
-
- def get(self, item, default=None):
- try:
- return self[item]
- except KeyError:
- return default
-
- def __contains__(self, item):
- try:
- self[item]
- return True
- except KeyError:
- return False
-
- def __setitem__(self, key, value):
- if key in self: # disallow overwriting existing chatters
- raise ValueError
-
- if key != value.name:
- raise ValueError
-
- self._users[key] = value
-
- if value.name in self._playerset:
- value.player = self._playerset[value.name]
-
- # We're first to connect, so first to get called
- value.updated.connect(self._at_user_updated)
-
- self.userAdded.emit(value)
-
- def __delitem__(self, item):
- try:
- user = self[item]
- except KeyError:
+ playerset.before_added.connect(self._at_player_added)
+ playerset.before_removed.connect(self._at_player_removed)
+
+ @transactional
+ def set_item(self, key, value, _transaction=None):
+ if not isinstance(key, str) or not isinstance(value, IrcUser):
+ raise TypeError
+ ModelItemSet.set_item(self, key, value, _transaction)
+ if value.id_key in self._playerset:
+ value.player = self._playerset[value.id_key]
+ value.before_updated.connect(self._at_user_updated)
+ self.emit_added(value, _transaction)
+
+ @transactional
+ def del_item(self, key, _transaction=None):
+ user = ModelItemSet.del_item(self, key, _transaction)
+ if user is None:
return
- del self._users[user.name]
- user.updated.disconnect(self._at_user_updated)
- self.userRemoved.emit(user)
-
- def clear(self):
- oldusers = list(self.keys())
- for user in oldusers:
- del self[user]
+ user.before_updated.disconnect(self._at_user_updated)
+ self.emit_removed(user, _transaction)
- def _at_player_added(self, player):
+ def _at_player_added(self, player, _transaction=None):
if player.login in self:
- self[player.login].player = player
+ self[player.login].set_player(player, _transaction)
- def _at_player_removed(self, player):
+ def _at_player_removed(self, player, _transaction=None):
if player.login in self:
- self[player.login].player = None
+ self[player.login].set_player(None, _transaction)
- def _at_user_updated(self, user, olduser):
+ def _at_user_updated(self, user, olduser, _transaction=None):
if user.name != olduser.name:
- self._handle_rename(user, olduser)
+ self._handle_rename(user, olduser, _transaction)
- def _handle_rename(self, user, olduser):
+ def _handle_rename(self, user, olduser, _transaction=None):
# We should never rename to an existing user, but let's handle it
if user.name in self:
- del self[user.name]
+ self.del_item(user.name, _transaction)
- if olduser.name in self._users:
- del self._users[olduser.name]
- self._users[user.name] = user
+ if olduser.name in self._items:
+ del self._items[olduser.name]
+ self._items[user.name] = user
newplayer = self._playerset.get(user.name)
- user.player = newplayer
+ user.set_player(newplayer, _transaction)
diff --git a/src/model/modelitem.py b/src/model/modelitem.py
new file mode 100644
index 000000000..d15eec6bd
--- /dev/null
+++ b/src/model/modelitem.py
@@ -0,0 +1,47 @@
+from PyQt6.QtCore import pyqtSignal
+
+from model.qobjectmapping import QObject
+from model.transaction import transactional
+
+
+class ModelItem(QObject):
+ updated = pyqtSignal(object, object)
+ before_updated = pyqtSignal(object, object, object)
+
+ def __init__(self):
+ QObject.__init__(self)
+ self._data_fields = []
+
+ def add_field(self, name, default):
+ self._data_fields.append(name)
+ setattr(self, name, default)
+
+ @property
+ def field_dict(self):
+ return {v: getattr(self, v) for v in self._data_fields}
+
+ def copy(self):
+ raise NotImplementedError
+
+ def update(self, **kwargs):
+ # Ignore unknown fields for convenience
+ for f in self._data_fields:
+ if f in kwargs:
+ setattr(self, f, kwargs[f])
+
+ @transactional
+ def emit_update(self, old, _transaction=None):
+ _transaction.emit(self.updated, self, old)
+ self.before_updated.emit(self, old, _transaction)
+
+ @property
+ def id_key(self):
+ raise NotImplementedError
+
+ def __hash__(self):
+ return hash(self.id_key)
+
+ def __eq__(self, other):
+ if not isinstance(self, type(other)):
+ return False
+ return self.id_key == other.id_key
diff --git a/src/model/modelitemset.py b/src/model/modelitemset.py
new file mode 100644
index 000000000..3aa6fef21
--- /dev/null
+++ b/src/model/modelitemset.py
@@ -0,0 +1,64 @@
+from PyQt6.QtCore import pyqtSignal
+
+from model.qobjectmapping import QObjectMapping
+from model.transaction import transactional
+
+
+class ModelItemSet(QObjectMapping):
+ added = pyqtSignal(object)
+ removed = pyqtSignal(object)
+ before_added = pyqtSignal(object, object)
+ before_removed = pyqtSignal(object, object)
+
+ def __init__(self):
+ QObjectMapping.__init__(self)
+
+ self._items = {}
+
+ def __getitem__(self, item):
+ return self._items[item]
+
+ def __len__(self):
+ return len(self._items)
+
+ def __iter__(self):
+ return iter(self._items)
+
+ def emit_added(self, value, _transaction=None):
+ _transaction.emit(self.added, value)
+ self.before_added.emit(value, _transaction)
+
+ def emit_removed(self, value, _transaction=None):
+ _transaction.emit(self.removed, value)
+ self.before_removed.emit(value, _transaction)
+
+ @transactional
+ def set_item(self, key, value, _transaction=None):
+ if key in self:
+ raise ValueError
+ if key != value.id_key:
+ raise ValueError
+ self._items[key] = value
+
+ def __setitem__(self, key, value):
+ # CAVEAT: use only as an entry point for model changes.
+ self.set_item(key, value)
+
+ @transactional
+ def del_item(self, item, _transaction=None):
+ try:
+ value = self[item]
+ except KeyError:
+ return None
+ del self._items[value.id_key]
+ return value
+
+ def __delitem__(self, item):
+ # CAVEAT: use only as an entry point for model changes.
+ self.del_item(item)
+
+ @transactional
+ def clear(self, _transaction=None):
+ items = list(self.keys())
+ for item in items:
+ self.del_item(item, _transaction)
diff --git a/src/model/player.py b/src/model/player.py
index 5b3e5334f..407edc29d 100644
--- a/src/model/player.py
+++ b/src/model/player.py
@@ -1,165 +1,152 @@
-from PyQt5.QtCore import QObject, pyqtSignal
+from PyQt6.QtCore import pyqtSignal
+from model.modelitem import ModelItem
+from model.rating import RatingType
+from model.transaction import transactional
-class Player(QObject):
- updated = pyqtSignal(object, object)
+
+class Player(ModelItem):
newCurrentGame = pyqtSignal(object, object, object)
"""
Represents a player the client knows about.
"""
- def __init__(self,
- id_,
- login,
- global_rating=(1500, 500),
- ladder_rating=(1500, 500),
- number_of_games=0,
- avatar=None,
- country=None,
- clan=None,
- league=None):
- QObject.__init__(self)
+
+ def __init__(
+ self,
+ id_,
+ login,
+ ratings={},
+ avatar=None,
+ country=None,
+ clan=None,
+ league=None,
+ **kwargs
+ ):
+ ModelItem.__init__(self)
"""
Initialize a Player
"""
# Required fields
+ # Login should be mutable, but we look up things by login right now
self.id = int(id_)
self.login = login
- self.global_rating = global_rating
- self.ladder_rating = ladder_rating
- self.number_of_games = number_of_games
- self.avatar = avatar
- self.country = country
- self.clan = clan
- self.league = league
+ self.add_field("avatar", avatar)
+ self.add_field("country", country)
+ self.add_field("clan", clan)
+ self.add_field("league", league)
+ self.add_field("ratings", ratings)
# The game the player is currently playing
self._currentGame = None
+ @property
+ def id_key(self):
+ return self.id
+
def copy(self):
- s = self
- p = Player(s.id, s.login, s.global_rating, s.ladder_rating,
- s.number_of_games, s.avatar, s.country, s.clan, s.league)
- p.currentGame = self._currentGame
+ p = Player(self.id, self.login, **self.field_dict)
+ p.currentGame = self.currentGame
return p
- def update(self,
- id_=None,
- login=None,
- global_rating=None,
- ladder_rating=None,
- number_of_games=None,
- avatar=None,
- country=None,
- clan=None,
- league=None):
+ @transactional
+ def update(self, **kwargs):
+ _transaction = kwargs.pop("_transaction")
old_data = self.copy()
- # Ignore id and login (they are be immutable)
- # Login should be mutable, but we look up things by login right now
- if global_rating is not None:
- self.global_rating = global_rating
- if ladder_rating is not None:
- self.ladder_rating = ladder_rating
- if number_of_games is not None:
- self.number_of_games = number_of_games
- if avatar is not None:
- self.avatar = avatar
- if country is not None:
- self.country = country
- if clan is not None:
- self.clan = clan
- if league is not None:
- self.league = league
-
- self.updated.emit(self, old_data)
-
- def __hash__(self):
- """
- Index by id
- """
- return self.id.__hash__()
+ ModelItem.update(self, **kwargs)
+ self.emit_update(old_data, _transaction)
def __index__(self):
return self.id
- def __eq__(self, other):
- """
- Equality by id
-
- :param other: player object to compare with
- """
- if not isinstance(other, Player):
- return False
- return other.id == self.id
-
- def rounded_rating_estimate(self):
- """
- Get the conservative estimate of the players global trueskill rating,
- rounded to nearest 100
- """
- return round((self.rating_estimate()/100))*100
-
- def rating_estimate(self):
- """
- Get the conservative estimate of the players global trueskill rating
- """
- return int(max(0, (self.global_rating[0] - 3 * self.global_rating[1])))
+ @property
+ def global_estimate(self):
+ return self.rating_estimate()
+ @property
def ladder_estimate(self):
- """
- Get the conservative estimate of the players ladder trueskill rating
- """
- return int(max(0, (self.ladder_rating[0] - 3 * self.ladder_rating[1])))
+ return self.rating_estimate(RatingType.LADDER.value)
@property
- def rating_mean(self):
- return self.global_rating[0]
+ def global_rating_mean(self):
+ return self.rating_mean()
@property
- def rating_deviation(self):
- return self.global_rating[1]
+ def global_rating_deviation(self):
+ return self.rating_deviation()
@property
def ladder_rating_mean(self):
- return self.ladder_rating[0]
+ return self.rating_mean(RatingType.LADDER.value)
@property
def ladder_rating_deviation(self):
- return self.ladder_rating[1]
+ return self.rating_deviation(RatingType.LADDER.value)
+
+ @property
+ def number_of_games(self):
+ count = 0
+ for rating_type in self.ratings:
+ count += self.ratings[rating_type].get("number_of_games", 0)
+ return count
+
+ def rating_estimate(self, rating_type=RatingType.GLOBAL.value):
+ """
+ Get the conservative estimate of the player's trueskill rating
+ """
+ try:
+ mean = self.ratings[rating_type]["rating"][0]
+ deviation = self.ratings[rating_type]["rating"][1]
+ return int(max(0, (mean - 3 * deviation)))
+ except (KeyError, IndexError):
+ return 0
+
+ def rating_mean(self, rating_type=RatingType.GLOBAL.value):
+ try:
+ return round(self.ratings[rating_type]["rating"][0])
+ except (KeyError, IndexError):
+ return 1500
+
+ def rating_deviation(self, rating_type=RatingType.GLOBAL.value):
+ try:
+ return round(self.ratings[rating_type]["rating"][1])
+ except (KeyError, IndexError):
+ return 500
+
+ def game_count(self, rating_type=RatingType.GLOBAL.value):
+ try:
+ return int(self.ratings[rating_type]["number_of_games"])
+ except KeyError:
+ return 0
def __repr__(self):
return self.__str__()
def __str__(self):
- return ("Player(id={}, login={}, global_rating={}, "
- "ladder_rating={})").format(
+ return (
+ "Player(id={}, login={}, global_rating={}, ladder_rating={})"
+ ).format(
self.id,
self.login,
- self.global_rating,
- self.ladder_rating
+ (self.global_rating_mean, self.global_rating_deviation),
+ (self.ladder_rating_mean, self.ladder_rating_deviation),
)
@property
def currentGame(self):
return self._currentGame
- @currentGame.setter
- def currentGame(self, game):
- self.set_current_game_defer_signal(game)()
-
- def set_current_game_defer_signal(self, game):
+ @transactional
+ def set_currentGame(self, game, _transaction=None):
if self.currentGame == game:
- return lambda: None
-
+ return
old = self._currentGame
self._currentGame = game
- return lambda: self._emit_game_change(game, old)
-
- def _emit_game_change(self, game, old):
- self.newCurrentGame.emit(self, game, old)
- if old is not None:
- old.ingamePlayerRemoved.emit(old, self)
- if game is not None:
- game.ingamePlayerAdded.emit(game, self)
+ _transaction.emit(self.newCurrentGame, self, game, old)
+
+ @currentGame.setter
+ def currentGame(self, val):
+ # CAVEAT: this will emit signals immediately!
+ self.set_currentGame(val)
diff --git a/src/model/playerset.py b/src/model/playerset.py
index e7ecdb254..96663aadf 100644
--- a/src/model/playerset.py
+++ b/src/model/playerset.py
@@ -1,91 +1,43 @@
-from PyQt5.QtCore import QObject, pyqtSignal
-
+from model.modelitemset import ModelItemSet
from model.player import Player
+from model.transaction import transactional
-class Playerset(QObject):
- """
- Wrapper for an id->Player map
-
- Used to lookup players either by id or by login.
- """
- playerAdded = pyqtSignal(object)
- playerRemoved = pyqtSignal(object)
-
+class Playerset(ModelItemSet):
def __init__(self):
- QObject.__init__(self)
-
- # UID -> Player map
- self._players = {}
+ ModelItemSet.__init__(self)
# Login -> Player map
self._logins = {}
def __getitem__(self, item):
if isinstance(item, int):
- return self._players[item]
+ return ModelItemSet.__getitem__(self, item)
if isinstance(item, str):
return self._logins[item]
raise TypeError
- def __len__(self):
- return len(self._players)
-
- def __iter__(self):
- return iter(self._players)
-
- # We need to define the below things - QObject
- # doesn't allow for Mapping mixin
- def keys(self):
- return self._players.keys()
-
- def values(self):
- return self._players.values()
-
- def items(self):
- return self._players.items()
-
- def get(self, item, default=None):
- try:
- return self[item]
- except KeyError:
- return default
-
- def __contains__(self, item):
- try:
- self[item]
- return True
- except KeyError:
- return False
-
def getID(self, name):
if name in self:
return self[name].id
return -1
- def __setitem__(self, key, value):
+ @transactional
+ def set_item(self, key, value, _transaction=None):
if not isinstance(key, int) or not isinstance(value, Player):
raise TypeError
- if key in self: # disallow overwriting existing players
- raise ValueError
-
- if key != value.id:
- raise ValueError
-
- self._players[key] = value
+ ModelItemSet.set_item(self, key, value, _transaction)
self._logins[value.login] = value
- self.playerAdded.emit(value)
+ self.emit_added(value, _transaction)
- def __delitem__(self, item):
- try:
- player = self[item]
- except KeyError:
+ @transactional
+ def del_item(self, key, _transaction=None):
+ player = ModelItemSet.del_item(self, key, _transaction)
+ if player is None:
return
- del self._players[player.id]
del self._logins[player.login]
- self.playerRemoved.emit(player)
+ self.emit_removed(player, _transaction)
- def clear(self):
- oldplayers = list(self.keys())
- for player in oldplayers:
- del self[player]
+ def __delitem__(self, item):
+ # CAVEAT: use only as an entry point for model changes.
+ self.del_item(item)
diff --git a/src/model/qobjectmapping.py b/src/model/qobjectmapping.py
new file mode 100644
index 000000000..7e15a6141
--- /dev/null
+++ b/src/model/qobjectmapping.py
@@ -0,0 +1,67 @@
+from collections.abc import ItemsView
+from collections.abc import KeysView
+from collections.abc import ValuesView
+
+from PyQt6.QtCore import QObject
+
+
+class QObjectMapping(QObject):
+ """
+ ABC similar to collections.abc.MutableMapping.
+ Used since we can't mixin the above with QObject.
+ """
+
+ def __init__(self):
+ QObject.__init__(self)
+
+ def __len__(self):
+ return 0
+
+ def __iter__(self):
+ while False:
+ yield None
+
+ def __getitem__(self, key):
+ raise KeyError
+
+ def __setitem__(self, key, value):
+ raise KeyError
+
+ def __delitem__(self, key):
+ raise KeyError
+
+ __marker = object()
+
+ def pop(self, key, default=__marker):
+ try:
+ value = self[key]
+ except KeyError:
+ if default is self.__marker:
+ raise
+ return default
+ else:
+ del self[key]
+ return value
+
+ def get(self, key, default=None):
+ try:
+ return self[key]
+ except KeyError:
+ return default
+
+ def __contains__(self, key):
+ try:
+ self[key]
+ except KeyError:
+ return False
+ else:
+ return True
+
+ def keys(self):
+ return KeysView(self)
+
+ def values(self):
+ return ValuesView(self)
+
+ def items(self):
+ return ItemsView(self)
diff --git a/src/model/rating.py b/src/model/rating.py
new file mode 100644
index 000000000..83fea2913
--- /dev/null
+++ b/src/model/rating.py
@@ -0,0 +1,41 @@
+# TODO: fetch this from API
+
+from enum import Enum
+
+# copied from the server code according to which
+# this will need be fixed when the database
+# gets migrated
+
+
+class RatingType(Enum):
+ GLOBAL = "global"
+ LADDER = "ladder_1v1"
+ TMM_2v2 = "tmm_2v2"
+ TMM_3v3 = "tmm_3v3"
+ TMM_4v4 = "tmm_4v4"
+
+ @staticmethod
+ def fromMatchmakerQueue(matchmakerQueueName):
+ for ratingType in list(RatingType):
+ if ratingType.value.replace("_", "") == matchmakerQueueName:
+ return ratingType.value
+ return RatingType.GLOBAL.value
+
+
+# this is not from the server code. but it is weird
+# that rating types and leaderboard names differ
+# from matchmaker queue names
+
+
+class MatchmakerQueueType(Enum):
+ LADDER = "ladder1v1"
+ TMM_2v2 = "tmm2v2"
+ TMM_3v3 = "tmm3v3"
+ TMM_4v4 = "tmm4v4"
+
+ @staticmethod
+ def fromRatingType(ratingTypeName):
+ for matchmakerQueue in list(MatchmakerQueueType):
+ if ratingTypeName.replace("_", "") == matchmakerQueue.value:
+ return matchmakerQueue.value
+ return MatchmakerQueueType.LADDER.value
diff --git a/src/model/transaction.py b/src/model/transaction.py
new file mode 100644
index 000000000..81a4db21c
--- /dev/null
+++ b/src/model/transaction.py
@@ -0,0 +1,45 @@
+class ModelTransaction:
+ """
+ Allows model classes to postpone side effects of a model update (such as
+ emitting signals) until after the model is in a consistent state.
+ """
+
+ def __init__(self):
+ self._signals = []
+
+ def emit(self, *args):
+ self._signals.append(args)
+
+ def finalize(self):
+ for s in self._signals:
+ s[0].emit(*s[1:])
+ self._signals = []
+
+
+# An easy way for a function to create a transaction if it's called without one
+# and finalize it once it's done, and otherwise use a supplied transaction.
+#
+# In order to use it, a function has to define a _transaction argument as its
+# last, and should not accept another transaction instance. The transaction
+# argument will be added to kwargs if any were defined and _transaction was not
+# among them, or if there are no kwargs and the last arg is not a transaction.
+
+def transactional(fn):
+ def trans_fn(*args, **kwargs):
+ top_transaction = None
+
+ # _transaction is last, so if kwargs are non-empty, it's in them
+ if kwargs:
+ if "_transaction" not in kwargs:
+ top_transaction = ModelTransaction()
+ kwargs["_transaction"] = top_transaction
+ else:
+ if not args or not isinstance(args[-1], ModelTransaction):
+ top_transaction = ModelTransaction()
+ args = args + (top_transaction,)
+
+ ret = fn(*args, **kwargs)
+ if top_transaction is not None:
+ top_transaction.finalize()
+ return ret
+ return trans_fn
diff --git a/src/modvault/__init__.py b/src/modvault/__init__.py
deleted file mode 100644
index 4749f5c30..000000000
--- a/src/modvault/__init__.py
+++ /dev/null
@@ -1,428 +0,0 @@
-"""
-Modvault database documentation:
-command = "modvault"
-possible commands (value for the 'type' key):
- start: - given when the tab is opened. Signals that the server should send the possible mods.
- addcomment: moduid=, comment={"or","uid","date","text"}
- addbugreport: moduid=, comment={"author","uid","date","text"}
- like: uid-
-
-Can also send a UPLOAD_MOD command directly using writeToServer
-"UPLOAD_MOD","modname.zip",{mod info}, qfile
-
-modInfo function is called when the client recieves a modvault_info command.
-It should have a message dict with the following keys:
-uid - Unique identifier for a mod. Also needed ingame.
-name - Name of the mod. Also the name of the folder the mod will be located in.
-description - A general description of the mod. As seen ingame
-author - The FAF username of the person that uploaded the mod.
-downloads - An integer containing the amount of downloads of this mod
-likes - An integer containing the amount of likes the mod has recieved. #TODO: Actually implement an inteface for this.
-comments - A python list containing dictionaries containing the keys as described above.
-bugreports - A python list containing dictionaries containing the keys as described above.
-date - A string describing the date the mod was uploaded. Format: "%Y-%m-%d %H:%M:%S" eg: 2012-10-28 16:50:28
-ui - A boolean describing if it is a ui mod yay or nay.
-link - Direct link to the zip file containing the mod.
-thumbnail - A direct link to the thumbnail file. Should be something suitable for util.THEME.icon(). Not yet tested if this works correctly
-
-Additional stuff:
-fa.exe now has a CheckMods method, which is used in fa.exe.check
-check has a new argument 'additional_mods' for this.
-In client._clientwindow joinGameFromURL is changed. The url should have a
-queryItemValue called 'mods' which with json can be translated in a list of modnames
-so that it can be checked with checkMods.
-handle_game_launch should have a new key in the form of mods, which is a list of modnames
-to be checked with checkMods.
-
-Stuff to be removed:
-In _gameswidget.py in hostGameCLicked setActiveMods is called.
-This should be done in the faf.exe.check function or in the lobby code.
-It is here because the server doesn't yet send the mods info.
-
-The tempAddMods function should be removed after the server can return mods in the modvault.
-"""
-
-import os
-
-import zipfile
-
-from PyQt5 import QtCore, QtWidgets, QtGui
-
-from modvault.utils import *
-from .modwidget import ModWidget
-from .uploadwidget import UploadModWidget
-from .uimodwidget import UIModWidget
-from ui.busy_widget import BusyWidget
-
-import util
-import logging
-import time
-logger = logging.getLogger(__name__)
-import urllib.request, urllib.error, urllib.parse
-
-from util import datetostr, now
-d = datetostr(now())
-
-from downloadManager import PreviewDownloadRequest
-
-"""
-tempmod1 = dict(uid=1,name='Mod1', comments=[],bugreports=[], date = d,
- ui=True, downloads=0, likes=0,
- thumbnail='',author='johnie102',
- description='Lorem ipsum dolor sit amet, consectetur adipiscing elit. ',)
-"""
-
-FormClass, BaseClass = util.THEME.loadUiType("modvault/modvault.ui")
-
-
-class ModVault(FormClass, BaseClass, BusyWidget):
- def __init__(self, client, *args, **kwargs):
- QtCore.QObject.__init__(self, *args, **kwargs)
-
- self.setupUi(self)
-
- self.client = client
-
- logger.debug("Mod Vault tab instantiating")
- self.loaded = False
-
- self.modList.setItemDelegate(ModItemDelegate(self))
- self.modList.itemDoubleClicked.connect(self.modClicked)
- self.searchButton.clicked.connect(self.search)
- self.searchInput.returnPressed.connect(self.search)
- self.uploadButton.clicked.connect(self.openUploadForm)
- self.UIButton.clicked.connect(self.openUIModForm)
-
- self.SortType.setCurrentIndex(2)
- self.SortType.currentIndexChanged.connect(self.sortChanged)
- self.ShowType.currentIndexChanged.connect(self.showChanged)
-
- self.client.lobby_info.modVaultInfo.connect(self.modInfo)
-
- self.sortType = "rating"
- self.showType = "all"
- self.searchString = ""
-
- self.mods = {}
- self.uids = [mod.uid for mod in getInstalledMods()]
-
- @QtCore.pyqtSlot(dict)
- def modInfo(self, message): # this is called when the database has send a mod to us
- """
- See above for the keys neccessary in message.
- """
- uid = message["uid"]
- if not uid in self.mods:
- mod = ModItem(self, uid)
- self.mods[uid] = mod
- self.modList.addItem(mod)
- else:
- mod = self.mods[uid]
- mod.update(message)
- self.modList.sortItems(1)
-
- @QtCore.pyqtSlot(int)
- def sortChanged(self, index):
- if index == -1 or index == 0:
- self.sortType = "alphabetical"
- elif index == 1:
- self.sortType = "date"
- elif index == 2:
- self.sortType = "rating"
- elif index == 3:
- self.sortType = "downloads"
- self.updateVisibilities()
-
- @QtCore.pyqtSlot(int)
- def showChanged(self, index):
- if index == -1 or index == 0:
- self.showType = "all"
- elif index == 1:
- self.showType = "ui"
- elif index == 2:
- self.showType = "sim"
- elif index == 5:
- self.showType = "yours"
- elif index == 6:
- self.showType = "installed"
- self.updateVisibilities()
-
- @QtCore.pyqtSlot(QtWidgets.QListWidgetItem)
- def modClicked(self, item):
- widget = ModWidget(self, item)
- widget.exec_()
-
- def search(self):
- """ Sending search to mod server"""
-
- self.searchString = self.searchInput.text().lower()
- index = self.ShowType.currentIndex()
- typemod = 2
-
- if index == 1:
- typemod = 1
- elif index == 2:
- typemod = 0
-
- self.client.statsServer.send(dict(command="modvault_search", typemod=typemod, search=self.searchString))
-
- self.updateVisibilities()
-
- @QtCore.pyqtSlot()
- def openUIModForm(self):
- dialog = UIModWidget(self)
- dialog.exec_()
-
- @QtCore.pyqtSlot()
- def openUploadForm(self):
- modDir = QtWidgets.QFileDialog.getExistingDirectory(self.client, "Select the mod directory to upload",
- MODFOLDER, QtWidgets.QFileDialog.ShowDirsOnly)
- logger.debug("Uploading mod from: " + modDir)
- if modDir != "":
- if isModFolderValid(modDir):
- # os.chmod(modDir, S_IWRITE) Don't need this at the moment
- modinfofile, modinfo = parseModInfo(modDir)
- if modinfofile.error:
- logger.debug("There were " + str(modinfofile.errors) + " errors and " + str(modinfofile.warnings) +
- " warnings.")
- logger.debug(modinfofile.errorMsg)
- QtWidgets.QMessageBox.critical(self.client, "Lua parsing error", modinfofile.errorMsg +
- "\nMod uploading cancelled.")
- else:
- if modinfofile.warning:
- uploadmod = QtWidgets.QMessageBox.question(self.client, "Lua parsing warning",
- modinfofile.errorMsg +
- "\nDo you want to upload the mod?",
- QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No)
- else:
- uploadmod = QtWidgets.QMessageBox.Yes
- if uploadmod == QtWidgets.QMessageBox.Yes:
- modinfo = ModInfo(**modinfo)
- modinfo.setFolder(os.path.split(modDir)[1])
- modinfo.update()
- dialog = UploadModWidget(self, modDir, modinfo)
- dialog.exec_()
- else:
- QtWidgets.QMessageBox.information(self.client, "Mod selection",
- "This folder doesn't contain a mod_info.lua file")
-
- @QtCore.pyqtSlot()
- def busy_entered(self):
- self.client.lobby_connection.send(dict(command="modvault", type="start"))
-
- def updateVisibilities(self):
- logger.debug("Updating visibilities with sort '%s' and visibility '%s'" % (self.sortType, self.showType))
- for mod in self.mods:
- self.mods[mod].updateVisibility()
- self.modList.sortItems(1)
-
- def downloadMod(self, mod):
- if downloadMod(mod):
- self.client.lobby_connection.send(dict(command="modvault", type="download", uid=mod.uid))
- self.uids = [mod.uid for mod in getInstalledMods()]
- self.updateVisibilities()
- return True
- else:
- return False
-
- def removeMod(self, mod):
- if removeMod(mod):
- self.uids = [m.uid for m in installedMods]
- mod.updateVisibility()
-
-
-# the drawing helper function for the modlist
-class ModItemDelegate(QtWidgets.QStyledItemDelegate):
-
- def __init__(self, *args, **kwargs):
- QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
-
- def paint(self, painter, option, index, *args, **kwargs):
- self.initStyleOption(option, index)
-
- painter.save()
-
- html = QtGui.QTextDocument()
- html.setHtml(option.text)
-
- icon = QtGui.QIcon(option.icon)
- iconsize = icon.actualSize(option.rect.size())
-
- # clear icon and text before letting the control draw itself because we're rendering these parts ourselves
- option.icon = QtGui.QIcon()
- option.text = ""
- option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget)
-
- # Shadow
- painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1, iconsize.width(), iconsize.height(), QtGui.QColor("#202020"))
-
- # Icon
- icon.paint(painter, option.rect.adjusted(5-2, -2, 0, 0), QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
-
- # Frame around the icon
- pen = QtGui.QPen()
- pen.setWidth(1)
- pen.setBrush(QtGui.QColor("#303030")) # FIXME: This needs to come from theme.
- pen.setCapStyle(QtCore.Qt.RoundCap)
- painter.setPen(pen)
- painter.drawRect(option.rect.left()+5-2, option.rect.top()+3, iconsize.width(), iconsize.height())
-
- # Description
- painter.translate(option.rect.left() + iconsize.width() + 10, option.rect.top()+4)
- clip = QtCore.QRectF(0, 0, option.rect.width()-iconsize.width() - 10 - 5, option.rect.height())
- html.drawContents(painter, clip)
-
- painter.restore()
-
- def sizeHint(self, option, index, *args, **kwargs):
- self.initStyleOption(option, index)
-
- html = QtGui.QTextDocument()
- html.setHtml(option.text)
- html.setTextWidth(ModItem.TEXTWIDTH)
- return QtCore.QSize(ModItem.ICONSIZE + ModItem.TEXTWIDTH + ModItem.PADDING, ModItem.ICONSIZE + ModItem.PADDING)
-
-
-class ModItem(QtWidgets.QListWidgetItem):
- TEXTWIDTH = 230
- ICONSIZE = 100
- PADDING = 10
-
- WIDTH = ICONSIZE + TEXTWIDTH
- #DATA_PLAYERS = 32
-
- FORMATTER_MOD = str(util.THEME.readfile("modvault/modinfo.qthtml"))
- FORMATTER_MOD_UI = str(util.THEME.readfile("modvault/modinfoui.qthtml"))
-
- def __init__(self, parent, uid, *args, **kwargs):
- QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
-
- self.parent = parent
- self.uid = uid
- self.name = ""
- self.description = ""
- self.author = ""
- self.version = 0
- self.downloads = 0
- self.likes = 0
- self.played = 0
- self.comments = [] # every element is a dictionary with a
- self.bugreports = [] # text, author and date key
- self.date = None
- self.isuidmod = False
- self.uploadedbyuser = False
-
- self.thumbnail = None
- self.link = ""
- self.loadThread = None
- self.setHidden(True)
-
- self._map_dl_request = PreviewDownloadRequest()
- self._map_dl_request.done.connect(self._on_mod_downloaded)
-
- def update(self, dic):
- self.name = dic["name"]
- self.played = dic["played"]
- self.description = dic["description"]
- self.version = dic["version"]
- self.author = dic["author"]
- self.downloads = dic["downloads"]
- self.likes = dic["likes"]
- self.comments = dic["comments"]
- self.bugreports = dic["bugreports"]
- self.date = QtCore.QDateTime.fromTime_t(dic['date']).toString("yyyy-MM-dd")
- self.isuimod = dic["ui"]
- self.link = dic["link"] # Direct link to the zip file.
- self.thumbstr = dic["thumbnail"] # direct url to the thumbnail file.
- self.uploadedbyuser = (self.author == self.parent.client.login)
-
- self.thumbnail = None
- if self.thumbstr == "":
- self.setIcon(util.THEME.icon("games/unknown_map.png"))
- else:
- name = os.path.basename(urllib.parse.unquote(self.thumbstr))
- img = getIcon(name)
- if img:
- self.setIcon(util.THEME.icon(img, False))
- else:
- self.parent.client.mod_downloader.download_preview(name, self._map_dl_request, self.thumbstr)
- self.updateVisibility()
-
- def _on_mod_downloaded(self, modname, result):
- path, is_local = result
- icon = util.THEME.icon(path, is_local)
- self.setIcon(icon)
-
- def updateIcon(self):
- self.setIcon(self.thumbnail)
-
- def shouldBeVisible(self):
- p = self.parent
- if p.searchString != "":
- if not (self.author.lower().find(p.searchString) != -1 or self.name.lower().find(p.searchString) != -1 or
- self.description.lower().find(" " + p.searchString + " ") != -1):
- return False
- if p.showType == "all":
- return True
- elif p.showType == "ui":
- return self.isuimod
- elif p.showType == "sim":
- return not self.isuimod
- elif p.showType == "yours":
- return self.uploadedbyuser
- elif p.showType == "installed":
- return self.uid in self.parent.uids
- else: # shouldn't happen
- return True
-
- def updateVisibility(self):
- self.setHidden(not self.shouldBeVisible())
- if len(self.description) < 200:
- descr = self.description
- else:
- descr = self.description[:197] + "..."
-
- modtype = ""
- if self.isuimod:
- modtype = "UI mod"
- if self.uid in self.parent.uids:
- color = "green"
- else:
- color = "white"
-
- if self.isuimod:
- self.setText(self.FORMATTER_MOD_UI.format(color=color, version=str(self.version), title=self.name,
- description=descr, author=self.author,
- downloads=str(self.downloads), likes=str(self.likes),
- date=str(self.date), modtype=modtype))
- else:
- self.setText(self.FORMATTER_MOD.format(color=color, version=str(self.version), title=self.name,
- description=descr, author=self.author, downloads=str(self.downloads),
- likes=str(self.likes), date=str(self.date), modtype=modtype,
- played=str(self.played)))
-
- self.setToolTip('
%s
' % self.description)
-
- def __ge__(self, other):
- return not self.__lt__(self, other)
-
- def __lt__(self, other):
- if self.parent.sortType == "alphabetical":
- if self.name.lower() == other.name.lower():
- return self.uid < other.uid
- return self.name.lower() > other.name.lower()
- elif self.parent.sortType == "rating":
- if self.likes == other.likes:
- return self.downloads < other.downloads
- return self.likes < other.likes
- elif self.parent.sortType == "downloads":
- if self.downloads == other.downloads:
- return self.date < other.date
- return self.downloads < other.downloads
- elif self.parent.sortType == "date":
- # guard
- if self.date is None:
- return other.date is not None
- if self.date == other.date:
- return self.name.lower() < other.name.lower()
- return self.date < other.date
diff --git a/src/modvault/modwidget.py b/src/modvault/modwidget.py
deleted file mode 100644
index 55cb914e2..000000000
--- a/src/modvault/modwidget.py
+++ /dev/null
@@ -1,168 +0,0 @@
-
-import urllib.request, urllib.error, urllib.parse
-
-from PyQt5 import QtCore, QtWidgets, QtGui
-
-from util import strtodate, datetostr, now
-import util
-
-FormClass, BaseClass = util.THEME.loadUiType("modvault/mod.ui")
-
-
-class ModWidget(FormClass, BaseClass):
- def __init__(self, parent, mod, *args, **kwargs):
- BaseClass.__init__(self, *args, **kwargs)
-
- self.setupUi(self)
- self.parent = parent
-
- self.setStyleSheet(self.parent.client.styleSheet())
-
- self.setWindowTitle(mod.name)
-
- self.mod = mod
-
- self.Title.setText(mod.name)
- self.Description.setText(mod.description)
- modtext = ""
- if mod.isuimod: modtext = "UI mod\n"
- self.Info.setText(modtext + "By %s\nUploaded %s" % (mod.author, str(mod.date)))
- if mod.thumbnail is None:
- self.Picture.setPixmap(util.THEME.pixmap("games/unknown_map.png"))
- else:
- self.Picture.setPixmap(mod.thumbnail.pixmap(100, 100))
-
- #self.Comments.setItemDelegate(CommentItemDelegate(self))
- #self.BugReports.setItemDelegate(CommentItemDelegate(self))
-
- self.tabWidget.setEnabled(False)
-
- if self.mod.uid in self.parent.uids:
- self.DownloadButton.setText("Remove Mod")
- self.DownloadButton.clicked.connect(self.download)
-
- #self.likeButton.clicked.connect(self.like)
- #self.LineComment.returnPressed.connect(self.addComment)
- #self.LineBugReport.returnPressed.connect(self.addBugReport)
-
- #for item in mod.comments:
- # comment = CommentItem(self,item["uid"])
- # comment.update(item)
- # self.Comments.addItem(comment)
- #for item in mod.bugreports:
- # comment = CommentItem(self,item["uid"])
- # comment.update(item)
- # self.BugReports.addItem(comment)
-
- self.likeButton.setEnabled(False)
- self.LineComment.setEnabled(False)
- self.LineBugReport.setEnabled(False)
-
- @QtCore.pyqtSlot()
- def download(self):
- if self.mod.uid not in self.parent.uids:
- self.parent.downloadMod(self.mod)
- self.done(1)
- else:
- show = QtWidgets.QMessageBox.question(self.parent.client, "Delete Mod",
- "Are you sure you want to delete this mod?",
- QtWidgets.QMessageBox.Yes, QtWidgets.QMessageBox.No)
- if show == QtWidgets.QMessageBox.Yes:
- self.parent.removeMod(self.mod)
- self.done(1)
-
- @QtCore.pyqtSlot()
- def addComment(self):
- if self.LineComment.text() == "":
- return
- comment = {"author": self.parent.client.login, "text": self.LineComment.text(),
- "date": datetostr(now()), "uid": "%s-%s" % (self.mod.uid, str(len(self.mod.bugreports) +
- len(self.mod.comments)).zfill(3))}
-
- self.parent.client.lobby_connection.send(dict(command="modvault", type="addcomment", moduid=self.mod.uid,
- comment=comment))
- c = CommentItem(self, comment["uid"])
- c.update(comment)
- self.Comments.addItem(c)
- self.mod.comments.append(comment)
- self.LineComment.setText("")
-
- @QtCore.pyqtSlot()
- def addBugReport(self):
- if self.LineBugReport.text() == "":
- return
- bugreport = {"author": self.parent.client.login, "text": self.LineBugReport.text(),
- "date": datetostr(now()), "uid": "%s-%s" % (self.mod.uid, str(len(self.mod.bugreports) +
- len(self.mod.comments)).zfill(3))}
-
- self.parent.client.lobby_connection.send(dict(command="modvault", type="addbugreport", moduid=self.mod.uid,
- bugreport=bugreport))
- c = CommentItem(self, bugreport["uid"])
- c.update(bugreport)
- self.BugReports.addItem(c)
- self.mod.bugreports.append(bugreport)
- self.LineBugReport.setText("")
-
- @QtCore.pyqtSlot()
- def like(self): # the server should determine if the user hasn't already clicked the like button for this mod.
- self.parent.client.lobby_connection.send(dict(command="modvault", type="like", uid=self.mod.uid))
- self.likeButton.setEnabled(False)
-
-
-class CommentItemDelegate(QtWidgets.QStyledItemDelegate):
- TEXTWIDTH = 350
- TEXTHEIGHT = 60
-
- def __init__(self, *args, **kwargs):
- QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
-
- def paint(self, painter, option, index, *args, **kwargs):
- self.initStyleOption(option, index)
-
- painter.save()
-
- html = QtGui.QTextDocument()
- html.setHtml(option.text)
-
- option.text = ""
- option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget)
-
- # Description
- painter.translate(option.rect.left() + 10, option.rect.top()+10)
- clip = QtCore.QRectF(0, 0, option.rect.width(), option.rect.height())
- html.drawContents(painter, clip)
-
- painter.restore()
-
- def sizeHint(self, option, index, *args, **kwargs):
- self.initStyleOption(option, index)
-
- html = QtGui.QTextDocument()
- html.setHtml(option.text)
- html.setTextWidth(self.TEXTWIDTH)
- return QtCore.QSize(self.TEXTWIDTH, self.TEXTHEIGHT)
-
-
-class CommentItem(QtWidgets.QListWidgetItem):
- FORMATTER_COMMENT = str(util.THEME.readfile("modvault/comment.qthtml"))
-
- def __init__(self, parent, uid, *args, **kwargs):
- QtWidgets.QListWidgetItem.__init__(self, *args, **kwargs)
-
- self.parent = parent
- self.uid = uid
- self.text = ""
- self.author = ""
- self.date = None
-
- def update(self, dic):
- self.text = dic["text"]
- self.author = dic["author"]
- self.date = strtodate(dic["date"])
- self.setText(self.FORMATTER_COMMENT.format(text=self.text, author=self.author, date=str(self.date)))
-
- def __ge__(self, other):
- return self.date > other.date
-
- def __lt__(self, other):
- return self.date <= other.date
diff --git a/src/modvault/uimodwidget.py b/src/modvault/uimodwidget.py
deleted file mode 100644
index 06c3f1f45..000000000
--- a/src/modvault/uimodwidget.py
+++ /dev/null
@@ -1,55 +0,0 @@
-
-import urllib.request, urllib.error, urllib.parse
-
-from PyQt5 import QtCore, QtWidgets
-
-import modvault
-import util
-
-FormClass, BaseClass = util.THEME.loadUiType("modvault/uimod.ui")
-
-
-class UIModWidget(FormClass, BaseClass):
- FORMATTER_UIMOD = str(util.THEME.readfile("modvault/uimod.qthtml"))
-
- def __init__(self, parent, *args, **kwargs):
- BaseClass.__init__(self, *args, **kwargs)
-
- self.setupUi(self)
- self.parent = parent
-
- self.setStyleSheet(self.parent.client.styleSheet())
-
- self.setWindowTitle("Ui Mod Manager")
-
- self.doneButton.clicked.connect(self.doneClicked)
- self.modList.itemEntered.connect(self.hoverOver)
- allmods = modvault.getInstalledMods()
- self.uimods = {}
- for mod in allmods:
- if mod.ui_only:
- self.uimods[mod.totalname] = mod
- self.modList.addItem(mod.totalname)
-
- names = [mod.totalname for mod in modvault.getActiveMods(uimods=True)]
- for name in names:
- l = self.modList.findItems(name, QtCore.Qt.MatchExactly)
- if l:
- l[0].setSelected(True)
-
- if len(self.uimods) != 0:
- self.hoverOver(self.modList.item(0))
-
- @QtCore.pyqtSlot()
- def doneClicked(self):
- selected_mods = [self.uimods[str(item.text())] for item in self.modList.selectedItems()]
- succes = modvault.setActiveMods(selected_mods, False)
- if not succes:
- QtWidgets.QMessageBox.information(None, "Error", "Could not set the active UI mods. Maybe something is "
- "wrong with your game.prefs file. Please send your log.")
- self.done(1)
-
- @QtCore.pyqtSlot(QtWidgets.QListWidgetItem)
- def hoverOver(self, item):
- mod = self.uimods[str(item.text())]
- self.modInfo.setText(self.FORMATTER_UIMOD.format(name=mod.totalname, description=mod.description))
diff --git a/src/modvault/uploadwidget.py b/src/modvault/uploadwidget.py
deleted file mode 100644
index 4f9952901..000000000
--- a/src/modvault/uploadwidget.py
+++ /dev/null
@@ -1,116 +0,0 @@
-import urllib.request, urllib.error, urllib.parse
-import tempfile
-import zipfile
-import os
-
-from PyQt5 import QtCore, QtWidgets
-
-import modvault
-import util
-
-FormClass, BaseClass = util.THEME.loadUiType("modvault/upload.ui")
-
-
-class UploadModWidget(FormClass, BaseClass):
- def __init__(self, parent, modDir, modinfo, *args, **kwargs):
- BaseClass.__init__(self, *args, **kwargs)
-
- self.setupUi(self)
- self.parent = parent
- self.client = self.parent.client
- self.modinfo = modinfo
- self.modDir = modDir
-
- self.setStyleSheet(self.parent.client.styleSheet())
-
- self.setWindowTitle("Uploading Mod")
-
- self.Name.setText(modinfo.name)
- self.Version.setText(str(modinfo.version))
- if modinfo.ui_only:
- self.isUILabel.setText("is UI Only")
- else:
- self.isUILabel.setText("not UI Only")
- self.UID.setText(modinfo.uid)
- self.Description.setPlainText(modinfo.description)
- if modinfo.icon != "":
- self.IconURI.setText(modvault.iconPathToFull(modinfo.icon))
- self.updateThumbnail()
- else:
- self.Thumbnail.setPixmap(util.THEME.pixmap("games/unknown_map.png"))
- self.UploadButton.pressed.connect(self.upload)
-
- @QtCore.pyqtSlot()
- def upload(self):
- n = self.Name.text()
- if any([(i in n) for i in '"<*>|?/\\:']):
- QtWidgets.QMessageBox.information(self.client, "Invalid Name",
- "The mod name contains invalid characters: /\\<>|?:\"")
- return
-
- iconpath = modvault.iconPathToFull(self.modinfo.icon)
- infolder = False
- if iconpath != "" and os.path.commonprefix([os.path.normcase(self.modDir), os.path.normcase(iconpath)]) == \
- os.path.normcase(self.modDir): # the icon is in the game folder
- localpath = modvault.fullPathToIcon(iconpath)
- infolder = True
- if iconpath != "" and not infolder:
- QtWidgets.QMessageBox.information(self.client, "Invalid Icon File",
- "The file %s is not located inside the modfolder. Copy the icon file to "
- "your modfolder and change the mod_info.lua accordingly" % iconpath)
- return
-
- try:
- temp = tempfile.NamedTemporaryFile(mode='w+b', suffix=".zip", delete=False)
- zipped = zipfile.ZipFile(temp, "w", zipfile.ZIP_DEFLATED)
- zipdir(self.modDir, zipped, os.path.basename(self.modDir))
- zipped.close()
- temp.flush()
- except:
- QtWidgets.QMessageBox.critical(self.client, "Mod uploading error", "Something went wrong zipping the mod files.")
- return
- qfile = QtCore.QFile(temp.name)
-
- # The server should check again if there is already a mod with this name or UID.
- self.client.lobby_connection.writeToServer("UPLOAD_MOD", "%s.v%04d.zip" % (self.modinfo.name, self.modinfo.version), self.modinfo.to_dict(), qfile)
-
- @QtCore.pyqtSlot()
- def updateThumbnail(self):
- iconfilename = modvault.iconPathToFull(self.modinfo.icon)
- if iconfilename == "":
- return False
- if os.path.splitext(iconfilename)[1].lower() == ".dds":
- old = iconfilename
- iconfilename = os.path.join(self.modDir, os.path.splitext(os.path.basename(iconfilename))[0] + ".png")
- succes = modvault.generateThumbnail(old, iconfilename)
- if not succes:
- QtWidgets.QMessageBox.information(self.client, "Invalid Icon File",
- "Because FAF can't read DDS files, it tried to convert it to a png. "
- "This failed. Try something else")
- return False
- try:
- self.Thumbnail.setPixmap(util.THEME.pixmap(iconfilename, False))
- except:
- QtWidgets.QMessageBox.information(self.client, "Invalid Icon File",
- "This was not a valid icon file. Please pick a png or jpeg")
- return False
- self.modinfo.thumbnail = modvault.fullPathToIcon(iconfilename)
- self.IconURI.setText(iconfilename)
- return True
-
-
-# from http://stackoverflow.com/questions/1855095/how-to-create-a-zip-archive-of-a-directory-in-python
-def zipdir(path, zipf, fname):
- # zips the entire directory path to zipf. Every file in the zipfile starts with fname.
- # So if path is "/foo/bar/hello" and fname is "test" then every file in zipf is of the form "/test/*.*"
- path = os.path.normcase(path)
- if path[-1] in r'\/':
- path = path[:-1]
- short = os.path.split(path)[0]
- for root, dirs, files in os.walk(path):
- for f in files:
- name = os.path.join(os.path.normcase(root), f)
- n = name[len(os.path.commonprefix([name, path])):]
- if n[0] == "\\":
- n = n[1:]
- zipf.write(name, os.path.join(fname, n))
diff --git a/src/news/__init__.py b/src/news/__init__.py
index 97c382b5d..fbc0740e2 100644
--- a/src/news/__init__.py
+++ b/src/news/__init__.py
@@ -1,7 +1,11 @@
-from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
-from PyQt5 import QtWidgets, QtCore
-
from ._newswidget import NewsWidget
from .newsitem import NewsItem
from .newsmanager import NewsManager
from .wpapi import WPAPI
+
+__all__ = (
+ "NewsWidget",
+ "NewsItem",
+ "NewsManager",
+ "WPAPI",
+)
diff --git a/src/news/_newswidget.py b/src/news/_newswidget.py
index c5a2642bc..d873952bf 100644
--- a/src/news/_newswidget.py
+++ b/src/news/_newswidget.py
@@ -1,86 +1,160 @@
-from PyQt5 import QtCore, QtWidgets
-from PyQt5.QtCore import Qt
-
-import webbrowser
-import util
-import re
-from .newsitem import NewsItem, NewsItemDelegate
-from .newsmanager import NewsManager
-
-from util.qt import ExternalLinkPage
-
-import base64
-
import logging
+import os.path
-logger = logging.getLogger(__name__)
-
-
-class Hider(QtCore.QObject):
- """
- Hides a widget by blocking its paint event. This is useful if a
- widget is in a layout that you do not want to change when the
- widget is hidden.
- """
- def __init__(self, parent=None):
- super(Hider, self).__init__(parent)
+from PyQt6 import QtWidgets
+from PyQt6.QtCore import QPoint
+from PyQt6.QtCore import QSize
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QImage
+from PyQt6.QtGui import QTextDocument
+from PyQt6.QtNetwork import QNetworkAccessManager
- def eventFilter(self, obj, ev):
- return ev.type() == QtCore.QEvent.Paint
+import util
+from config import Settings
+from downloadManager import Downloader
+from downloadManager import DownloadRequest
- def hide(self, widget):
- widget.installEventFilter(self)
- widget.update()
+from .newsitem import NewsItem
+from .newsitem import NewsItemDelegate
+from .newsmanager import NewsManager
- def unhide(self, widget):
- widget.removeEventFilter(self)
- widget.update()
+logger = logging.getLogger(__name__)
- def hideWidget(self, sender):
- if sender.isWidgetType():
- self.hide(sender)
FormClass, BaseClass = util.THEME.loadUiType("news/news.ui")
class NewsWidget(FormClass, BaseClass):
- CSS = util.THEME.readstylesheet('news/news_webview.css')
+ CSS = util.THEME.readstylesheet('news/news_style.css')
- HTML = str(util.THEME.readfile('news/news_webview_frame.html'))
+ HTML = util.THEME.readfile('news/news_page.html')
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
BaseClass.__init__(self, *args, **kwargs)
self.setupUi(self)
+ self.nam = QNetworkAccessManager()
+ self._downloader = Downloader(util.NEWS_CACHE_DIR)
+ self._images_dl_request = DownloadRequest()
+ self._images_dl_request.done.connect(self.item_image_downloaded)
+
self.newsManager = NewsManager(self)
+ self.newsItems = []
# open all links in external browser
- self.newsWebView.setPage(ExternalLinkPage(self))
+ self.newsTextBrowser.setOpenExternalLinks(True)
- # hide webview until loaded to avoid FOUC
- self.hider = Hider()
- self.hider.hide(self.newsWebView)
- self.newsWebView.loadFinished.connect(self.loadFinished)
+ self.settingsFrame.hide()
+ self.hideNewsEdit.setText(Settings.get('news/hideWords', ""))
- self.newsList.setIconSize(QtCore.QSize(0, 0))
+ self.newsList.setIconSize(QSize(0, 0))
self.newsList.setItemDelegate(NewsItemDelegate(self))
self.newsList.currentItemChanged.connect(self.itemChanged)
+ self.newsSettings.pressed.connect(self.showSettings)
+ self.showAllButton.pressed.connect(self.showAll)
+ self.hideNewsEdit.textEdited.connect(self.updateNewsFilter)
+ self.hideNewsEdit.cursorPositionChanged.connect(self.showEditToolTip)
def addNews(self, newsPost):
newsItem = NewsItem(newsPost, self.newsList)
-
- # QtWebEngine has no user CSS support yet, so let's just prepend it to the HTML
- def _injectCSS(self, body):
- return ''.format(self.CSS) + body
-
- def itemChanged(self, current, previous):
- self.newsWebView.page().setHtml(self.HTML.format(title=current.newsPost['title'],
- content=self._injectCSS(current.newsPost['body']),))
-
- def linkClicked(self, url):
- webbrowser.open(url.toString())
-
- def loadFinished(self, ok):
- self.hider.unhide(self.newsWebView)
- self.newsWebView.loadFinished.disconnect(self.loadFinished)
+ self.newsItems.append(newsItem)
+
+ def download_image(self, img_url: str) -> None:
+ name = os.path.basename(img_url)
+ self._downloader.download(name, self._images_dl_request, img_url)
+
+ def add_image_resource(self, image_name: str, image_path: str) -> None:
+ doc = self.newsTextBrowser.document()
+ if doc.resource(QTextDocument.ResourceType.ImageResource, QUrl(image_name)):
+ return
+ img = QImage(image_path)
+ scaled = img.scaled(QSize(900, 500))
+ doc.addResource(QTextDocument.ResourceType.ImageResource, QUrl(image_name), scaled)
+
+ def item_image_downloaded(self, image_name: str, result: tuple[str, bool]) -> None:
+ image_path, download_failed = result
+ if not download_failed:
+ self.add_image_resource(image_name, image_path)
+ self.show_newspage()
+
+ def itemChanged(self, current: NewsItem | None, previous: NewsItem | None) -> None:
+ if current is None:
+ return
+
+ url = current.newsPost["img_url"]
+ image_name = os.path.basename(url)
+ image_path = os.path.join(util.NEWS_CACHE_DIR, image_name)
+ if os.path.isfile(image_path):
+ self.add_image_resource(image_name, image_path)
+ self.show_newspage()
+ else:
+ self._downloader.download(image_name, self._images_dl_request, url)
+
+ def show_newspage(self) -> None:
+ current = self.newsList.currentItem()
+
+ if current.newsPost['external_link'] == '':
+ external_link = current.newsPost['link']
+ else:
+ external_link = current.newsPost['external_link']
+
+ image_name = os.path.basename(current.newsPost["img_url"])
+ content = current.newsPost["excerpt"].strip().removeprefix("
").removesuffix("
")
+ html = self.HTML.format(
+ style=self.CSS,
+ title=current.newsPost['title'],
+ content=content,
+ img_source=image_name,
+ external_link=external_link,
+ )
+ self.newsTextBrowser.setHtml(html)
+
+ def showAll(self):
+ for item in self.newsItems:
+ item.setHidden(False)
+ self.updateLabel(0)
+
+ def showEditToolTip(self) -> None:
+ """
+ Default tooltips are too slow and disappear when user starts typing
+ """
+ widget = self.hideNewsEdit
+ position = widget.mapToGlobal(
+ QPoint(0 + widget.width(), 0 - widget.height() / 2),
+ )
+ QtWidgets.QToolTip.showText(
+ position,
+ "To separate multiple words use commas: nomads,server,dev",
+ )
+
+ def showSettings(self):
+ if self.settingsFrame.isHidden():
+ self.settingsFrame.show()
+ else:
+ self.settingsFrame.hide()
+
+ def updateNewsFilter(self, text=False):
+ if text is not False:
+ Settings.set('news/hideWords', text)
+
+ filterList = Settings.get('news/hideWords', "").lower().split(",")
+ newsHidden = 0
+
+ if filterList[0]:
+ for item in self.newsItems:
+ for word in filterList:
+ if word in item.text().lower():
+ item.setHidden(True)
+ newsHidden += 1
+ break
+ else:
+ item.setHidden(False)
+ else:
+ for item in self.newsItems:
+ item.setHidden(False)
+
+ self.updateLabel(newsHidden)
+
+ def updateLabel(self, number):
+ self.totalHidden.setText("NEWS HIDDEN: " + str(number))
diff --git a/src/news/newsitem.py b/src/news/newsitem.py
index 81ac4daa1..b3c27e240 100644
--- a/src/news/newsitem.py
+++ b/src/news/newsitem.py
@@ -1,19 +1,21 @@
-from PyQt5 import QtCore, QtGui, QtWidgets
+import logging
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
import util
-import client
-import logging
logger = logging.getLogger(__name__)
class NewsItemDelegate(QtWidgets.QStyledItemDelegate):
- def __init__(self, *args, **kwargs):
+ def __init__(self, *args, **kwargs) -> None:
QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
html = QtGui.QTextDocument()
to = QtGui.QTextOption()
- to.setWrapMode(QtGui.QTextOption.WordWrap)
+ to.setWrapMode(QtGui.QTextOption.WrapMode.WordWrap)
html.setDefaultTextOption(to)
html.setTextWidth(NewsItem.TEXTWIDTH)
@@ -26,30 +28,40 @@ def paint(self, painter, option, index, *args, **kwargs):
self.html.setHtml(option.text)
- icon = QtGui.QIcon(option.icon)
-
- # clear icon and text before letting the control draw itself because we're rendering these parts ourselves
+ # clear icon and text before letting the control draw itself because
+ # we're rendering these parts ourselves
option.icon = QtGui.QIcon()
- option.text = ""
- option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget)
+ option.text = ""
+ option.widget.style().drawControl(
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
+ )
# Shadow (100x100 shifted 8 right and 8 down)
-# painter.fillRect(option.rect.left()+8, option.rect.top()+8, 100, 100, QtGui.QColor("#202020"))
+ # painter.fillRect(option.rect.left()+8, option.rect.top()+8,
+ # 100, 100, QtGui.QColor("#202020"))
-# # Icon (110x110 adjusted: shifts top,left 3 and bottom,right -7 -> makes/clips it to 100x100)
-# icon.paint(painter, option.rect.adjusted(3, 3, -7, -7), QtCore.Qt.AlignLeft|QtCore.Qt.AlignTop)
+ # Icon (110x110 adjusted: shifts top,left 3 and bottom,right -7 ->
+ # makes/clips it to 100x100)
+ # icon.paint(painter, option.rect.adjusted(3, 3, -7, -7),
+ # QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
# Frame around the icon (100x100 shifted 3 right and 3 down)
-# pen = QtWidgets.QPen()
-# pen.setWidth(1)
-# pen.setBrush(QtGui.QColor("#303030")) # FIXME: This needs to come from theme.
-# pen.setCapStyle(QtCore.Qt.RoundCap)
-# painter.setPen(pen)
-# painter.drawRect(option.rect.left() + 3, option.rect.top() + 3, 100, 100)
-
- # Description (text right of map icon(100), shifted 10 more right and 10 down)
- painter.translate(option.rect.left() + 10, option.rect.top()+10)
- clip = QtCore.QRectF(0, 0, option.rect.width() - 10 - 5, option.rect.height())
+ # pen = QtWidgets.QPen()
+ # pen.setWidth(1)
+ # FIXME: This needs to come from theme.
+ # pen.setBrush(QtGui.QColor("#303030"))
+
+ # pen.setCapStyle(QtCore.Qt.RoundCap)
+ # painter.setPen(pen)
+ # painter.drawRect(option.rect.left() + 3, option.rect.top() + 3,
+ # 100, 100)
+
+ # Description (text right of map icon(100), shifted 10 more right and
+ # 10 down)
+ painter.translate(option.rect.left() + 10, option.rect.top() + 10)
+ clip = QtCore.QRectF(
+ 0, 0, option.rect.width() - 10 - 5, option.rect.height(),
+ )
self.html.drawContents(painter, clip)
painter.restore()
@@ -59,7 +71,9 @@ def sizeHint(self, option, index, *args, **kwargs):
self.html.setHtml(option.text)
- return QtCore.QSize(NewsItem.TEXTWIDTH + NewsItem.PADDING, NewsItem.TEXTHEIGHT)
+ return QtCore.QSize(
+ NewsItem.TEXTWIDTH + NewsItem.PADDING, NewsItem.TEXTHEIGHT,
+ )
class NewsItem(QtWidgets.QListWidgetItem):
@@ -74,11 +88,13 @@ def __init__(self, newsPost, *args, **kwargs):
self.newsPost = newsPost
- self.setText(self.FORMATTER.format(
- author=newsPost['author'][0]['name'],
- date=newsPost['date'],
- title=newsPost['title']
- ))
+ self.setText(
+ self.FORMATTER.format(
+ author=newsPost['author'][0]['name'],
+ date=newsPost['date'],
+ title=newsPost['title'],
+ ),
+ )
def __ge__(self, other):
""" Comparison operator used for item list sorting """
diff --git a/src/news/newsmanager.py b/src/news/newsmanager.py
index 23a87fa4b..b2707933d 100644
--- a/src/news/newsmanager.py
+++ b/src/news/newsmanager.py
@@ -1,13 +1,13 @@
-from PyQt5 import QtCore
-from PyQt5.QtCore import QObject, Qt
+import logging
-from .newsitem import NewsItem
-from .wpapi import WPAPI
+from PyQt6 import QtCore
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import Qt
import client
-import math
-import logging
+from .wpapi import WPAPI
+
logger = logging.getLogger(__name__)
@@ -17,20 +17,20 @@ class NewsManager(QObject):
def __init__(self, client):
QObject.__init__(self)
self.widget = client
-# self.newsContent = []
-# self.newsFrames = []
-# self.selectedFrame = None
-# self.page = 0
-#
-# for i in range(self.FRAMES):
-# frame = NewsFrame()
-# self.newsFrames.append(frame)
-# client.newsAreaLayout.addWidget(frame)
-# frame.clicked.connect(self.frameClicked)
-#
-# client.nextPageButton.clicked.connect(self.nextPage)
-# client.prevPageButton.clicked.connect(self.prevPage)
-# client.pageBox.currentIndexChanged.connect(self.selectPage)
+ # self.newsContent = []
+ # self.newsFrames = []
+ # self.selectedFrame = None
+ # self.page = 0
+
+ # for i in range(self.FRAMES):
+ # frame = NewsFrame()
+ # self.newsFrames.append(frame)
+ # client.newsAreaLayout.addWidget(frame)
+ # frame.clicked.connect(self.frameClicked)
+
+ # client.nextPageButton.clicked.connect(self.nextPage)
+ # client.prevPageButton.clicked.connect(self.prevPage)
+ # client.pageBox.currentIndexChanged.connect(self.selectPage)
self.WpApi = WPAPI(client)
self.WpApi.newsDone.connect(self.on_wpapi_done)
@@ -39,7 +39,8 @@ def __init__(self, client):
@QtCore.pyqtSlot(list)
def on_wpapi_done(self, items):
"""
- Reinitialize the whole news conglomerate after downloading the news from the api.
+ Reinitialize the whole news conglomerate after downloading the news
+ from the api.
items is a list of (title, content) tuples.
@@ -47,17 +48,30 @@ def on_wpapi_done(self, items):
"""
for item in items:
self.widget.addNews(item)
- self.widget.newsList.setCurrentItem(self.widget.newsList.item(0))
-# self.newsContent = self.newsContent + items
-#
-# self.npages = int(math.ceil(len(self.newsContent) / self.FRAMES))
-#
-## origpage = self.page
-#
-# pb = client.instance.pageBox
-# pb.insertItems(pb.count(), ['Page {: >2}'.format(x + 1) for x in range(pb.count(), self.npages)])
-#
-# self.selectPage(self.page)
+
+ self.widget.updateNewsFilter()
+ for i in range(0, 10):
+ if not self.widget.newsList.item(i).isHidden():
+ self.widget.newsList.setCurrentItem(
+ self.widget.newsList.item(i),
+ )
+ break
+ # self.newsContent = self.newsContent + items
+
+ # self.npages = int(math.ceil(len(self.newsContent) / self.FRAMES))
+
+ # origpage = self.page
+
+ # pb = client.instance.pageBox
+ # pb.insertItems(
+ # pb.count(),
+ # [
+ # 'Page {: >2}'.format(x + 1)
+ # for x in range(pb.count(), self.npages)
+ # ],
+ # )
+
+ # self.selectPage(self.page)
@QtCore.pyqtSlot()
def frameClicked(self):
@@ -80,7 +94,7 @@ def expandFrame(self, selectedFrame):
for frame in self.newsFrames:
frame.collapse()
- selectedFrame.expand(Qt.ScrollBarAsNeeded, set_filter=False)
+ selectedFrame.expand(Qt.ScrollBarPolicy.ScrollBarAsNeeded, set_filter=False)
self.selectedFrame = selectedFrame
@@ -88,7 +102,7 @@ def resetFrames(self):
logger.info('resetFrames')
self.selectedFrame = None
for frame in self.newsFrames:
- frame.expand(Qt.ScrollBarAlwaysOff, set_filter=True)
+ frame.expand(Qt.ScrollBarPolicy.ScrollBarAlwaysOff, set_filter=True)
def nextPage(self):
pb = client.instance.pageBox
@@ -111,7 +125,7 @@ def selectPage(self, idx):
elif idx == self.npages - 1:
client.instance.nextPageButton.setEnabled(False)
# download next page
- self.WpApi.download(page=self.npages+1, perpage=self.FRAMES)
+ self.WpApi.download(page=self.npages + 1, perpage=self.FRAMES)
firstNewsIdx = idx * self.FRAMES
diff --git a/src/news/wpapi.py b/src/news/wpapi.py
index 82c703eaa..15a6ad780 100644
--- a/src/news/wpapi.py
+++ b/src/news/wpapi.py
@@ -1,14 +1,19 @@
-from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
-from PyQt5 import QtCore
-
import json
import logging
-import sys
+
+from PyQt6 import QtCore
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
+
+from config import Settings
logger = logging.getLogger(__name__)
# FIXME: Make setting
-WPAPI_ROOT = 'http://direct.faforever.com/wp-json/wp/v2/posts?per_page={perpage}&page={page}&_embed=1'
+WPAPI_ROOT = (
+ '{host}/wp-json/wp/v2/posts?per_page={perpage}&page={page}&_embed=1'
+)
class WPAPI(QtCore.QObject):
@@ -41,15 +46,26 @@ def finishedDownload(self, reply):
'body': post.get('content', {}).get('rendered'),
'date': post.get('date'),
'excerpt': post.get('excerpt', {}).get('rendered'),
- 'author': post.get('_embedded', {}).get('author')
+ 'author': post.get('_embedded', {}).get('author'),
+ 'link': post.get('link'),
+ 'external_link': post.get('newshub_externalLinkUrl'),
+ 'img_url': (
+ post.get('_embedded', {})
+ .get('wp:featuredmedia', [{}])[0]
+ .get('source_url', "")
+ ),
}
posts.append(content)
self.newsDone.emit(posts)
- except:
+ except BaseException:
logger.exception('Error handling wp data')
def download(self, page=1, perpage=10):
- url = QtCore.QUrl(WPAPI_ROOT.format(page=page, perpage=perpage))
+ url = QtCore.QUrl(
+ WPAPI_ROOT.format(
+ host=Settings.get('news/host'), page=page, perpage=perpage,
+ ),
+ )
request = QNetworkRequest(url)
self.nam.get(request)
diff --git a/src/notifications/__init__.py b/src/notifications/__init__.py
index 13c072d76..f5a4b74db 100644
--- a/src/notifications/__init__.py
+++ b/src/notifications/__init__.py
@@ -1,20 +1,23 @@
-from PyQt5 import QtCore
-
-import util
-from fa import maps
-from notifications.ns_dialog import NotificationDialog
-from notifications.ns_settings import NsSettingsDialog, IngameNotification
-
"""
The Notification Systems reacts on events and displays a popup.
Each event_type has a NsHook to customize it.
"""
+from PyQt6 import QtCore
+
+import util
+from config import Settings
+from fa import maps
+from notifications.ns_dialog import NotificationDialog
+from notifications.ns_settings import IngameNotification
+from notifications.ns_settings import NsSettingsDialog
class Notifications:
USER_ONLINE = 'user_online'
NEW_GAME = 'new_game'
GAME_FULL = 'game_full'
+ UNOFFICIAL_CLIENT = 'unofficial_client'
+ PARTY_INVITE = 'party_invite'
def __init__(self, client, gameset, playerset, me):
self.client = client
@@ -25,24 +28,35 @@ def __init__(self, client, gameset, playerset, me):
self.events = []
self.disabledStartup = True
self.game_running = False
+ self.unofficialClientDate = Settings.get(
+ 'notifications/unofficialClientDate', 0, type=int,
+ )
- client.gameEnter.connect(self.gameEnter)
- client.gameExit.connect(self.gameExit)
- client.gameFull.connect(self._gamefull)
+ client.game_enter.connect(self.gameEnter)
+ client.game_exit.connect(self.gameExit)
+ client.game_full.connect(self._gamefull)
+ client.unofficial_client.connect(self.unofficialClient)
+ client.party_invite.connect(self.partyInvite)
gameset.newLobby.connect(self._newLobby)
- playerset.playerAdded.connect(self._newPlayer)
+ playerset.added.connect(self._newPlayer)
self.user = util.THEME.icon("client/user.png", pix=True)
def _newPlayer(self, player):
- if self.isDisabled() or not self.settings.popupEnabled(self.USER_ONLINE):
+ if (
+ self.isDisabled()
+ or not self.settings.popupEnabled(self.USER_ONLINE)
+ ):
return
if self.me.player is not None and self.me.player == player:
return
notify_mode = self.settings.getCustomSetting(self.USER_ONLINE, 'mode')
- if notify_mode != 'all' and not self.me.isFriend(player.id):
+ if (
+ notify_mode != 'all'
+ and not self.me.relations.model.is_friend(player.id)
+ ):
return
self.events.append((self.USER_ONLINE, player.copy()))
@@ -55,7 +69,7 @@ def _newLobby(self, game):
host = game.host_player
notify_mode = self.settings.getCustomSetting(self.NEW_GAME, 'mode')
if notify_mode != 'all':
- if host is None or not self.me.isFriend(host):
+ if host is None or not self.me.relations.model.is_friend(host):
return
self.events.append((self.NEW_GAME, game.copy()))
@@ -64,7 +78,30 @@ def _newLobby(self, game):
def _gamefull(self):
if self.isDisabled() or not self.settings.popupEnabled(self.GAME_FULL):
return
- self.events.append((self.GAME_FULL, None))
+ if (self.GAME_FULL, None) not in self.events:
+ self.events.append((self.GAME_FULL, None))
+ self.checkEvent()
+
+ def unofficialClient(self, msg):
+ date = QtCore.QDate.currentDate().dayOfYear()
+ if date == self.unofficialClientDate: # Show once per day
+ return
+
+ self.unofficialClientDate = date
+ Settings.set(
+ 'notifications/unofficialClientDate', self.unofficialClientDate,
+ )
+ self.events.append((self.UNOFFICIAL_CLIENT, msg))
+ self.checkEvent()
+
+ def partyInvite(self, message):
+ notify_mode = self.settings.getCustomSetting(self.PARTY_INVITE, 'mode')
+ if (
+ notify_mode != 'all'
+ and not self.me.relations.model.is_friend(message["sender"])
+ ):
+ return
+ self.events.append((self.PARTY_INVITE, message))
self.checkEvent()
def gameEnter(self):
@@ -79,7 +116,13 @@ def gameExit(self):
def isDisabled(self):
return (
self.disabledStartup
- or self.game_running and self.settings.ingame_notifications == IngameNotification.DISABLE
+ or (
+ self.game_running
+ and (
+ self.settings.ingame_notifications
+ == IngameNotification.DISABLE
+ )
+ )
or not self.settings.enabled
)
@@ -89,7 +132,9 @@ def setNotificationEnabled(self, enabled):
@QtCore.pyqtSlot()
def on_showSettings(self):
- """ Shows a Settings Dialg with all registered notifications modules """
+ """
+ Shows a Settings Dialg with all registered notifications modules
+ """
self.settings.show()
def showEvent(self):
@@ -97,7 +142,8 @@ def showEvent(self):
Display the next event in the queue as popup
Pops event from queue and checks if it is showable as per settings
- If event is showable, process event data and then feed it into notification dialog
+ If event is showable, process event data and then feed it into
+ notification dialog
Returns True if showable event found, False otherwise
"""
@@ -111,8 +157,10 @@ def showEvent(self):
if eventType == self.USER_ONLINE:
player = data
pixmap = self.user
- text = '%s is online' % \
- (player.login)
+ text = (
+ '{} is online'
+ ''.format(player.login)
+ )
elif eventType == self.NEW_GAME:
game = data
preview = maps.preview(game.mapname, pixmap=True)
@@ -134,13 +182,55 @@ def showEvent(self):
if len(modstr) > 20:
modstr = modstr[:15] + "..."
- modhtml = '' if (modstr == '') else ' mods %s' % modstr
- text = '%s on %s%s' % \
- (game.title, maps.getDisplayName(game.mapname), modhtml)
+ if modstr == '':
+ modhtml = ''
+ else:
+ modhtml = (
+ ' mods '
+ '{}'.format(modstr)
+ )
+ text = (
+ '{} on '
+ '{}{}'.format(
+ game.title,
+ maps.getDisplayName(game.mapname),
+ modhtml,
+ )
+ )
elif eventType == self.GAME_FULL:
pixmap = self.user
- text = ' Game is full.'
- self.dialog.newEvent(pixmap, text, self.settings.popup_lifetime, self.settings.soundEnabled(eventType))
+ text = (
+ ' Game is full.'
+ ''
+ )
+ elif eventType == self.UNOFFICIAL_CLIENT:
+ pixmap = self.user
+ text = (
+ ' {}'
+ .format(data)
+ )
+ self.dialog.newEvent(pixmap, text, 10, False, 200)
+ return
+ elif eventType == self.PARTY_INVITE:
+ pixmap = self.user
+
+ text = (
+ '{} invites you to'
+ ' their party'
+ .format(str(self.client.players[data["sender"]].login))
+ )
+ self.dialog.newEvent(
+ pixmap, text, 15,
+ self.settings.soundEnabled(eventType),
+ hide_accept_button=False,
+ sender_id=data["sender"],
+ )
+ return
+
+ self.dialog.newEvent(
+ pixmap, text, self.settings.popup_lifetime,
+ self.settings.soundEnabled(eventType),
+ )
def checkEvent(self):
"""
@@ -148,10 +238,20 @@ def checkEvent(self):
This means:
* There need to be events pending
- * There must be no notification showing right now (i.e. notification dialog hidden)
+ * There must be no notification showing right now
+ (i.e. notification dialog hidden)
* Game isn't running, or ingame notifications are enabled
"""
- if (len(self.events) > 0 and self.dialog.isHidden() and
- (not self.game_running or self.settings.ingame_notifications == IngameNotification.ENABLE)):
- self.showEvent()
\ No newline at end of file
+ if (
+ len(self.events) > 0
+ and self.dialog.isHidden()
+ and (
+ not self.game_running
+ or (
+ self.settings.ingame_notifications
+ == IngameNotification.ENABLE
+ )
+ )
+ ):
+ self.showEvent()
diff --git a/src/notifications/hook_gamefull.py b/src/notifications/hook_gamefull.py
index d910f2872..845f7a194 100644
--- a/src/notifications/hook_gamefull.py
+++ b/src/notifications/hook_gamefull.py
@@ -1,15 +1,10 @@
-from PyQt5 import QtCore
-import util
-import config
-from config import Settings
-from notifications.ns_hook import NsHook
-import notifications as ns
-
"""
Settings for notifications: If a game is full
"""
+import notifications as ns
+from notifications.ns_hook import NsHook
class NsHookGameFull(NsHook):
def __init__(self):
- NsHook.__init__(self, ns.Notifications.GAME_FULL)
\ No newline at end of file
+ NsHook.__init__(self, ns.Notifications.GAME_FULL)
diff --git a/src/notifications/hook_newgame.py b/src/notifications/hook_newgame.py
index 839b694cd..2abbba7b9 100644
--- a/src/notifications/hook_newgame.py
+++ b/src/notifications/hook_newgame.py
@@ -1,13 +1,13 @@
-from PyQt5 import QtCore
-import util
-import config
-from config import Settings
-from notifications.ns_hook import NsHook
-import notifications as ns
-
"""
Settings for notifications: if a new game is hosted.
"""
+from PyQt6 import QtCore
+
+import config
+import notifications as ns
+import util
+from config import Settings
+from notifications.ns_hook import NsHook
class NsHookNewGame(NsHook):
@@ -17,6 +17,7 @@ def __init__(self):
self.dialog = NewGameDialog(self, self.eventType)
self.button.clicked.connect(self.dialog.show)
+
FormClass, BaseClass = util.THEME.loadUiType("notification_system/new_game.ui")
@@ -29,22 +30,30 @@ def __init__(self, parent, eventType):
self.setupUi(self)
# remove help button
- self.setWindowFlags(self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint))
+ self.setWindowFlags(
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
+ )
self.loadSettings()
def loadSettings(self):
- self.mode = Settings.get(self._settings_key+'/mode', 'friends')
+ self.mode = Settings.get(self._settings_key + '/mode', 'friends')
- self.checkBoxFriends.setCheckState(QtCore.Qt.Checked if self.mode == 'friends' else QtCore.Qt.Unchecked)
+ if self.mode == 'friends':
+ self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Checked)
+ else:
+ self.checkBoxFriends.setCheckState(QtCore.Qt.CheckState.Unchecked)
self.parent.mode = self.mode
def saveSettings(self):
- config.Settings.set(self._settings_key+'/mode', self.mode)
+ config.Settings.set(self._settings_key + '/mode', self.mode)
self.parent.mode = self.mode
@QtCore.pyqtSlot()
def on_btnSave_clicked(self):
- self.mode = 'friends' if self.checkBoxFriends.checkState() == QtCore.Qt.Checked else 'all'
+ if self.checkBoxFriends.checkState() == QtCore.Qt.CheckState.Checked:
+ self.mode = 'friends'
+ else:
+ self.mode = 'all'
self.saveSettings()
self.hide()
diff --git a/src/notifications/hook_partyinvite.py b/src/notifications/hook_partyinvite.py
new file mode 100644
index 000000000..a2c0c5fb8
--- /dev/null
+++ b/src/notifications/hook_partyinvite.py
@@ -0,0 +1,57 @@
+"""
+Settings for notifications: if a player comes online
+"""
+from PyQt6 import QtCore
+
+import notifications as ns
+import util
+from config import Settings
+from notifications.ns_hook import NsHook
+
+
+class NsHookPartyInvite(NsHook):
+ def __init__(self):
+ NsHook.__init__(self, ns.Notifications.PARTY_INVITE)
+ self.button.setEnabled(True)
+ self.dialog = PartyInviteDialog(self, self.eventType)
+ self.button.clicked.connect(self.dialog.show)
+
+
+FormClass, BaseClass = util.THEME.loadUiType(
+ "notification_system/party_invite.ui",
+)
+
+
+class PartyInviteDialog(FormClass, BaseClass):
+ def __init__(self, parent, eventType):
+ BaseClass.__init__(self)
+ self.parent = parent
+ self.eventType = eventType
+ self._settings_key = 'notifications/{}'.format(eventType)
+ self.setupUi(self)
+
+ # remove help button
+ self.setWindowFlags(
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
+ )
+
+ self.loadSettings()
+
+ def loadSettings(self):
+ self.mode = Settings.get(self._settings_key + '/mode', 'friends')
+
+ if self.mode == 'friends':
+ self.radioButtonFriends.setChecked(True)
+ else:
+ self.radioButtonAll.setChecked(True)
+ self.parent.mode = self.mode
+
+ def saveSettings(self):
+ Settings.set(self._settings_key + '/mode', self.mode)
+ self.parent.mode = self.mode
+
+ @QtCore.pyqtSlot()
+ def on_btnSave_clicked(self):
+ self.mode = 'friends' if self.radioButtonFriends.isChecked() else 'all'
+ self.saveSettings()
+ self.hide()
diff --git a/src/notifications/hook_useronline.py b/src/notifications/hook_useronline.py
index bd29571f3..d949bb4c2 100644
--- a/src/notifications/hook_useronline.py
+++ b/src/notifications/hook_useronline.py
@@ -1,13 +1,12 @@
-from PyQt5 import QtCore
-import util
-import config
-from config import Settings
-from notifications.ns_hook import NsHook
-import notifications as ns
-
"""
Settings for notifications: if a player comes online
"""
+from PyQt6 import QtCore
+
+import notifications as ns
+import util
+from config import Settings
+from notifications.ns_hook import NsHook
class NsHookUserOnline(NsHook):
@@ -17,7 +16,10 @@ def __init__(self):
self.dialog = UserOnlineDialog(self, self.eventType)
self.button.clicked.connect(self.dialog.show)
-FormClass, BaseClass = util.THEME.loadUiType("notification_system/user_online.ui")
+
+FormClass, BaseClass = util.THEME.loadUiType(
+ "notification_system/user_online.ui",
+)
class UserOnlineDialog(FormClass, BaseClass):
@@ -29,12 +31,14 @@ def __init__(self, parent, eventType):
self.setupUi(self)
# remove help button
- self.setWindowFlags(self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint))
+ self.setWindowFlags(
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
+ )
self.loadSettings()
def loadSettings(self):
- self.mode = Settings.get(self._settings_key+'/mode', 'friends')
+ self.mode = Settings.get(self._settings_key + '/mode', 'friends')
if self.mode == 'friends':
self.radioButtonFriends.setChecked(True)
@@ -43,7 +47,7 @@ def loadSettings(self):
self.parent.mode = self.mode
def saveSettings(self):
- Settings.set(self._settings_key+'/mode', self.mode)
+ Settings.set(self._settings_key + '/mode', self.mode)
self.parent.mode = self.mode
@QtCore.pyqtSlot()
diff --git a/src/notifications/ns_dialog.py b/src/notifications/ns_dialog.py
index c681bc9d0..2426b2e14 100644
--- a/src/notifications/ns_dialog.py
+++ b/src/notifications/ns_dialog.py
@@ -1,11 +1,15 @@
-from PyQt5 import QtCore, QtWidgets
-import util
-import time
-from .ns_settings import NotificationPosition
-
"""
The UI popup of the notification system
"""
+import time
+
+from PyQt6 import QtCore
+from PyQt6.QtMultimedia import QSoundEffect
+
+import util
+
+from .ns_settings import NotificationPosition
+
FormClass, BaseClass = util.THEME.loadUiType("notification_system/dialog.ui")
@@ -17,20 +21,43 @@ def __init__(self, client, settings, *args, **kwargs):
self.setupUi(self)
self.client = client
- self.labelIcon.setPixmap(util.THEME.icon("client/tray_icon.png", pix=True).scaled(32, 32))
+ self.labelIcon.setPixmap(
+ util.THEME.icon("client/tray_icon.png", pix=True).scaled(32, 32),
+ )
self.standardIcon = util.THEME.icon("client/comment.png", pix=True)
self.settings = settings
self.updatePosition()
# Frameless, always on top, steal no focus & no entry at the taskbar
- self.setWindowFlags(QtCore.Qt.ToolTip)
+ self.setWindowFlags(QtCore.Qt.WindowType.ToolTip)
+ self.labelEvent.setOpenExternalLinks(True)
+
+ self.baseHeight = 165
+ self.baseWidth = 375
+
+ self.sender_id = None
+ self.acceptButton.clicked.connect(
+ lambda: self.acceptPartyInvite(sender_id=self.sender_id),
+ )
+ self.sound_effect = QSoundEffect()
+ self.sound_effect.setSource(util.THEME.sound("chat/sfx/query.wav"))
# TODO: integrate into client.css
# self.setStyleSheet(self.client.styleSheet())
@QtCore.pyqtSlot()
- def newEvent(self, pixmap, text, lifetime, sound):
+ def newEvent(
+ self,
+ pixmap,
+ text,
+ lifetime,
+ sound,
+ height=None,
+ width=None,
+ hide_accept_button=True,
+ sender_id=None,
+ ):
""" Called to display a new popup
Keyword arguments:
pixmap -- Icon for the event (displayed left)
@@ -43,10 +70,18 @@ def newEvent(self, pixmap, text, lifetime, sound):
pixmap = self.standardIcon
self.labelImage.setPixmap(pixmap)
- self.labelTime.setText(time.strftime("%H:%M:%S", time.gmtime()))
+ self.labelTime.setText(time.strftime("%H:%M:%S", time.localtime()))
QtCore.QTimer.singleShot(lifetime * 1000, self.hide)
if sound:
- util.THEME.sound("chat/sfx/query.wav")
+ self.sound_effect.play()
+ self.setFixedHeight(height or self.baseHeight)
+ self.setFixedWidth(width or self.baseWidth)
+
+ if hide_accept_button:
+ self.acceptButton.hide()
+ else:
+ self.sender_id = sender_id
+ self.acceptButton.show()
self.updatePosition()
self.show()
@@ -59,19 +94,28 @@ def hide(self):
# mouseReleaseEvent sometimes not fired
def mousePressEvent(self, event):
- if event.button() == QtCore.Qt.RightButton:
+ if event.button() == QtCore.Qt.MouseButton.RightButton:
self.hide()
- def updatePosition(self):
- screen = QtWidgets.QDesktopWidget().screenGeometry()
+ def updatePosition(self) -> None:
+ screen_size = self.screen().availableGeometry()
dialog_size = self.geometry()
- position = self.settings.popup_position # self.client.notificationSystem.settings.popup_position
+ # self.client.notificationSystem.settings.popup_position
+ position = self.settings.popup_position
if position == NotificationPosition.TOP_LEFT:
self.move(0, 0)
elif position == NotificationPosition.TOP_RIGHT:
- self.move(screen.width() - dialog_size.width(), 0)
+ self.move(screen_size.width() - dialog_size.width(), 0)
elif position == NotificationPosition.BOTTOM_LEFT:
- self.move(0, screen.height() - dialog_size.height())
+ self.move(0, screen_size.height() - dialog_size.height())
else:
- self.move(screen.width() - dialog_size.width(), screen.height() - dialog_size.height())
+ self.move(
+ screen_size.width() - dialog_size.width(),
+ screen_size.height() - dialog_size.height(),
+ )
+
+ @QtCore.pyqtSlot()
+ def acceptPartyInvite(self, sender_id):
+ self.client.games.accept_party_invite(sender_id)
+ self.hide()
diff --git a/src/notifications/ns_hook.py b/src/notifications/ns_hook.py
index 3c68b5de3..00cb15a57 100644
--- a/src/notifications/ns_hook.py
+++ b/src/notifications/ns_hook.py
@@ -1,7 +1,3 @@
-from PyQt5 import QtWidgets
-import util
-from config import Settings
-
"""
Setting Model class.
All Event Types (Notifications) are customizable.
@@ -11,6 +7,9 @@
self.button.clicked.connect(self.dialog.show)
"""
+from PyQt6 import QtWidgets
+
+from config import Settings
class NsHook():
@@ -22,12 +21,16 @@ def __init__(self, eventType):
self.button.setEnabled(False)
def loadSettings(self):
- self.popup = Settings.get(self._settings_key + '/popup', True, type=bool)
- self.sound = Settings.get(self._settings_key + '/sound', True, type=bool)
+ self.popup = Settings.get(
+ self._settings_key + '/popup', True, type=bool,
+ )
+ self.sound = Settings.get(
+ self._settings_key + '/sound', True, type=bool,
+ )
def saveSettings(self):
- Settings.set(self._settings_key+'/popup', self.popup)
- Settings.set(self._settings_key+'/sound', self.sound)
+ Settings.set(self._settings_key + '/popup', self.popup)
+ Settings.set(self._settings_key + '/sound', self.sound)
def getEventDisplayName(self):
return self.eventType
diff --git a/src/notifications/ns_settings.py b/src/notifications/ns_settings.py
index c794c6798..8b7b976ec 100644
--- a/src/notifications/ns_settings.py
+++ b/src/notifications/ns_settings.py
@@ -1,16 +1,19 @@
-from PyQt5 import QtCore, QtWidgets
-from enum import Enum
-from config import Settings
-import util
-import notifications as ns
-from notifications.hook_useronline import NsHookUserOnline
-from notifications.hook_newgame import NsHookNewGame
-from notifications.hook_gamefull import NsHookGameFull
-
"""
The UI of the Notification System Settings Frame.
Each module/hook for the notification system must be registered here.
"""
+from enum import Enum
+
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
+
+import notifications as ns
+import util
+from config import Settings
+from notifications.hook_gamefull import NsHookGameFull
+from notifications.hook_newgame import NsHookNewGame
+from notifications.hook_partyinvite import NsHookPartyInvite
+from notifications.hook_useronline import NsHookUserOnline
class IngameNotification(Enum):
@@ -37,43 +40,63 @@ def getLabel(self):
# TODO: how to register hooks?
-FormClass2, BaseClass2 = util.THEME.loadUiType("notification_system/ns_settings.ui")
+FormClass2, BaseClass2 = util.THEME.loadUiType(
+ "notification_system/ns_settings.ui",
+)
class NsSettingsDialog(FormClass2, BaseClass2):
def __init__(self, client):
BaseClass2.__init__(self)
- #BaseClass2.__init__(self, client)
+ # BaseClass2.__init__(self, client)
self.setupUi(self)
self.client = client
# remove help button
- self.setWindowFlags(self.windowFlags() & (~QtCore.Qt.WindowContextHelpButtonHint))
+ self.setWindowFlags(
+ self.windowFlags() & (~QtCore.Qt.WindowType.WindowContextHelpButtonHint),
+ )
# init hooks
self.hooks = {}
self.hooks[ns.Notifications.USER_ONLINE] = NsHookUserOnline()
self.hooks[ns.Notifications.NEW_GAME] = NsHookNewGame()
self.hooks[ns.Notifications.GAME_FULL] = NsHookGameFull()
+ self.hooks[ns.Notifications.PARTY_INVITE] = NsHookPartyInvite()
model = NotificationHooks(self, list(self.hooks.values()))
self.tableView.setModel(model)
# stretch first column
- self.tableView.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
+ self.tableView.horizontalHeader().setSectionResizeMode(
+ 0, QtWidgets.QHeaderView.ResizeMode.Stretch,
+ )
for row in range(0, model.rowCount(None)):
- self.tableView.setIndexWidget(model.createIndex(row, 3), model.getHook(row).settings())
+ self.tableView.setIndexWidget(
+ model.createIndex(row, 3),
+ model.getHook(row).settings(),
+ )
self.loadSettings()
def loadSettings(self):
self.enabled = Settings.get('notifications/enabled', True, type=bool)
- self.popup_lifetime = Settings.get('notifications/popup_lifetime', 5, type=int)
- self.popup_position = NotificationPosition(Settings.get('notifications/popup_position',
- NotificationPosition.BOTTOM_RIGHT.value, type=int))
- self.ingame_notifications = IngameNotification(Settings.get('notifications/ingame',
- IngameNotification.ENABLE, type=int))
+ self.popup_lifetime = Settings.get(
+ 'notifications/popup_lifetime', 5, type=int,
+ )
+ self.popup_position = NotificationPosition(
+ Settings.get(
+ 'notifications/popup_position',
+ NotificationPosition.BOTTOM_RIGHT.value,
+ type=int,
+ ),
+ )
+ self.ingame_notifications = IngameNotification(
+ Settings.get(
+ 'notifications/ingame', IngameNotification.ENABLE, type=int,
+ ),
+ )
self.nsEnabled.setChecked(self.enabled)
self.nsPopLifetime.setValue(self.popup_lifetime)
@@ -92,8 +115,12 @@ def saveSettings(self):
def on_btnSave_clicked(self):
self.enabled = self.nsEnabled.isChecked()
self.popup_lifetime = self.nsPopLifetime.value()
- self.popup_position = NotificationPosition(self.nsPositionComboBox.currentIndex())
- self.ingame_notifications = IngameNotification(self.nsIngameComboBox.currentIndex())
+ self.popup_position = NotificationPosition(
+ self.nsPositionComboBox.currentIndex(),
+ )
+ self.ingame_notifications = IngameNotification(
+ self.nsIngameComboBox.currentIndex(),
+ )
self.saveSettings()
self.hide()
@@ -119,13 +146,13 @@ def getCustomSetting(self, eventType, key):
return getattr(self.hooks[eventType], key)
return None
-"""
-Model Class for notification type table.
-Needs an NsHook.
-"""
-
class NotificationHooks(QtCore.QAbstractTableModel):
+ """
+ Model Class for notification type table.
+ Needs an NsHook.
+ """
+
POPUP = 1
SOUND = 2
SETTINGS = 3
@@ -139,9 +166,9 @@ def __init__(self, parent, hooks, *args):
def flags(self, index):
flags = super(QtCore.QAbstractTableModel, self).flags(index)
if index.column() == self.POPUP or index.column() == self.SOUND:
- return flags | QtCore.Qt.ItemIsUserCheckable
+ return flags | QtCore.Qt.ItemFlag.ItemIsUserCheckable
if index.column() == self.SETTINGS:
- return flags | QtCore.Qt.ItemIsEditable
+ return flags | QtCore.Qt.ItemFlag.ItemIsEditable
return flags
def rowCount(self, parent):
@@ -153,21 +180,25 @@ def columnCount(self, parent):
def getHook(self, row):
return self.hooks[row]
- def data(self, index, role = QtCore.Qt.EditRole):
+ def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole):
if not index.isValid():
return None
- # if role == QtCore.Qt.TextAlignmentRole and index.column() != 0:
- # return QtCore.Qt.AlignHCenter
+ # if role == QtCore.Qt.ItemDataRole.TextAlignmentRole and index.column() != 0:
+ # return QtCore.Qt.AlignmentFlag.AlignHCenter
- if role == QtCore.Qt.CheckStateRole:
+ if role == QtCore.Qt.ItemDataRole.CheckStateRole:
if index.column() == self.POPUP:
- return self.returnChecked(self.hooks[index.row()].popupEnabled())
+ return self.returnChecked(
+ self.hooks[index.row()].popupEnabled(),
+ )
if index.column() == self.SOUND:
- return self.returnChecked(self.hooks[index.row()].soundEnabled())
+ return self.returnChecked(
+ self.hooks[index.row()].soundEnabled(),
+ )
return None
- if role != QtCore.Qt.DisplayRole:
+ if role != QtCore.Qt.ItemDataRole.DisplayRole:
return None
if index.column() == 0:
@@ -175,9 +206,9 @@ def data(self, index, role = QtCore.Qt.EditRole):
return ''
def returnChecked(self, state):
- return QtCore.Qt.Checked if state else QtCore.Qt.Unchecked
+ return QtCore.Qt.CheckState.Checked if state else QtCore.Qt.CheckState.Unchecked
- def setData(self, index, value, role = QtCore.Qt.EditRole):
+ def setData(self, index, value, role=QtCore.Qt.ItemDataRole.DisplayRole.EditRole):
if index.column() == self.POPUP:
self.hooks[index.row()].switchPopup()
self.dataChanged.emit(index, index)
@@ -189,6 +220,9 @@ def setData(self, index, value, role = QtCore.Qt.EditRole):
return False
def headerData(self, col, orientation, role):
- if orientation == QtCore.Qt.Horizontal and role == QtCore.Qt.DisplayRole:
+ if (
+ orientation == QtCore.Qt.Orientation.Horizontal
+ and role == QtCore.Qt.ItemDataRole.DisplayRole
+ ):
return self.headerdata[col]
return None
diff --git a/src/oauth/__init__.py b/src/oauth/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/oauth/oauth_flow.py b/src/oauth/oauth_flow.py
new file mode 100644
index 000000000..9710e990f
--- /dev/null
+++ b/src/oauth/oauth_flow.py
@@ -0,0 +1,99 @@
+from PyQt6.QtCore import QDateTime
+from PyQt6.QtCore import QObject
+from PyQt6.QtCore import QTimer
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetworkAuth import QOAuth2AuthorizationCodeFlow
+from PyQt6.QtNetworkAuth import QOAuthHttpServerReplyHandler
+
+from config import Settings
+from decorators import with_logger
+
+
+class OAuthReplyHandler(QOAuthHttpServerReplyHandler):
+ def callback(self) -> str:
+ with_trailing_slash = super().callback()
+ # remove trailing slash because server does not accept it
+ return with_trailing_slash.removesuffix("/")
+
+
+@with_logger
+class OAuth2Flow(QOAuth2AuthorizationCodeFlow):
+ def __init__(
+ self,
+ manager: QNetworkAccessManager | None = None,
+ parent: QObject | None = None,
+ ) -> None:
+ super().__init__(manager, parent)
+
+ if manager is None:
+ self.setNetworkAccessManager(QNetworkAccessManager())
+
+ self.setup_credentials()
+ reply_handler = OAuthReplyHandler(self)
+ self.setReplyHandler(reply_handler)
+
+ self.authorizeWithBrowser.connect(QDesktopServices.openUrl)
+ self.requestFailed.connect(self.on_request_failed)
+ self.granted.connect(self.on_granted)
+ self.tokenChanged.connect(self.on_token_changed)
+ self.expirationAtChanged.connect(self.on_expiration_at_changed)
+
+ self._check_timer = QTimer(self)
+ self._check_timer.timeout.connect(self.check_token)
+ self._check_interval = 5000
+ self._expires_in = None
+
+ def stop_checking_expiration(self) -> None:
+ self._check_timer.stop()
+ self._expires_in = None
+
+ def start_checking_expiration(self) -> None:
+ self._check_timer.start(self._check_interval)
+
+ def check_token(self) -> None:
+ if self._expires_in is None:
+ return
+
+ self._expires_in -= self._check_interval
+ if self._expires_in <= 60_000:
+ self.refreshAccessToken()
+
+ def on_expiration_at_changed(self, expiration_at: QDateTime) -> None:
+ self._logger.debug(f"Token expiration at changed to: {expiration_at}")
+ self._expires_in = QDateTime.currentDateTime().msecsTo(expiration_at)
+
+ def on_token_changed(self, new_token: str) -> None:
+ self._logger.debug("Token changed")
+
+ def on_granted(self) -> None:
+ self._logger.debug("Token granted successfuly!")
+ self.start_checking_expiration()
+
+ def on_request_failed(self, error: QOAuth2AuthorizationCodeFlow.Error) -> None:
+ self._logger.debug(f"Request failed with an error: {error}")
+ self.stop_checking_expiration()
+
+ def setup_credentials(self) -> None:
+ """
+ Set client's credentials, scopes and OAuth endpoints
+ """
+ client_id = Settings.get("oauth/client_id")
+ scopes = Settings.get("oauth/scope")
+
+ oauth_host = QUrl(Settings.get("oauth/host"))
+ auth_endpoint = QUrl(Settings.get("oauth/auth_endpoint"))
+ token_endpoint = QUrl(Settings.get("oauth/token_endpoint"))
+
+ authorization_url = oauth_host.resolved(auth_endpoint)
+ token_url = oauth_host.resolved(token_endpoint)
+
+ self.setUserAgent("FAF Client")
+ self.setAuthorizationUrl(authorization_url)
+ self.setClientIdentifier(client_id)
+ self.setAccessTokenUrl(token_url)
+ self.setScope(" ".join(scopes))
+
+
+OAuth2FlowInstance = OAuth2Flow()
diff --git a/src/power/__init__.py b/src/power/__init__.py
new file mode 100644
index 000000000..58ad93557
--- /dev/null
+++ b/src/power/__init__.py
@@ -0,0 +1,15 @@
+from power.actions import PowerActions
+from power.view import PowerView
+
+
+class PowerTools:
+ def __init__(self, actions, view):
+ self.power = 0
+ self.actions = actions
+ self.view = view
+
+ @classmethod
+ def build(cls, **kwargs):
+ actions = PowerActions.build(**kwargs)
+ view = PowerView.build(mod_actions=actions, **kwargs)
+ return cls(actions, view)
diff --git a/src/power/actions.py b/src/power/actions.py
new file mode 100644
index 000000000..3f4004cb5
--- /dev/null
+++ b/src/power/actions.py
@@ -0,0 +1,73 @@
+import logging
+from enum import Enum
+
+from PyQt6.QtCore import QUrl
+from PyQt6.QtGui import QDesktopServices
+
+logger = logging.getLogger(__name__)
+
+
+class BanPeriod(Enum):
+ HOUR = 'HOUR'
+ DAY = 'DAY'
+ WEEK = 'WEEK'
+ MONTH = 'MONTH'
+ YEAR = 'YEAR'
+
+
+class PowerActions:
+ def __init__(self, lobby_connection, playerset, settings):
+ self._lobby_connection = lobby_connection
+ self._playerset = playerset
+ self._settings = settings
+
+ @classmethod
+ def build(cls, lobby_connection, playerset, settings, **kwargs):
+ return cls(lobby_connection, playerset, settings)
+
+ def close_fa(self, username):
+ player = self._playerset.get(username, None)
+ if player is None:
+ return False
+ logger.info('Closing FA for {}'.format(player.login))
+ self._lobby_connection.send({
+ "command": "admin",
+ "action": "closeFA",
+ "user_id": player.id,
+ })
+ return True
+
+ def kick_player(self, username):
+ player = self._playerset.get(username, None)
+ if player is None:
+ return False
+ logger.info('Closing lobby for {}'.format(player.login))
+ self._lobby_connection.send({
+ "command": "admin",
+ "action": "closelobby",
+ "user_id": player.id,
+ })
+ return True
+
+ def ban_player(self, username, reason, duration, period):
+ player = self._playerset.get(username, None)
+ if player is None:
+ return False
+ message = {
+ "command": "admin",
+ "action": "closelobby",
+ "ban": {
+ "reason": reason,
+ "duration": duration,
+ "period": period,
+ },
+ "user_id": player.id,
+ }
+ self._lobby_connection.send(message)
+ return True
+
+ def send_the_orcs(self, username):
+ player = self._playerset.get(username, None)
+ target = username if player is None else player.id
+ route = self._settings.get('mordor/host')
+ QDesktopServices.openUrl(QUrl("{}/users/{}".format(route, target)))
diff --git a/src/power/view.py b/src/power/view.py
new file mode 100644
index 000000000..442e7f895
--- /dev/null
+++ b/src/power/view.py
@@ -0,0 +1,102 @@
+from PyQt6.QtCore import QObject
+from PyQt6.QtWidgets import QMessageBox
+
+from power.actions import BanPeriod
+from util.select_player_dialog import PlayerCompleter
+from util.select_player_dialog import SelectPlayerDialog
+
+
+class CloseGameDialog(SelectPlayerDialog):
+ def __init__(self, mod_actions, playerset, parent_widget):
+ SelectPlayerDialog.__init__(self, playerset, parent_widget)
+ self._mod_actions = mod_actions
+
+ @classmethod
+ def build(cls, mod_actions, playerset, parent_widget, **kwargs):
+ return cls(mod_actions, playerset, parent_widget)
+
+ def show(self, username=""):
+ self.show_dialog("Closing player's game", "Player name:", username)
+
+ def _at_value(self, name):
+ if not self._mod_actions.close_fa(name):
+ msg = QMessageBox(self._parent_widget)
+ msg.setWindowTitle("Player not found!")
+ msg.setText("The specified player was not found.")
+ msg.show()
+
+
+class KickDialog(QObject):
+ def __init__(self, username, mod_actions, playerset, theme, parent_widget):
+ QObject.__init__(self, parent_widget)
+ self._mod_actions = mod_actions
+ self._playerset = playerset
+ self.set_theme(theme, parent_widget)
+ self.form.leUsername.setText(username)
+ self.base.show()
+
+ @classmethod
+ def builder(cls, mod_actions, playerset, theme, parent_widget, **kwargs):
+ def make(username=""):
+ return cls(username, mod_actions, playerset, theme, parent_widget)
+ return make
+
+ def set_theme(self, theme, parent_widget):
+ formc, basec = theme.loadUiType("client/kick.ui")
+ self.form = formc()
+ self.base = basec(parent_widget)
+ self.form.setupUi(self.base)
+
+ self.form.cbBan.stateChanged.connect(self.banChanged)
+ self.base.accepted.connect(self.accepted)
+ self.base.rejected.connect(self.rejected)
+
+ completer = PlayerCompleter(self._playerset, self.base)
+ self.form.leUsername.setCompleter(completer)
+
+ def banChanged(self, newState):
+ checked = self.form.cbBan.isChecked()
+ self.form.cbReason.setEnabled(checked)
+ self.form.sbDuration.setEnabled(checked)
+ self.form.cbPeriod.setEnabled(checked)
+
+ def _warning(self, title, text):
+ msg = QMessageBox(
+ QMessageBox.Warning, title, text,
+ parent=self._parent_widget,
+ )
+ msg.show()
+
+ def accepted(self):
+ username = self.form.leUsername.text()
+ if not self.form.cbBan.isChecked():
+ result = self._mod_actions.kick_player(username)
+ else:
+ reason = self.form.cbReason.currentText()
+ duration = self.form.sbDuration.value()
+ period = [e for e in BanPeriod][self.form.cbPeriod.currentIndex()]
+ result = self._mod_actions.ban_player(
+ username, reason, duration, period,
+ )
+
+ if not result:
+ self._warning(
+ "Player not found",
+ 'Player "{}" was not found.'.format(username),
+ )
+ self.setParent(None) # Let ourselves get GC'd
+
+ def rejected(self):
+ self.setParent(None) # Let ourselves get GC'd
+
+
+class PowerView:
+ def __init__(self, close_game_dialog, kick_dialog):
+ self.close_game_dialog = close_game_dialog
+ self.kick_dialog = kick_dialog
+
+ @classmethod
+ def build(cls, **kwargs):
+ close_game_dialog = CloseGameDialog.build(**kwargs)
+ kick_dialog = KickDialog.builder(**kwargs)
+ return cls(close_game_dialog, kick_dialog)
diff --git a/src/qt/itemviews/tableheaderview.py b/src/qt/itemviews/tableheaderview.py
new file mode 100644
index 000000000..07b995e08
--- /dev/null
+++ b/src/qt/itemviews/tableheaderview.py
@@ -0,0 +1,71 @@
+from PyQt6.QtCore import QModelIndex
+from PyQt6.QtCore import QRect
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QHoverEvent
+from PyQt6.QtGui import QMouseEvent
+from PyQt6.QtGui import QPainter
+from PyQt6.QtGui import QWheelEvent
+from PyQt6.QtWidgets import QHeaderView
+from PyQt6.QtWidgets import QStyle
+from PyQt6.QtWidgets import QStyleOptionHeader
+
+
+class VerticalHeaderView(QHeaderView):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(Qt.Orientation.Vertical, *args, **kwargs)
+ self.setHighlightSections(True)
+ self.setSectionResizeMode(self.ResizeMode.Fixed)
+ self.setVisible(True)
+ self.setSectionsClickable(True)
+ self.setAlternatingRowColors(True)
+ self.setObjectName("VerticalHeader")
+
+ self.hover = -1
+
+ def paintSection(self, painter: QPainter, rect: QRect, index: QModelIndex) -> None:
+ opt = QStyleOptionHeader()
+ self.initStyleOption(opt)
+ opt.rect = rect
+ opt.section = index
+
+ data = self.model().headerData(index, self.orientation(), Qt.ItemDataRole.DisplayRole)
+ opt.text = str(data)
+
+ opt.textAlignment = Qt.AlignmentFlag.AlignCenter
+
+ state = QStyle.StateFlag.State_None
+
+ if self.highlightSections():
+ if self.selectionModel().rowIntersectsSelection(index, QModelIndex()):
+ state |= QStyle.StateFlag.State_On
+ elif index == self.hover:
+ state |= QStyle.StateFlag.State_MouseOver
+
+ opt.state |= state
+
+ self.style().drawControl(QStyle.ControlElement.CE_Header, opt, painter, self)
+
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
+ QHeaderView.mouseMoveEvent(self, event)
+ self.parent().update_hover_row(event)
+ self.update_hover_section(event)
+
+ def wheelEvent(self, event: QWheelEvent) -> None:
+ QHeaderView.wheelEvent(self, event)
+ self.parent().update_hover_row(event)
+ self.update_hover_section(event)
+
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ QHeaderView.mousePressEvent(self, event)
+ self.parent().update_hover_row(event)
+ self.update_hover_section(event)
+
+ def update_hover_section(self, event: QHoverEvent) -> None:
+ index = self.logicalIndexAt(event.position().toPoint())
+ old_hover, self.hover = self.hover, index
+
+ if self.hover != old_hover:
+ if old_hover != -1:
+ self.updateSection(old_hover)
+ if self.hover != -1:
+ self.updateSection(self.hover)
diff --git a/src/qt/itemviews/tableitemdelegte.py b/src/qt/itemviews/tableitemdelegte.py
new file mode 100644
index 000000000..b5c7ded05
--- /dev/null
+++ b/src/qt/itemviews/tableitemdelegte.py
@@ -0,0 +1,61 @@
+from PyQt6.QtCore import QModelIndex
+from PyQt6.QtCore import Qt
+from PyQt6.QtGui import QPainter
+from PyQt6.QtWidgets import QStyle
+from PyQt6.QtWidgets import QStyledItemDelegate
+from PyQt6.QtWidgets import QStyleOptionViewItem
+from PyQt6.QtWidgets import QTableView
+
+from util.qt import qpainter
+
+
+class TableItemDelegate(QStyledItemDelegate):
+ """
+ Highlights the entire row on mouse hover when table's
+ SelectionBehavior is set to SelectRows
+ Requires TableView to have method hover_index() defined
+ """
+
+ def _customize_style_option(
+ self,
+ option: QStyleOptionViewItem,
+ index: QModelIndex,
+ ) -> QStyleOptionViewItem:
+ opt = QStyleOptionViewItem(option)
+ opt.state &= ~QStyle.StateFlag.State_HasFocus
+ opt.state &= ~QStyle.StateFlag.State_MouseOver
+
+ view = opt.styleObject
+ behavior = view.selectionBehavior()
+ hover_index = view.hover_index()
+
+ if (
+ not (option.state & QStyle.StateFlag.State_Selected)
+ and behavior != QTableView.SelectionBehavior.SelectItems
+ ):
+ if (
+ behavior == QTableView.SelectionBehavior.SelectRows
+ and hover_index.row() == index.row()
+ ):
+ opt.state |= QStyle.StateFlag.State_MouseOver
+
+ self.initStyleOption(opt, index)
+ return opt
+
+ def _draw_clear_option(self, painter: QPainter, option: QStyleOptionViewItem) -> None:
+ option.text = ""
+ control_element = QStyle.ControlElement.CE_ItemViewItem
+ option.widget.style().drawControl(control_element, option, painter, option.widget)
+
+ def _set_pen(self, painter: QPainter, option: QStyleOptionViewItem) -> None:
+ if option.state & QStyle.StateFlag.State_Selected:
+ painter.setPen(Qt.GlobalColor.white)
+
+ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
+ opt = self._customize_style_option(option, index)
+ text = opt.text
+
+ with qpainter(painter):
+ self._draw_clear_option(painter, opt)
+ self._set_pen(painter, opt)
+ painter.drawText(opt.rect, Qt.AlignmentFlag.AlignCenter, text)
diff --git a/src/qt/itemviews/tableview.py b/src/qt/itemviews/tableview.py
new file mode 100644
index 000000000..0816e1a24
--- /dev/null
+++ b/src/qt/itemviews/tableview.py
@@ -0,0 +1,56 @@
+from PyQt6.QtCore import QModelIndex
+from PyQt6.QtGui import QHoverEvent
+from PyQt6.QtGui import QMouseEvent
+from PyQt6.QtGui import QWheelEvent
+from PyQt6.QtWidgets import QTableView
+
+from qt.itemviews.tableheaderview import VerticalHeaderView
+
+
+class TableView(QTableView):
+ def __init__(self, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.setMouseTracking(True)
+ self.setSelectionBehavior(self.SelectionBehavior.SelectRows)
+ self.setSelectionMode(self.SelectionMode.SingleSelection)
+ self.setAlternatingRowColors(True)
+ self.setSortingEnabled(True)
+
+ self.setVerticalHeader(VerticalHeaderView())
+ self.m_hover_row = -1
+ self.m_hover_column = -1
+
+ def hover_index(self) -> QModelIndex:
+ return QModelIndex(self.model().index(self.m_hover_row, self.m_hover_column))
+
+ def update_hover_row(self, event: QHoverEvent) -> None:
+ index = self.indexAt(event.position().toPoint())
+ old_hover_row = self.m_hover_row
+ self.m_hover_row = index.row()
+ self.m_hover_column = index.column()
+
+ if (
+ self.selectionBehavior() is self.SelectionBehavior.SelectRows
+ and old_hover_row != self.m_hover_row
+ ):
+ if old_hover_row != -1:
+ for i in range(self.model().columnCount()):
+ self.update(self.model().index(old_hover_row, i))
+ if self.m_hover_row != -1:
+ for i in range(self.model().columnCount()):
+ self.update(self.model().index(self.m_hover_row, i))
+
+ def mouseMoveEvent(self, event: QMouseEvent) -> None:
+ QTableView.mouseMoveEvent(self, event)
+ self.update_hover_row(event)
+ self.verticalHeader().update_hover_section(event)
+
+ def wheelEvent(self, event: QWheelEvent) -> None:
+ QTableView.wheelEvent(self, event)
+ self.update_hover_row(event)
+ self.verticalHeader().update_hover_section(event)
+
+ def mousePressEvent(self, event: QMouseEvent) -> None:
+ QTableView.mousePressEvent(self, event)
+ self.update_hover_row(event)
+ self.verticalHeader().update_hover_section(event)
diff --git a/src/replays/__init__.py b/src/replays/__init__.py
index b475521df..a9a26303b 100644
--- a/src/replays/__init__.py
+++ b/src/replays/__init__.py
@@ -1,5 +1,10 @@
import logging
-logger = logging.getLogger(__name__)
from ._replayswidget import ReplaysWidget
+
+__all__ = (
+ "ReplaysWidget",
+)
+
+logger = logging.getLogger(__name__)
diff --git a/src/replays/_replayswidget.py b/src/replays/_replayswidget.py
index ccc3739bf..8f48b4d65 100644
--- a/src/replays/_replayswidget.py
+++ b/src/replays/_replayswidget.py
@@ -1,21 +1,31 @@
-from PyQt5 import QtCore, QtWidgets, QtGui
-from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
-from fa.replay import replay
-from config import Settings
-import util
+import json
+import logging
import os
-import fa
import time
-import client
-import json
-import jsonschema
-from replays.replayitem import ReplayItem, ReplayItemDelegate
+from pydantic import ValidationError
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+from PyQt6.QtNetwork import QNetworkAccessManager
+from PyQt6.QtNetwork import QNetworkReply
+from PyQt6.QtNetwork import QNetworkRequest
+
+import client
+import fa
+import util
+from api.replaysapi import ReplaysApiConnector
+from config import Settings
+from downloadManager import DownloadRequest
+from fa.replay import replay
from model.game import GameState
-from replays.connection import ReplaysConnection
-from downloadManager import PreviewDownloadRequest
+from replays.models import MetadataModel
+from replays.replayitem import ReplayItem
+from replays.replayitem import ReplayItemDelegate
+from replays.replayToolbox import ReplayToolboxHandler
+from util.gameurl import GameUrl
+from util.gameurl import GameUrlType
-import logging
logger = logging.getLogger(__name__)
# Replays uses the new Inheritance Based UI creation pattern
@@ -34,10 +44,10 @@ def __init__(self, game):
self.launch_time = game.launched_at
else:
self.launch_time = time.time()
- self._map_dl_request = PreviewDownloadRequest()
+ self._map_dl_request = DownloadRequest()
self._map_dl_request.done.connect(self._map_preview_downloaded)
- self._game.gameUpdated.connect(self._update_game)
+ self._game.updated.connect(self._update_game)
self._set_show_delay()
self._update_game(self._game)
@@ -47,7 +57,7 @@ def _set_show_delay(self):
# Wait until the replayserver makes the replay available
elapsed_time = time.time() - self.launch_time
delay_time = self.LIVEREPLAY_DELAY - elapsed_time
- QtCore.QTimer.singleShot(1000 * delay_time, self._show_item)
+ QtCore.QTimer.singleShot(int(1000 * delay_time), self._show_item)
def _show_item(self):
self.setHidden(False)
@@ -74,7 +84,7 @@ def _set_debug_tooltip(self, game):
info = game.to_dict()
tip = ""
for key in list(info.keys()):
- tip += "'" + str(key) + "' : '" + str(info[key]) + "' "
+ tip += "'{}' : '{}' ".format(key, info[key])
self.setToolTip(1, tip)
def _set_game_map_icon(self, game):
@@ -83,7 +93,7 @@ def _set_game_map_icon(self, game):
else:
icon = fa.maps.preview(game.mapname)
if not icon:
- dler = client.instance.map_downloader
+ dler = client.instance.map_preview_downloader
dler.download_preview(game.mapname, self._map_dl_request)
icon = util.THEME.icon("games/unknown_map.png")
self.setIcon(0, icon)
@@ -96,21 +106,21 @@ def _set_misc_formatting(self, game):
self.setText(0, launch_time)
colors = client.instance.player_colors
- self.setForeground(0, QtGui.QColor(colors.getColor("default")))
+ self.setForeground(0, QtGui.QColor(colors.get_color("default")))
if game.featured_mod == "ladder1v1":
self.setText(1, game.title)
else:
self.setText(1, game.title + " - [host: " + game.host + "]")
- self.setForeground(1, QtGui.QColor(colors.getColor("player")))
+ self.setForeground(1, QtGui.QColor(colors.get_color("player")))
self.setText(2, game.featured_mod)
- self.setTextAlignment(2, QtCore.Qt.AlignCenter)
+ self.setTextAlignment(2, QtCore.Qt.AlignmentFlag.AlignCenter)
def _is_me(self, name):
return client.instance.login == name
def _is_friend(self, name):
playerid = client.instance.players.getID(name)
- return client.instance.me.isFriend(playerid)
+ return client.instance.me.relations.model.is_friend(playerid)
def _is_online(self, name):
return name in client.instance.players
@@ -125,7 +135,7 @@ def _set_color(self, game):
else:
my_color = "player"
colors = client.instance.player_colors
- self.setForeground(1, QtGui.QColor(colors.getColor(my_color)))
+ self.setForeground(1, QtGui.QColor(colors.get_color(my_color)))
def _generate_player_subitems(self, game):
if not game.teams:
@@ -148,26 +158,21 @@ def _create_playeritem(self, game, name):
else:
player_color = "default"
colors = client.instance.player_colors
- item.setForeground(0, QtGui.QColor(colors.getColor(player_color)))
+ item.setForeground(0, QtGui.QColor(colors.get_color(player_color)))
if self._is_online(name):
- item.url = self._generate_livereplay_link(game, name)
- item.setToolTip(0, item.url.toString())
+ item.gurl = self._generate_livereplay_link(game, name)
+ item.setToolTip(0, item.gurl.to_url().toString())
item.setIcon(0, util.THEME.icon("replays/replay.png"))
else:
item.setDisabled(True)
return item
def _generate_livereplay_link(self, game, name):
- url = QtCore.QUrl()
- url.setScheme("faflive")
- url.setHost("lobby.faforever.com")
- url.setPath("/" + str(game.uid) + "/" + name + ".SCFAreplay")
- query = QtCore.QUrlQuery()
- query.addQueryItem("map", game.mapname)
- query.addQueryItem("mod", game.featured_mod)
- url.setQuery(query)
- return url
+ return GameUrl(
+ GameUrlType.LIVE_REPLAY, game.mapname,
+ game.featured_mod, game.uid, name,
+ )
def __lt__(self, other):
return self.launch_time < other.launch_time
@@ -187,9 +192,15 @@ def __init__(self, liveTree, client, gameset):
self.liveTree = liveTree
self.liveTree.itemDoubleClicked.connect(self.liveTreeDoubleClicked)
self.liveTree.itemPressed.connect(self.liveTreePressed)
- self.liveTree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
- self.liveTree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
- self.liveTree.header().setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents)
+ self.liveTree.header().setSectionResizeMode(
+ 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
+ )
+ self.liveTree.header().setSectionResizeMode(
+ 1, QtWidgets.QHeaderView.ResizeMode.Stretch,
+ )
+ self.liveTree.header().setSectionResizeMode(
+ 2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
+ )
self.client = client
self.gameset = gameset
@@ -199,7 +210,7 @@ def __init__(self, liveTree, client, gameset):
self.games = {}
def liveTreePressed(self, item):
- if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton:
+ if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton:
return
if self.liveTree.indexOfTopLevelItem(item) != -1:
@@ -217,8 +228,14 @@ def liveTreePressed(self, item):
menu.addAction(actionLink)
# Triggers
- actionReplay.triggered.connect(lambda: self.liveTreeDoubleClicked(item))
- actionLink.triggered.connect(lambda: QtWidgets.QApplication.clipboard().setText(item.toolTip(0)))
+ actionReplay.triggered.connect(
+ lambda: self.liveTreeDoubleClicked(item),
+ )
+ actionLink.triggered.connect(
+ lambda: QtWidgets.QApplication.clipboard().setText(
+ item.toolTip(0),
+ ),
+ )
# Adding to menu
menu.addAction(actionReplay)
@@ -228,15 +245,24 @@ def liveTreePressed(self, item):
menu.popup(QtGui.QCursor.pos())
def liveTreeDoubleClicked(self, item):
- """ This slot launches a live replay from eligible items in liveTree """
+ """
+ This slot launches a live replay from eligible items in liveTree
+ """
if item.isDisabled():
return
+ if (
+ self.client.games.party
+ and self.client.games.party.memberCount > 1
+ ):
+ if not self.client.games.leave_party():
+ return
+
if self.liveTree.indexOfTopLevelItem(item) == -1:
# Notify other modules that we're watching a replay
- self.client.viewingReplay.emit(item.url)
- replay(item.url)
+ self.client.viewing_replay.emit(item.gurl)
+ replay(item.gurl)
def _addExistingGames(self, gameset):
for game in gameset.values():
@@ -247,80 +273,47 @@ def _newGame(self, game):
item = LiveReplayItem(game)
self.games[game] = item
self.liveTree.insertTopLevelItem(0, item)
- game.gameUpdated.connect(self._check_game_closed)
+ game.updated.connect(self._check_game_closed)
def _check_game_closed(self, game):
if game.state == GameState.CLOSED:
- game.gameUpdated.disconnect(self._check_game_closed)
+ game.updated.disconnect(self._check_game_closed)
self._removeGame(game)
def _removeGame(self, game):
- self.liveTree.takeTopLevelItem(self.liveTree.indexOfTopLevelItem(self.games[game]))
+ self.liveTree.takeTopLevelItem(
+ self.liveTree.indexOfTopLevelItem(self.games[game]),
+ )
del self.games[game]
class ReplayMetadata:
- def __init__(self, data):
+ def __init__(self, data: str) -> None:
self.raw_data = data
- self.data = None
self.is_broken = False
- self.is_incomplete = False
+ self.model: MetadataModel | None = None
try:
- self.data = json.loads(data)
+ json_data = json.loads(data)
except json.decoder.JSONDecodeError:
self.is_broken = True
return
- self._validate_data()
-
- # FIXME - this is what the widget uses so far, we should define this
- # schema precisely in the future
- def _validate_data(self):
- if not isinstance(self.data, dict):
- self.is_broken = True
- return
- if not self.data.get('complete', False):
- self.is_incomplete = True
- return
-
- replay_schema = {
- "type": "object",
- "properties": {
- "num_players": {"type": "number"},
- "launched_at": {"type": "number"},
- "game_time": {
- "type": "number",
- "minimum": 0
- },
- "mapname": {"type": "string"},
- "title": {"type": "string"},
- "teams": {
- "type": "object",
- "patternProperties": {
- ".*": {
- "type": "array",
- "items": {"type": "string"}
- }
- }
- },
- "featured_mod": {"type": "string"}
- },
- "required": ["num_players", "mapname", "title", "teams",
- "featured_mod"]
- }
try:
- jsonschema.validate(self.data, replay_schema)
- except jsonschema.ValidationError:
+ self.model = MetadataModel(**json_data)
+ except ValidationError:
self.is_broken = True
- def launch_time(self):
- if 'launched_at' in self.data:
- return self.data['launched_at']
- elif 'game_time' in self.data:
- return self.data['game_time']
- else:
- return time.time() # FIXME
+ @property
+ def is_incomplete(self) -> bool:
+ if self.model is None:
+ return True
+ return not self.model.complete
+
+ def launch_time(self) -> float:
+ if self.model.launched_at > 0:
+ return self.model.launched_at
+ return self.model.game_time
class LocalReplayItem(QtWidgets.QTreeWidgetItem):
@@ -328,7 +321,7 @@ def __init__(self, replay_file, metadata=None):
QtWidgets.QTreeWidgetItem.__init__(self)
self._replay_file = replay_file
self._metadata = metadata
- self._map_dl_request = PreviewDownloadRequest()
+ self._map_dl_request = DownloadRequest()
self._map_dl_request.done.connect(self._map_preview_downloaded)
self._setup_appearance()
@@ -349,51 +342,57 @@ def _setup_no_metadata_appearance(self):
self.setText(1, self._replay_file)
self.setIcon(0, util.THEME.icon("replays/replay.png"))
colors = client.instance.player_colors
- self.setForeground(0, QtGui.QColor(colors.getColor("default")))
+ self.setForeground(0, QtGui.QColor(colors.get_color("default")))
def _setup_broken_appearance(self):
self.setIcon(0, util.THEME.icon("replays/broken.png"))
self.setText(1, self._replay_file)
- self.setForeground(1, QtGui.QColor("red")) # FIXME: Needs to come from theme
+ # FIXME: Needs to come from theme
+ self.setForeground(1, QtGui.QColor("red"))
+ self.setForeground(2, QtGui.QColor("gray"))
+
self.setText(2, "(replay parse error)")
- self.setForeground(2, QtGui.QColor("gray")) # FIXME: Needs to come from theme
def _setup_incomplete_appearance(self):
self.setIcon(0, util.THEME.icon("replays/replay.png"))
self.setText(1, self._replay_file)
self.setText(2, "(replay doesn't have complete metadata)")
- self.setForeground(1, QtGui.QColor("yellow")) # FIXME: Needs to come from theme
+ # FIXME: Needs to come from theme
+ self.setForeground(1, QtGui.QColor("yellow"))
- def _setup_complete_appearance(self):
- data = self._metadata.data
+ def _setup_complete_appearance(self) -> None:
+ data = self._metadata.model
launch_time = time.localtime(self._metadata.launch_time())
try:
game_time = time.strftime("%H:%M", launch_time)
except ValueError:
game_time = "Unknown"
- icon = fa.maps.preview(data['mapname'])
+ icon = fa.maps.preview(data.mapname)
if icon:
self.setIcon(0, icon)
else:
- dler = client.instance.map_downloader
- dler.download_preview(data['mapname'], self._map_dl_request)
+ dler = client.instance.map_preview_downloader
+ dler.download_preview(data.mapname, self._map_dl_request)
self.setIcon(0, util.THEME.icon("games/unknown_map.png"))
- self.setToolTip(0, fa.maps.getDisplayName(data['mapname']))
+ self.setToolTip(0, fa.maps.getDisplayName(data.mapname))
self.setText(0, game_time)
- self.setForeground(0, QtGui.QColor(client.instance.player_colors.getColor("default")))
- self.setText(1, data['title'])
+ self.setForeground(
+ 0,
+ QtGui.QColor(client.instance.player_colors.get_color("default")),
+ )
+ self.setText(1, data.title)
self.setToolTip(1, self._replay_file)
playerlist = []
- for players in list(data['teams'].values()):
+ for players in data.teams.values():
playerlist.extend(players)
self.setText(2, ", ".join(playerlist))
self.setToolTip(2, ", ".join(playerlist))
- self.setText(3, data['featured_mod'])
- self.setTextAlignment(3, QtCore.Qt.AlignCenter)
+ self.setText(3, data.featured_mod)
+ self.setTextAlignment(3, QtCore.Qt.AlignmentFlag.AlignCenter)
def replay_bucket(self):
if self._metadata is None:
@@ -430,28 +429,50 @@ def _setup_appearance(self, kind, children):
self.setIcon(0, util.THEME.icon("replays/bucket.png"))
self.setText(0, kind)
self.setText(3, "{} replays".format(len(children)))
- self.setForeground(3, QtGui.QColor(client.instance.player_colors.getColor("default")))
+ self.setForeground(
+ 3,
+ QtGui.QColor(client.instance.player_colors.get_color("default")),
+ )
for item in children:
self.addChild(item)
def _setup_broken_appearance(self):
- self.setForeground(0, QtGui.QColor("red")) # FIXME: Needs to come from theme
+ # FIXME: Needs to come from theme
+ self.setForeground(0, QtGui.QColor("red"))
+
self.setText(1, "(not watchable)")
- self.setForeground(1, QtGui.QColor(client.instance.player_colors.getColor("default")))
+ self.setForeground(
+ 1,
+ QtGui.QColor(client.instance.player_colors.get_color("default")),
+ )
def _setup_incomplete_appearance(self):
- self.setForeground(0, QtGui.QColor("yellow")) # FIXME: Needs to come from theme
+ # FIXME: Needs to come from theme
+ self.setForeground(0, QtGui.QColor("yellow"))
+
self.setText(1, "(watchable)")
- self.setForeground(1, QtGui.QColor(client.instance.player_colors.getColor("default")))
+ self.setForeground(
+ 1,
+ QtGui.QColor(client.instance.player_colors.get_color("default")),
+ )
def _setup_legacy_appearance(self):
- self.setForeground(0, QtGui.QColor(client.instance.player_colors.getColor("default")))
- self.setForeground(1, QtGui.QColor(client.instance.player_colors.getColor("default")))
+ self.setForeground(
+ 0,
+ QtGui.QColor(client.instance.player_colors.get_color("default")),
+ )
+ self.setForeground(
+ 1,
+ QtGui.QColor(client.instance.player_colors.get_color("default")),
+ )
self.setText(1, "(old replay system)")
def _setup_date_appearance(self):
- self.setForeground(0, QtGui.QColor(client.instance.player_colors.getColor("player")))
+ self.setForeground(
+ 0,
+ QtGui.QColor(client.instance.player_colors.get_color("player")),
+ )
class LocalReplaysWidgetHandler(object):
@@ -459,18 +480,27 @@ def __init__(self, myTree):
self.myTree = myTree
self.myTree.itemDoubleClicked.connect(self.myTreeDoubleClicked)
self.myTree.itemPressed.connect(self.myTreePressed)
- self.myTree.header().setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
- self.myTree.header().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents)
- self.myTree.header().setSectionResizeMode(2, QtWidgets.QHeaderView.Stretch)
- self.myTree.header().setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents)
+ self.myTree.header().setSectionResizeMode(
+ 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
+ )
+ self.myTree.header().setSectionResizeMode(
+ 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
+ )
+ self.myTree.header().setSectionResizeMode(
+ 2, QtWidgets.QHeaderView.ResizeMode.Stretch,
+ )
+ self.myTree.header().setSectionResizeMode(
+ 3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents,
+ )
self.myTree.modification_time = 0
replay_cache = os.path.join(util.CACHE_DIR, "local_replays_metadata")
- self.replay_files = LocalReplayMetadataCache(util.REPLAY_DIR,
- replay_cache)
+ self.replay_files = LocalReplayMetadataCache(
+ util.REPLAY_DIR, replay_cache,
+ )
def myTreePressed(self, item):
- if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.RightButton:
+ if QtWidgets.QApplication.mouseButtons() != QtCore.Qt.MouseButton.RightButton:
return
if item.isDisabled():
@@ -491,7 +521,9 @@ def myTreePressed(self, item):
# Triggers
actionReplay.triggered.connect(lambda: self.myTreeDoubleClicked(item))
- actionExplorer.triggered.connect(lambda: util.showFileInFileBrowser(item.replay_path()))
+ actionExplorer.triggered.connect(
+ lambda: util.showFileInFileBrowser(item.replay_path()),
+ )
# Adding to menu
menu.addAction(actionReplay)
@@ -509,12 +541,13 @@ def myTreeDoubleClicked(self, item):
def updatemyTree(self):
modification_time = os.path.getmtime(util.REPLAY_DIR)
- if self.myTree.modification_time == modification_time: # anything changed?
+ if self.myTree.modification_time == modification_time:
return # nothing changed -> don't redo
self.myTree.modification_time = modification_time
self.myTree.clear()
- # We put the replays into buckets by day first, then we add them to the treewidget.
+ # We put the replays into buckets by day first, then we add them to the
+ # treewidget.
buckets = {}
if not self.replay_files.cache_loaded:
@@ -534,7 +567,8 @@ def updatemyTree(self):
buckets[bucket].append(item)
self.replay_files.save_cache()
- # Now, create a top level treeWidgetItem for every bucket, and put the bucket's contents into them
+ # Now, create a top level treeWidgetItem for every bucket, and put the
+ # bucket's contents into them
for bucket, items in buckets.items():
bucket_item = LocalReplayBucketItem(bucket, items)
self.myTree.addTopLevelItem(bucket_item)
@@ -589,12 +623,20 @@ def __getitem__(self, filename):
class ReplayVaultWidgetHandler(object):
- HOST = "lobby.faforever.com"
- PORT = 11002
-
- # connect to save/restore persistence settings for checkboxes & search parameters
- automatic = Settings.persisted_property("replay/automatic", default_value=False, type=bool)
- spoiler_free = Settings.persisted_property("replay/spoilerFree", default_value=True, type=bool)
+ # connect to save/restore persistence settings for checkboxes & search
+ # parameters
+ automatic = Settings.persisted_property(
+ "replay/automatic", default_value=False, type=bool,
+ )
+ spoiler_free = Settings.persisted_property(
+ "replay/spoilerFree", default_value=True, type=bool,
+ )
+ hide_unranked = Settings.persisted_property(
+ "replay/hideUnranked", default_value=False, type=bool,
+ )
+ match_username = Settings.persisted_property(
+ "replay/matchUsername", default_value=True, type=bool,
+ )
def __init__(self, widget, dispatcher, client, gameset, playerset):
self._w = widget
@@ -605,13 +647,26 @@ def __init__(self, widget, dispatcher, client, gameset, playerset):
self.onlineReplays = {}
self.selectedReplay = None
- self.vault_connection = ReplaysConnection(self._dispatcher, self.HOST, self.PORT)
- self.client.lobby_info.replayVault.connect(self.replayVault)
+ self.apiConnector = ReplaysApiConnector()
+ self.apiConnector.data_ready.connect(self.process_replays_data)
self.replayDownload = QNetworkAccessManager()
- self.replayDownload.finished.connect(self.finishRequest)
+ self.replayDownload.finished.connect(self.onDownloadFinished)
+ self.toolboxHandler = ReplayToolboxHandler(
+ self, widget, dispatcher, client, gameset, playerset,
+ )
+ self.showLatest = True
self.searching = False
self.searchInfo = "Searching..."
+ self.defaultSearchParams = {
+ "page[number]": 1,
+ "page[size]": 100,
+ "sort": "-startTime",
+ "include": (
+ "featuredMod,mapVersion,mapVersion.map,playerStats,"
+ "playerStats.player"
+ ),
+ }
_w = self._w
_w.onlineTree.setItemDelegate(ReplayItemDelegate(_w))
@@ -622,55 +677,207 @@ def __init__(self, widget, dispatcher, client, gameset, playerset):
_w.playerName.returnPressed.connect(self.searchVault)
_w.mapName.returnPressed.connect(self.searchVault)
_w.automaticCheckbox.stateChanged.connect(self.automaticCheckboxchange)
+ _w.matchUsernameCheckbox.stateChanged.connect(
+ self.matchUsernameCheckboxChange,
+ )
+ _w.showLatestCheckbox.stateChanged.connect(
+ self.showLatestCheckboxchange,
+ )
_w.spoilerCheckbox.stateChanged.connect(self.spoilerCheckboxchange)
+ _w.hideUnrCheckbox.stateChanged.connect(self.hideUnrCheckboxchange)
_w.RefreshResetButton.pressed.connect(self.resetRefreshPressed)
# restore persistent checkbox settings
+ _w.matchUsernameCheckbox.setChecked(self.match_username)
_w.automaticCheckbox.setChecked(self.automatic)
_w.spoilerCheckbox.setChecked(self.spoiler_free)
+ _w.hideUnrCheckbox.setChecked(self.hide_unranked)
+
+ self.timer = QtCore.QTimer()
+ self.timer.timeout.connect(self.stopSearchVault)
+
+ def showToolTip(self, widget, msg):
+ """
+ Default tooltips are too slow and disappear when user starts typing
+ """
- def searchVault(self, minRating=None, mapName=None, playerName=None, modListIndex=None):
+ position = widget.mapToGlobal(
+ QtCore.QPoint(0 + widget.width(), 0 - widget.height() / 2),
+ )
+ QtWidgets.QToolTip.showText(position, msg)
+
+ def stopSearchVault(self):
+ self.searching = False
+ self._w.searchInfoLabel.clear()
+ self._w.advSearchInfoLabel.clear()
+ self.timer.stop()
+
+ def searchVault(
+ self,
+ minRating=None,
+ mapName=None,
+ playerName=None,
+ leaderboardId=None,
+ modListIndex=None,
+ quantity=None,
+ reset=None,
+ exactPlayerName=None,
+ ):
w = self._w
- if minRating is not None:
- w.minRating.setValue(minRating)
- if mapName is not None:
- w.mapName.setText(mapName)
- if playerName is not None:
- w.playerName.setText(playerName)
- if modListIndex is not None:
- w.modList.setCurrentIndex(modListIndex)
-
- # Map Search helper - the secondary server has a problem with blanks (fix until change to api)
- map_name = w.mapName.text().replace(" ", "*")
-
- """ search for some replays """
+ timePeriod = None
+
+ if self.searching:
+ QtWidgets.QMessageBox.critical(
+ None,
+ "Replay vault",
+ "Please, wait for previous search to finish.",
+ )
+ return
+
+ if reset:
+ w.minRating.setValue(0)
+ w.mapName.setText("")
+ w.playerName.setText("")
+ w.leaderboardList.setCurrentIndex(0)
+ w.modList.setCurrentIndex(0)
+ w.quantity.setValue(100)
+ w.showLatestCheckbox.setChecked(True)
+ else:
+ if minRating is not None:
+ w.minRating.setValue(minRating)
+ if mapName is not None:
+ w.mapName.setText(mapName)
+ if playerName is not None:
+ w.playerName.setText(playerName)
+ if leaderboardId is not None:
+ w.leaderboardList.setCurrentIndex(leaderboardId)
+ if modListIndex is not None:
+ w.modList.setCurrentIndex(modListIndex)
+ if quantity is not None:
+ w.quantity.setValue(quantity)
+ if not self.showLatest:
+ timePeriod = []
+ timePeriod.append(
+ w.dateStart.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate),
+ )
+ timePeriod.append(
+ w.dateEnd.dateTime().toUTC().toString(QtCore.Qt.DateFormat.ISODate),
+ )
+
+ filters = self.prepareFilters(
+ w.minRating.value(),
+ w.mapName.text(),
+ w.playerName.text(),
+ w.leaderboardList.currentIndex(),
+ w.modList.currentText(),
+ timePeriod,
+ exactPlayerName,
+ )
+
+ # """ search for some replays """
+ self._w.onlineTree.clear()
self._w.searchInfoLabel.setText(self.searchInfo)
+ self._w.searchInfoLabel.setVisible(True)
+ self._w.advSearchInfoLabel.setVisible(False)
self.searching = True
- self.vault_connection.connect()
- self.vault_connection.send(dict(command="search",
- rating=w.minRating.value(),
- map=map_name,
- player=w.playerName.text(),
- mod=w.modList.currentText()))
- self._w.onlineTree.clear()
+
+ parameters = self.defaultSearchParams.copy()
+ parameters["page[size]"] = w.quantity.value()
+
+ if filters:
+ parameters["filter"] = filters
+
+ self.apiConnector.requestData(parameters)
+ self.timer.start(90000)
+
+ def prepareFilters(
+ self,
+ minRating,
+ mapName,
+ playerName,
+ leaderboardId,
+ modListIndex,
+ timePeriod=None,
+ exactPlayerName=None,
+ ):
+ '''
+ Making filter string here + some logic to exclude "heavy" requests
+ which may overload database (>30 sec searches). It might looks weak
+ (and probably it is), but hey, it works! =)
+ '''
+
+ filters = []
+
+ if self.hide_unranked:
+ filters.append('validity=="VALID"')
+
+ if leaderboardId:
+ filters.append(
+ 'playerStats.ratingChanges.leaderboard.id=="{}"'
+ .format(leaderboardId),
+ )
+
+ if minRating and minRating > 0:
+ filters.append(
+ 'playerStats.ratingChanges.meanBefore=ge="{}"'
+ .format(minRating + 300),
+ )
+
+ if mapName:
+ filters.append(
+ 'mapVersion.map.displayName=="*{}*"'.format(mapName),
+ )
+
+ if playerName:
+ if self.match_username or exactPlayerName:
+ filters.append(
+ 'playerStats.player.login=="{}"'.format(playerName),
+ )
+ else:
+ filters.append(
+ 'playerStats.player.login=="*{}*"'.format(playerName),
+ )
+
+ if modListIndex and modListIndex != "All":
+ filters.append(
+ 'featuredMod.technicalName=="{}"'.format(modListIndex),
+ )
+
+ if timePeriod:
+ filters.append('startTime=ge="{}"'.format(timePeriod[0]))
+ filters.append('startTime=le="{}"'.format(timePeriod[1]))
+ elif len(filters) > 0:
+ months = 3
+ if playerName:
+ months = 6
+
+ startTime = (
+ QtCore.QDateTime.currentDateTimeUtc()
+ .addMonths(-months)
+ .toString(QtCore.Qt.DateFormat.ISODate)
+ )
+ filters.append('startTime=ge="{}"'.format(startTime))
+
+ if len(filters) > 0:
+ return "({})".format(";".join(filters))
+
+ return None
def reloadView(self):
- if not self.searching: # something else is already in the pipe from SearchVault
- if self.automatic or self.onlineReplays == {}: # refresh on Tab change or only the first time
- self._w.searchInfoLabel.setText(self.searchInfo)
- self.vault_connection.connect()
- self.vault_connection.send(dict(command="list"))
+ if not self.searching:
+ # refresh on Tab change or only the first time
+ if self.automatic or self.onlineReplays == {}:
+ self.searchVault(reset=True)
def onlineTreeClicked(self, item):
- if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.RightButton:
- if type(item.parent) == ReplaysWidget: # FIXME - hack
+ if QtWidgets.QApplication.mouseButtons() == QtCore.Qt.MouseButton.RightButton:
+ if isinstance(item.parent, ReplaysWidget): # FIXME - hack
item.pressed(item)
else:
self.selectedReplay = item
if hasattr(item, "moreInfo"):
if item.moreInfo is False:
- self.vault_connection.connect()
- self.vault_connection.send(dict(command="info_replay", uid=item.uid))
+ item.infoPlayers()
elif item.spoiled != self._w.spoilerCheckbox.isChecked():
self._w.replayInfos.clear()
self._w.replayInfos.setHtml(item.replayInfo)
@@ -678,13 +885,23 @@ def onlineTreeClicked(self, item):
else:
self._w.replayInfos.clear()
item.generateInfoPlayersHtml()
+ if self.toolboxHandler.mapPreview:
+ self.toolboxHandler.updateMapPreview()
def onlineTreeDoubleClicked(self, item):
+ if (
+ self.client.games.party
+ and self.client.games.party.memberCount > 1
+ ):
+ if not self.client.games.leave_party():
+ return
+
if hasattr(item, "duration"): # it's a game not a date separator
if "playing" in item.duration: # live game will not be in vault
- # search result isn't updated automatically - so game status might have changed
- if item.uid in self._gameset.games: # game still running
- game = self._gameset.games[item.uid]
+ # search result isn't updated automatically - so game status
+ # might have changed
+ if item.uid in self._gameset: # game still running
+ game = self._gameset[item.uid]
if not game.launched_at: # we frown upon those
return
if game.has_live_replay: # live game over 5min
@@ -693,21 +910,34 @@ def onlineTreeDoubleClicked(self, item):
self._startReplay(name)
break
else:
- wait_str = time.strftime('%M Min %S Sec', time.gmtime(game.LIVE_REPLAY_DELAY_SECS -
- (time.time() - game.launched_at)))
- QtWidgets.QMessageBox.information(client.instance, "5 Minute Live Game Delay",
- "It is too early to join the Game.\n"
- "You have to wait " + wait_str + " to join.")
+ delta = time.gmtime(
+ game.LIVE_REPLAY_DELAY_SECS
+ - (time.time() - game.launched_at),
+ )
+ wait_str = time.strftime('%M Min %S Sec', delta)
+ QtWidgets.QMessageBox.information(
+ client.instance,
+ "5 Minute Live Game Delay",
+ (
+ "It is too early to join the Game.\n"
+ "You have to wait {} to join.".format(wait_str)
+ ),
+ )
else: # game ended - ask to start replay
- if QtWidgets.QMessageBox.question(client.instance, "Live Game ended",
- "Would you like to watch the replay from the vault?",
- QtWidgets.QMessageBox.Yes,
- QtWidgets.QMessageBox.No) == QtWidgets.QMessageBox.Yes:
- self.replayDownload.get(QNetworkRequest(QtCore.QUrl(item.url)))
+ if QtWidgets.QMessageBox.question(
+ client.instance,
+ "Live Game ended",
+ "Would you like to watch the replay from the vault?",
+ QtWidgets.QMessageBox.StandardButton.Yes,
+ QtWidgets.QMessageBox.StandardButton.No,
+ ) == QtWidgets.QMessageBox.StandardButton.Yes:
+ req = QNetworkRequest(QtCore.QUrl(item.url))
+ self.replayDownload.get(req)
else: # start replay
if hasattr(item, "url"):
- self.replayDownload.get(QNetworkRequest(QtCore.QUrl(item.url)))
+ req = QNetworkRequest(QtCore.QUrl(item.url))
+ self.replayDownload.get(req)
def _startReplay(self, name):
if name is None or name not in self._playerset:
@@ -718,78 +948,82 @@ def _startReplay(self, name):
return
replay(player.currentGame.url(player.id))
+ def matchUsernameCheckboxChange(self, state):
+ self.match_username = state
+
def automaticCheckboxchange(self, state):
self.automatic = state
def spoilerCheckboxchange(self, state):
self.spoiler_free = state
- if self.selectedReplay: # if something is selected in the tree to the left
- if type(self.selectedReplay) == ReplayItem: # and if it is a game
- self.selectedReplay.generateInfoPlayersHtml() # then we redo it
-
- def resetRefreshPressed(self): # reset search parameter and reload recent Replays List
- self._w.searchInfoLabel.setText(self.searchInfo)
- self.vault_connection.connect()
- self.vault_connection.send(dict(command="list"))
- self._w.minRating.setValue(0)
- self._w.mapName.setText("")
- self._w.playerName.setText("")
- self._w.modList.setCurrentIndex(0) # "All"
-
- def finishRequest(self, reply):
- if reply.error() != QNetworkReply.NoError:
- QtWidgets.QMessageBox.warning(self._w, "Network Error", reply.errorString())
+ # if something is selected in the tree to the left
+ if self.selectedReplay:
+ # and if it is a game
+ if isinstance(self.selectedReplay, ReplayItem):
+ # then we redo it
+ self.selectedReplay.generateInfoPlayersHtml()
+
+ def showLatestCheckboxchange(self, state):
+ self.showLatest = state
+ if state: # disable date edit fields if True
+ self._w.dateStart.setEnabled(False)
+ self._w.dateEnd.setEnabled(False)
+ else: # enable date edit and set current date
+ self._w.dateStart.setEnabled(True)
+ self._w.dateEnd.setEnabled(True)
+
+ date = QtCore.QDate.currentDate()
+ self._w.dateStart.setDate(date)
+ self._w.dateEnd.setDate(date)
+
+ def hideUnrCheckboxchange(self, state):
+ self.hide_unranked = state
+
+ def resetRefreshPressed(self):
+ # reset search parameter and reload recent Replays List
+ if not self.searching:
+ self.searchVault(reset=True)
+
+ def onDownloadFinished(self, reply):
+ if reply.error() != QNetworkReply.NetworkError.NoError:
+ QtWidgets.QMessageBox.warning(
+ self._w, "Network Error", reply.errorString(),
+ )
else:
- faf_replay = QtCore.QFile(os.path.join(util.CACHE_DIR, "temp.fafreplay"))
- faf_replay.open(QtCore.QIODevice.WriteOnly | QtCore.QIODevice.Truncate)
+ faf_replay = QtCore.QFile(
+ os.path.join(util.CACHE_DIR, "temp.fafreplay"),
+ )
+ faf_replay.open(
+ QtCore.QIODevice.OpenModeFlag.WriteOnly
+ | QtCore.QIODevice.OpenModeFlag.Truncate,
+ )
faf_replay.write(reply.readAll())
faf_replay.flush()
faf_replay.close()
replay(os.path.join(util.CACHE_DIR, "temp.fafreplay"))
- def replayVault(self, message):
- action = message["action"]
- self._w.searchInfoLabel.clear()
- if action == "list_recents":
- self.onlineReplays = {}
- replays = message["replays"]
- for replay in replays:
- uid = replay["id"]
-
- if uid not in self.onlineReplays:
- self.onlineReplays[uid] = ReplayItem(uid, self._w)
- self.onlineReplays[uid].update(replay, self.client)
- else:
- self.onlineReplays[uid].update(replay, self.client)
-
- self.updateOnlineTree()
- self._w.replayInfos.clear()
- self._w.RefreshResetButton.setText("Refresh Recent List")
-
- elif action == "info_replay":
- uid = message["uid"]
- if uid in self.onlineReplays:
- self.onlineReplays[uid].infoPlayers(message["players"])
-
- elif action == "search_result":
- self.searching = False
- self.onlineReplays = {}
- replays = message["replays"]
- for replay in replays:
- uid = replay["id"]
-
- if uid not in self.onlineReplays:
- self.onlineReplays[uid] = ReplayItem(uid, self._w)
- self.onlineReplays[uid].update(replay, self.client)
- else:
- self.onlineReplays[uid].update(replay, self.client)
-
- self.updateOnlineTree()
- self._w.replayInfos.clear()
- self._w.RefreshResetButton.setText("Reset Search to Recent")
+ def process_replays_data(self, message: dict) -> None:
+ self.stopSearchVault()
+ self._w.replayInfos.clear()
+ self.onlineReplays = {}
+ replays = message["data"]
+ for replay_item in replays:
+ uid = int(replay_item["id"])
+ if uid not in self.onlineReplays:
+ self.onlineReplays[uid] = ReplayItem(uid, self._w)
+ self.onlineReplays[uid].update(replay_item, self.client)
+ self.updateOnlineTree()
+
+ if len(message["data"]) == 0:
+ self._w.searchInfoLabel.setText(
+ "No replays found",
+ )
+ self._w.advSearchInfoLabel.setText(
+ "No replays found",
+ )
def updateOnlineTree(self):
- self.selectedReplay = None # clear because won't be part of the new tree
+ self.selectedReplay = None # clear, it won't be part of the new tree
self._w.replayInfos.clear()
self._w.onlineTree.clear()
buckets = {}
@@ -802,13 +1036,18 @@ def updateOnlineTree(self):
self._w.onlineTree.addTopLevelItem(bucket_item)
bucket_item.setIcon(0, util.THEME.icon("replays/bucket.png"))
- bucket_item.setText(0, "" + bucket+"")
- bucket_item.setText(1, "" + str(len(buckets[bucket])) + " replays")
-
- for replay in buckets[bucket]:
- bucket_item.addChild(replay)
- replay.setFirstColumnSpanned(True)
- replay.setIcon(0, replay.icon)
+ bucket_item.setText(
+ 0, "{}".format(bucket),
+ )
+ bucket_len = len(buckets[bucket])
+ bucket_item.setText(
+ 1, "{} replays".format(bucket_len),
+ )
+
+ for replay_item in buckets[bucket]:
+ bucket_item.addChild(replay_item)
+ replay_item.setFirstColumnSpanned(True)
+ replay_item.setIcon(0, replay_item.icon)
bucket_item.setExpanded(True)
@@ -819,15 +1058,27 @@ def __init__(self, client, dispatcher, gameset, playerset):
self.setupUi(self)
- self.liveManager = LiveReplaysWidgetHandler(self.liveTree, client, gameset)
+ self.liveManager = LiveReplaysWidgetHandler(
+ self.liveTree, client, gameset,
+ )
self.localManager = LocalReplaysWidgetHandler(self.myTree)
- self.vaultManager = ReplayVaultWidgetHandler(self, dispatcher, client, gameset, playerset)
+ self.vaultManager = ReplayVaultWidgetHandler(
+ self, dispatcher, client, gameset, playerset,
+ )
logger.info("Replays Widget instantiated.")
- def set_player(self, name):
+ def set_player(self, name, leaderboardName=None):
self.setCurrentIndex(2) # focus on Online Fault
- self.vaultManager.searchVault(-1400, "", name, 0)
+ if leaderboardName is not None:
+ leaderboardId = self.leaderboardList.findText(leaderboardName)
+ self.vaultManager.searchVault(
+ 0, "", name, leaderboardId, 0, 100, exactPlayerName=True,
+ )
+ else:
+ self.vaultManager.searchVault(
+ 0, "", name, 0, 0, 100, exactPlayerName=True,
+ )
def focusEvent(self, event):
self.localManager.updatemyTree()
diff --git a/src/replays/connection.py b/src/replays/connection.py
deleted file mode 100644
index 81cbbe153..000000000
--- a/src/replays/connection.py
+++ /dev/null
@@ -1,105 +0,0 @@
-from PyQt5 import QtCore, QtNetwork
-import json
-
-import logging
-logger = logging.getLogger(__name__)
-
-# Connection to the replay vault. Given how this works, it will one day
-# be replaced with FAF API.
-
-
-class ReplaysConnection(QtCore.QObject):
- def __init__(self, dispatch, host, port):
- QtCore.QObject.__init__(self)
-
- self.dispatch = dispatch
- self.blockSize = 0
- self.host = host
- self.port = port
-
- self.replayVaultSocket = QtNetwork.QTcpSocket()
- self.replayVaultSocket.readyRead.connect(self._readDataFromServer)
- self.replayVaultSocket.error.connect(self._handleServerError)
- self.replayVaultSocket.disconnected.connect(self._disconnected)
-
- def connect(self):
- """ connect to the replay vault server """
- state = self.replayVaultSocket.state()
- states = QtNetwork.QAbstractSocket
- if state != states.ConnectedState and state != states.ConnectingState:
- self.replayVaultSocket.connectToHost(self.host, self.port)
-
- def receiveJSON(self, data_string, stream):
- """ A fairly pythonic way to process received strings as JSON messages. """
-
- try:
- message = json.loads(data_string)
- self.dispatch.dispatch(message)
- except ValueError as e:
- logger.error("Error decoding json ")
- logger.error(e)
- self.replayVaultSocket.disconnectFromHost()
-
- @QtCore.pyqtSlot()
- def _readDataFromServer(self):
- ins = QtCore.QDataStream(self.replayVaultSocket)
- ins.setVersion(QtCore.QDataStream.Qt_4_2)
-
- while not ins.atEnd():
- if self.blockSize == 0:
- if self.replayVaultSocket.bytesAvailable() < 4:
- return
- self.blockSize = ins.readUInt32()
- if self.replayVaultSocket.bytesAvailable() < self.blockSize:
- return
-
- action = ins.readQString()
- logger.debug("Replay Vault Server: " + action)
- self.receiveJSON(action, ins)
- self.blockSize = 0
-
- def send(self, message):
- data = json.dumps(message)
- logger.debug("Outgoing JSON Message: " + data)
- self._writeToServer(data)
-
- def _writeToServer(self, action, *args, **kw):
- logger.debug(("writeToServer(" + action + ", [" + ', '.join(args) + "])"))
-
- block = QtCore.QByteArray()
- out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite)
- out.setVersion(QtCore.QDataStream.Qt_4_2)
- out.writeUInt32(0)
- out.writeQString(action)
-
- for arg in args:
- if type(arg) is int:
- out.writeInt(arg)
- elif isinstance(arg, str):
- out.writeQString(arg)
- elif type(arg) is float:
- out.writeFloat(arg)
- elif type(arg) is list:
- out.writeQVariantList(arg)
- else:
- logger.warning("Uninterpreted Data Type: " + str(type(arg)) + " of value: " + str(arg))
- out.writeQString(str(arg))
-
- out.device().seek(0)
- out.writeUInt32(block.size() - 4)
- self.replayVaultSocket.write(block)
-
- def _handleServerError(self, socketError):
- if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError:
- logger.info("Replay Server down: The server is down for maintenance, please try later.")
-
- elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError:
- logger.info("Connection to Host lost. Please check the host name and port settings.")
-
- elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError:
- logger.info("The connection was refused by the peer.")
- else:
- logger.info("The following error occurred: %s." % self.replayVaultSocket.errorString())
-
- def _disconnected(self):
- logger.debug("Disconnected from server")
diff --git a/src/replays/models.py b/src/replays/models.py
new file mode 100644
index 000000000..21700a128
--- /dev/null
+++ b/src/replays/models.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+from pydantic import Field
+
+
+# FIXME - this is what the widget uses so far, we should define this
+# schema precisely in the future
+class MetadataModel(BaseModel):
+ complete: bool = Field(False)
+ featured_mod: str | None
+ launched_at: float
+ mapname: str
+ num_players: int
+ teams: dict[str, list[str]]
+ title: str
+ game_time: float = Field(0.0)
diff --git a/src/replays/replayToolbox.py b/src/replays/replayToolbox.py
new file mode 100644
index 000000000..97d0a7b67
--- /dev/null
+++ b/src/replays/replayToolbox.py
@@ -0,0 +1,368 @@
+import logging
+import os
+
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+
+from config import Settings
+from downloadManager import DownloadRequest
+from downloadManager import MapLargePreviewDownloader
+from util import MAP_PREVIEW_LARGE_DIR
+
+logger = logging.getLogger(__name__)
+
+filtersSettings = {
+ "Player name": dict(
+ filterString="playerStats.player.login",
+ operators=["contains", "is", "is not"],
+ ),
+ "One of global ratings": dict(
+ filterString="playerStats.player.globalRating.rating",
+ operators=[">", "<"],
+ ),
+ "One of ladder ratings": dict(
+ filterString="playerStats.player.ladder1v1Rating.rating",
+ operators=[">", "<"],
+ ),
+ "One of ratings": dict(
+ filterString="playerStats.ratingChanges.meanBefore",
+ operators=[">", "<"],
+ ),
+ "Game mod name": dict(
+ filterString="featuredMod.technicalName",
+ operators=["contains", "is", "is not"],
+ ),
+ "Leaderboard name": dict(
+ filterString="playerStats.ratingChanges.leaderboard.technicalName",
+ operators=["contains", "is", "is not"],
+ ),
+ "Map name": dict(
+ filterString="mapVersion.map.displayName",
+ operators=["contains", "is", "is not"],
+ ),
+ "Player faction": dict(
+ filterString="playerStats.faction",
+ operators=["is", "is not"],
+ values=["AEON", "CYBRAN", "UEF", "SERAPHIM", "NOMAD", "CIVILIAN"],
+ ),
+ "Player start position": dict(
+ filterString="playerStats.startSpot",
+ operators=["is", "is not"],
+ ),
+ "Max players (map)": dict(
+ filterString="mapVersion.maxPlayers",
+ operators=["is", "is not", ">", "<"],
+ ),
+ "Replay ID": dict(
+ filterString="id",
+ operators=["is"],
+ ),
+ "Title": dict(
+ filterString="name",
+ operators=["contains", "is", "is not"],
+ ),
+ "Start time": dict(
+ filterString="startTime",
+ operators=[">", "<"],
+ ),
+ "Validity": dict(
+ filterString="validity",
+ operators=["is"],
+ values=[
+ "VALID", "TOO_MANY_DESYNCS", "WRONG_VICTORY_CONDITION",
+ "NO_FOG_OF_WAR", "CHEATS_ENABLED", "PREBUILT_ENABLED",
+ "NORUSH_ENABLED", "BAD_UNIT_RESTRICTIONS", "BAD_MAP", "TOO_SHORT",
+ ],
+ ),
+ "Victory condition": dict(
+ filterString="victoryCondition",
+ operators=["is", "is not"],
+ values=[
+ "DEMORALIZATION", "DOMINATION", "ERADICATION",
+ "SANDBOX", "UNKNOWN",
+ ],
+ ),
+}
+
+operators = {
+ 'is': '=="{}"',
+ 'is not': '!="{}"',
+ 'contains': '=="*{}*"',
+ '>': '=gt="{}"',
+ '<': '=lt="{}"',
+}
+
+
+class ReplayToolboxHandler(object):
+ activePage = Settings.get('replay/activeTboxPage', "Hide all", str)
+
+ def __init__(
+ self,
+ wigetHandler,
+ widget,
+ dispatcher,
+ client,
+ gameset,
+ playerset,
+ ):
+ self._w = widget
+ self._dispatcher = dispatcher
+ self.client = client
+ self._gameset = gameset
+ self._playerset = playerset
+ self.widgetHandler = wigetHandler
+
+ self._map_preview_dler = MapLargePreviewDownloader(MAP_PREVIEW_LARGE_DIR)
+ self._map_dl_request = DownloadRequest()
+ self._map_dl_request.done.connect(self._on_map_preview_downloaded)
+
+ w = self._w
+
+ self.hidden = False
+ self.pageChanged = False
+ self.mapPreview = False
+ self.numOfFiltersLines = 6
+ self.filtersList = []
+ self.numOfPages = w.replayToolBox.count()
+ self.hideAllIndex = self.numOfPages - 1
+ self.tboxMinHeight = w.replayToolBox.minimumHeight()
+ self.widgetMinHeight = w.widget_3.minimumHeight()
+
+ w.replayToolBox.currentChanged.connect(self.tboxChanged)
+ w.advSearchButton.pressed.connect(self.advancedSearch)
+ w.advResetButton.pressed.connect(self.resetAll)
+ w.mapPreviewLabel.currentMap = None
+
+ self.setupTboxPages()
+ self.setupComboBoxes()
+
+ def setupTboxPages(self):
+ '''
+ A hack to imitate 'collapse all' function
+ + some style tweaks that can't be done via css or QtDesigner.
+ Ideally, we should rewrite QToolBox and make our own :)
+ '''
+ w = self._w
+ children = w.replayToolBox.children()
+
+ for widget in children:
+ if isinstance(widget, QtWidgets.QAbstractButton):
+ widget.clicked.connect(self.tboxTitleClicked)
+ widget.setStyleSheet("font-size:9pt")
+
+ # make our empty page invisible
+ children[-1].setStyleSheet(
+ "background-color: transparent; border-width: 0px",
+ )
+ children[-2].setStyleSheet("max-height: 0px")
+
+ for n in range(self.numOfPages):
+ if w.replayToolBox.itemText(n) == self.activePage:
+ w.replayToolBox.setCurrentIndex(n)
+ break
+
+ if self.activePage == "Hide all":
+ self.adjustTboxSize(hide=True)
+ elif self.activePage == "Map Preview":
+ self.mapPreview = True
+
+ def adjustTboxSize(self, hide=None):
+ ''' a part of "collapse all" hack'''
+ if hide:
+ self.hidden = True
+ height = 35 * self.numOfPages
+ self._w.widget_3.setMaximumHeight(height)
+ self._w.widget_3.setMinimumHeight(0)
+
+ self._w.replayToolBox.setMaximumHeight(height)
+ self._w.replayToolBox.setMinimumHeight(0)
+ else:
+ self.hidden = False
+ self._w.widget_3.setMaximumHeight(1000)
+ self._w.widget_3.setMinimumHeight(self.widgetMinHeight)
+
+ self._w.replayToolBox.setMaximumHeight(1000)
+ self._w.replayToolBox.setMinimumHeight(self.tboxMinHeight)
+
+ def tboxChanged(self, index):
+ page = self._w.replayToolBox.itemText(index)
+ if page == "Map Preview":
+ self.mapPreview = True
+ else:
+ self.mapPreview = False
+
+ Settings.set('replay/activeTboxPage', page)
+ self.pageChanged = True
+
+ def tboxTitleClicked(self, arg):
+ if not self.pageChanged:
+ self.adjustTboxSize(hide=True)
+ self._w.replayToolBox.setCurrentIndex(self.hideAllIndex)
+ elif self.hidden:
+ self.adjustTboxSize(hide=False)
+
+ self.pageChanged = False
+
+ # Advanced search section
+
+ def setupComboBoxes(self):
+ for n in range(1, self.numOfFiltersLines + 1):
+ filterComboBox = getattr(self._w, "filter{}".format(n))
+ filterComboBox.operatorBox = getattr(
+ self._w, "operator{}".format(n),
+ )
+ filterComboBox.valueBox = getattr(self._w, "value{}".format(n))
+ filterComboBox.layout = getattr(
+ self._w, "filterHorizontal{}".format(n),
+ )
+ filterComboBox.dateEdit = None
+ filterComboBox.dateIsActive = False
+
+ filterComboBox.currentIndexChanged.connect(self.filterChanged)
+ filterComboBox.addItem("")
+
+ for key, v in filtersSettings.items():
+ filterComboBox.addItem(key)
+ self.filtersList.append(filterComboBox)
+
+ self._w.filter1.setCurrentIndex(1)
+
+ def filterChanged(self):
+ '''Setup operator and value comboBoxes according to selected filter'''
+ filterWidget = self._w.sender()
+ filterName = filterWidget.currentText()
+ operatorBox = filterWidget.operatorBox
+ valueBox = filterWidget.valueBox
+
+ operatorBox.clear()
+ valueBox.clear()
+
+ if filterName:
+ if filterName == "Start time": # show date edit and hide valueBox
+ filterWidget.valueBox.hide()
+ if not filterWidget.dateEdit:
+ self.createDateEdit(filterWidget, valueBox)
+ else:
+ filterWidget.dateEdit.show()
+ filterWidget.dateIsActive = True
+ elif filterWidget.dateIsActive:
+ filterWidget.dateEdit.hide()
+ filterWidget.valueBox.show()
+ filterWidget.dateIsActive = False
+
+ for operator in filtersSettings[filterName]['operators']:
+ operatorBox.addItem(operator)
+
+ if 'values' in filtersSettings[filterName]:
+ for val in filtersSettings[filterName]['values']:
+ valueBox.addItem(val)
+ elif filterWidget.dateIsActive:
+ # Switch from "Start time" filter to empty
+ filterWidget.dateEdit.hide()
+ filterWidget.dateIsActive = False
+ filterWidget.valueBox.show()
+
+ def createDateEdit(self, filterWidget, valueBox):
+ filterWidget.dateEdit = QtWidgets.QDateEdit(
+ QtCore.QDate.currentDate(), valueBox,
+ )
+ filterWidget.dateEdit.setCalendarPopup(True)
+ filterWidget.layout.addWidget(filterWidget.dateEdit)
+
+ def advancedSearch(self):
+ if self.widgetHandler.searching:
+ QtWidgets.QMessageBox.critical(
+ None,
+ "Replay vault",
+ "Please, wait for previous search to finish.",
+ )
+ return
+
+ self._w.advSearchInfoLabel.setText(self.widgetHandler.searchInfo)
+ self._w.advSearchInfoLabel.setVisible(True)
+ self._w.searchInfoLabel.setVisible(False)
+ self.widgetHandler.searching = True
+
+ parameters = self.widgetHandler.defaultSearchParams.copy()
+ parameters["page[size]"] = self._w.advQuantity.value()
+
+ filters = self.prepareFilters()
+
+ if filters:
+ parameters["filter"] = filters
+
+ self.widgetHandler.apiConnector.requestData(parameters)
+ self.widgetHandler.timer.start(90000)
+
+ def prepareFilters(self):
+ finalFilters = []
+
+ for filterBox in self.filtersList:
+ filterName = filterBox.currentText()
+ opName = filterBox.operatorBox.currentText()
+ value = filterBox.valueBox.currentText()
+
+ if filterName:
+ filterString = filtersSettings[filterName]["filterString"]
+
+ if filterName == "Start time":
+ startDate = filterBox.dateEdit.dateTime().toUTC().toString(
+ QtCore.Qt.DateFormat.ISODate,
+ )
+ if opName == ">":
+ finalFilters.append(
+ filterString + operators[opName].format(startDate),
+ )
+ else:
+ finalFilters.append(
+ filterString + operators[opName].format(startDate),
+ )
+ elif filterName == "One of ratings":
+ finalFilters.append(
+ filterString + operators[opName].format(
+ int(value) + 300,
+ ),
+ )
+ elif value:
+ finalFilters.append(
+ filterString + operators[opName].format(value),
+ )
+
+ if len(finalFilters) > 0:
+ return "({})".format(";".join(finalFilters))
+
+ return None
+
+ def resetAll(self):
+ for filterWidget in self.filtersList:
+ filterWidget.setCurrentIndex(0)
+ filterWidget.valueBox.setEditText("")
+
+ # Map preview section
+
+ def updateMapPreview(self):
+ selectedReplay = self.widgetHandler.selectedReplay
+ if selectedReplay and hasattr(selectedReplay, "mapname"):
+ preview = self._w.mapPreviewLabel
+ if (
+ selectedReplay.mapname.lower() != "unknown"
+ and selectedReplay.mapname != preview.currentMap
+ ):
+ imgPath = os.path.join(
+ MAP_PREVIEW_LARGE_DIR, selectedReplay.mapname + ".png",
+ )
+
+ if os.path.isfile(imgPath):
+ pix = QtGui.QPixmap(imgPath)
+ preview.setPixmap(pix)
+ preview.currentMap = selectedReplay.mapname
+ else:
+ self._map_preview_dler.download_preview(
+ selectedReplay.mapname,
+ self._map_dl_request,
+ )
+
+ def _on_map_preview_downloaded(self, mapname, result):
+ if mapname == self.widgetHandler.selectedReplay.mapname:
+ self.updateMapPreview()
diff --git a/src/replays/replayitem.py b/src/replays/replayitem.py
index 4297efbcf..c3ee5aef3 100644
--- a/src/replays/replayitem.py
+++ b/src/replays/replayitem.py
@@ -1,59 +1,80 @@
import os
import time
+from datetime import datetime
+from datetime import timezone
-import util
-from PyQt5 import QtCore, QtWidgets, QtGui
+from PyQt6 import QtCore
+from PyQt6 import QtGui
+from PyQt6 import QtWidgets
+import util
from config import Settings
+from downloadManager import DownloadRequest
from fa import maps
from games.moditem import mods
-from downloadManager import PreviewDownloadRequest
class ReplayItemDelegate(QtWidgets.QStyledItemDelegate):
-
+
def __init__(self, *args, **kwargs):
QtWidgets.QStyledItemDelegate.__init__(self, *args, **kwargs)
-
+
def paint(self, painter, option, index, *args, **kwargs):
self.initStyleOption(option, index)
-
+
painter.save()
-
+
html = QtGui.QTextDocument()
html.setHtml(option.text)
-
+
icon = QtGui.QIcon(option.icon)
iconsize = icon.actualSize(option.rect.size())
-
- # clear icon and text before letting the control draw itself because we're rendering these parts ourselves
+
+ # clear icon and text before letting the control draw itself because
+ # we're rendering these parts ourselves
option.icon = QtGui.QIcon()
- option.text = ""
- option.widget.style().drawControl(QtWidgets.QStyle.CE_ItemViewItem, option, painter, option.widget)
-
+ option.text = ""
+ option.widget.style().drawControl(
+ QtWidgets.QStyle.ControlElement.CE_ItemViewItem, option, painter, option.widget,
+ )
+
# Shadow
- # painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1, iconsize.width(), iconsize.height(), QtGui.QColor("#202020"))
+ # painter.fillRect(option.rect.left()+8-1, option.rect.top()+8-1,
+ # iconsize.width(), iconsize.height(),
+ # QtGui.QColor("#202020"))
# Icon
- icon.paint(painter, option.rect.adjusted(5-2, -2, 0, 0), QtCore.Qt.AlignLeft|QtCore.Qt.AlignVCenter)
-
+ icon.paint(
+ painter, option.rect.adjusted(3, -2, 0, 0),
+ QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter,
+ )
+
# Frame around the icon
-# pen = QtWidgets.QPen()
-# pen.setWidth(1)
-# pen.setBrush(QtGui.QColor("#303030")) #FIXME: This needs to come from theme.
-# pen.setCapStyle(QtCore.Qt.RoundCap)
-# painter.setPen(pen)
-# painter.drawRect(option.rect.left()+5-2, option.rect.top()+5-2, iconsize.width(), iconsize.height())
+ # pen = QtWidgets.QPen()
+ # pen.setWidth(1)
+ # FIXME: This needs to come from theme.
+ # pen.setBrush(QtGui.QColor("#303030"))
+
+ # pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
+ # painter.setPen(pen)
+ # painter.drawRect(option.rect.left()+5-2, option.rect.top()+5-2,
+ # iconsize.width(), iconsize.height())
# Description
- painter.translate(option.rect.left() + iconsize.width() + 10, option.rect.top() + 10)
- clip = QtCore.QRectF(0, 0, option.rect.width()-iconsize.width() - 10 - 5, option.rect.height())
+ painter.translate(
+ option.rect.left() + iconsize.width() + 10,
+ option.rect.top() + 10,
+ )
+ clip = QtCore.QRectF(
+ 0, 0, option.rect.width() - iconsize.width() - 15,
+ option.rect.height(),
+ )
html.drawContents(painter, clip)
-
+
painter.restore()
def sizeHint(self, option, index, *args, **kwargs):
- clip = index.model().data(index, QtCore.Qt.UserRole)
+ clip = index.model().data(index, QtCore.Qt.ItemDataRole.UserRole)
self.initStyleOption(option, index)
html = QtGui.QTextDocument()
html.setHtml(option.text)
@@ -66,122 +87,192 @@ def sizeHint(self, option, index, *args, **kwargs):
class ReplayItem(QtWidgets.QTreeWidgetItem):
# list element
- FORMATTER_REPLAY = str(util.THEME.readfile("replays/formatters/replay.qthtml"))
+ FORMATTER_REPLAY = str(
+ util.THEME.readfile(
+ "replays/formatters/replay.qthtml",
+ ),
+ )
# replay-info elements
- FORMATTER_REPLAY_INFORMATION = "
".format(
+ playerLabel,
+ playerIcon,
+ playerScore,
+ )
+ )
if self.spoiled:
- if self.winner is not None: # FFA in rows: Win ... Lose ....
- teams += self.FORMATTER_REPLAY_FFA_SPOILED.format(winner=winnerHTML, players=players)
+ if self.winner is not None: # FFA in rows: Win... Lose...
+ teams += self.FORMATTER_REPLAY_FFA_SPOILED.format(
+ winner=winnerHTML, players=players,
+ )
else:
if "playing" in self.duration:
teamTitle = "Playing"
@@ -257,22 +376,37 @@ def generateInfoPlayersHtml(self):
teamTitle = "Lose"
if len(self.teams) == 2: # pack team in
- teams += self.FORMATTER_REPLAY_TEAM2_SPOILED.format(title=teamTitle, players=players)
+ teams += (
+ self.FORMATTER_REPLAY_TEAM2_SPOILED.format(
+ title=teamTitle, players=players,
+ )
+ )
else: # just row on
- teams += self.FORMATTER_REPLAY_TEAM_SPOILED.format(title=teamTitle, players=players)
+ teams += self.FORMATTER_REPLAY_TEAM_SPOILED.format(
+ title=teamTitle, players=players,
+ )
else:
if len(self.teams) == 2: # pack team in
- teams += self.FORMATTER_REPLAY_TEAM2.format(players=players)
+ teams += self.FORMATTER_REPLAY_TEAM2.format(
+ players=players,
+ )
else: # just row on
teams += players
if len(self.teams) == 2 and i == 1: # add the 'vs'
- teams += "
VS
"
+ teams += (
+ "
"
+ "VS
"
+ )
- if len(self.teams) == 2: # prepare the package to 'fit in' with its
s
- teams = "
%s
" % teams
+ # prepare the package to 'fit in' with its
s
+ if len(self.teams) == 2:
+ teams = "
{}
".format(teams)
- self.replayInfo = self.FORMATTER_REPLAY_INFORMATION.format(uid=self.uid, teams=teams)
+ self.replayInfo = self.FORMATTER_REPLAY_INFORMATION.format(
+ uid=self.uid, teams=teams,
+ )
if self.isSelected():
self.parent.replayInfos.clear()
@@ -285,17 +419,39 @@ def generatePlayerHTML(self, i, player):
else:
alignment = "left"
- playerLabel = self.FORMATTER_REPLAY_PLAYER_LABEL.format(player_name=player["name"],
- player_rating=player["rating"], alignment=alignment)
-
- iconUrl = os.path.join(util.COMMON_DIR, "replays/%s.png" % self.retrieveIconFaction(player, self.mod))
-
- playerIcon = self.FORMATTER_REPLAY_PLAYER_ICON.format(faction_icon_uri=iconUrl)
+ if "login" not in player["player"]:
+ player["player"]["login"] = "No data"
+
+ playerRating = int(
+ round((player["beforeMean"] - player["beforeDeviation"] * 3) / 100)
+ * 100,
+ )
+ playerLabel = self.FORMATTER_REPLAY_PLAYER_LABEL.format(
+ player_name=player["player"]["login"],
+ player_rating=playerRating,
+ alignment=alignment,
+ )
+
+ iconPath = os.path.join(
+ util.COMMON_DIR,
+ "replays/{}.png".format(
+ self.retrieveIconFaction(player, self.mod),
+ ),
+ )
+ iconUrl = QtCore.QUrl.fromLocalFile(iconPath).url()
+
+ playerIcon = self.FORMATTER_REPLAY_PLAYER_ICON.format(
+ faction_icon_uri=iconUrl,
+ )
if self.spoiled and not self.mod == "ladder1v1":
- playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format(player_score=player["score"])
+ playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format(
+ player_score=player["score"],
+ )
else: # no score for ladder
- playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format(player_score=" ")
+ playerScore = self.FORMATTER_REPLAY_PLAYER_SCORE.format(
+ player_score=" ",
+ )
return alignment, playerIcon, playerLabel, playerScore
@@ -331,13 +487,16 @@ def resize(self):
if self.extraInfoWidth == 0 or self.extraInfoHeight == 0:
if len(self.teams) == 1: # ladder, FFA
self.extraInfoWidth = 275
- self.extraInfoHeight = 75 + (self.numberplayers + 1) * 25 # + 1 -> second title
+ # + 1 -> second title
+ self.extraInfoHeight = 75 + (self.numberplayers + 1) * 25
elif len(self.teams) == 2: # Team vs Team
self.extraInfoWidth = 500
self.extraInfoHeight = 75 + self.biggestTeam * 22
else: # FAF
self.extraInfoWidth = 275
- self.extraInfoHeight = 75 + (self.numberplayers + len(self.teams)) * 25
+ self.extraInfoHeight = (
+ 75 + (self.numberplayers + len(self.teams)) * 25
+ )
self.parent.replayInfos.setMinimumWidth(self.extraInfoWidth)
self.parent.replayInfos.setMaximumWidth(600)
@@ -362,19 +521,19 @@ def display(self, column):
return self.viewtext
def data(self, column, role):
- if role == QtCore.Qt.DisplayRole:
+ if role == QtCore.Qt.ItemDataRole.DisplayRole:
return self.display(column)
- elif role == QtCore.Qt.UserRole:
+ elif role == QtCore.Qt.ItemDataRole.UserRole:
return self
return super(ReplayItem, self).data(column, role)
def permutations(self, items):
""" Yields all permutations of the items. """
- if items is []:
+ if items == []:
yield []
else:
for i in range(len(items)):
- for j in self.permutations(items[:i] + items[i+1:]):
+ for j in self.permutations(items[:i] + items[i + 1:]):
yield [items[i]] + j
def __ge__(self, other):
@@ -383,7 +542,9 @@ def __ge__(self, other):
def __lt__(self, other):
""" Comparison operator used for item list sorting """
- if not self.client: return True # If not initialized...
- if not other.client: return False
+ if not self.client:
+ return True # If not initialized...
+ if not other.client:
+ return False
# Default: uid
return self.uid < other.uid
diff --git a/src/secondaryServer/__init__.py b/src/secondaryServer/__init__.py
index 108b24a31..3996427ad 100644
--- a/src/secondaryServer/__init__.py
+++ b/src/secondaryServer/__init__.py
@@ -1 +1,5 @@
from .secondaryserver import SecondaryServer
+
+__all__ = (
+ "SecondaryServer",
+)
diff --git a/src/secondaryServer/secondaryserver.py b/src/secondaryServer/secondaryserver.py
index f54030ed6..49da8bd26 100644
--- a/src/secondaryServer/secondaryserver.py
+++ b/src/secondaryServer/secondaryserver.py
@@ -1,9 +1,10 @@
-from PyQt5 import QtCore, QtNetwork
-import time
import json
import logging
-from config import Settings
+from PyQt6 import QtCore
+from PyQt6 import QtNetwork
+
+from config import Settings
logger = logging.getLogger(__name__)
@@ -12,7 +13,8 @@ def log(string):
logger.debug(string)
-# A set of exceptions we use to see what goes wrong during asynchronous data transfer waits
+# A set of exceptions we use to see what goes wrong during asynchronous data
+# transfer waits
class Cancellation(Exception):
pass
@@ -47,7 +49,7 @@ def __init__(self, name, socket, dispatcher, *args, **kwargs):
self.name = name
- logger = logging.getLogger("faf.secondaryServer.%s" % self.name)
+ logger = logging.getLogger("faf.secondaryServer.{}".format(self.name))
logger.info("Instantiating secondary server.")
self.logger = logger
@@ -60,7 +62,7 @@ def __init__(self, name, socket, dispatcher, *args, **kwargs):
self.blockSize = 0
self.serverSocket = QtNetwork.QTcpSocket()
- self.serverSocket.error.connect(self.handleServerError)
+ self.serverSocket.errorOccurred.connect(self.handleServerError)
self.serverSocket.readyRead.connect(self.readDataFromServer)
self.serverSocket.connected.connect(self.send_pending)
self.invisible = False
@@ -71,10 +73,21 @@ def setInvisible(self):
def send(self, command, *args, **kwargs):
""" actually do the settings """
- self._requests += [{'command': command, 'args': args, 'kwargs': kwargs}]
+ self._requests.extend([{
+ 'command': command,
+ 'args': args,
+ 'kwargs': kwargs,
+ }])
self.logger.info("Pending requests: {}".format(len(self._requests)))
- if not self.serverSocket.state() == QtNetwork.QAbstractSocket.ConnectedState:
- self.logger.info("Connecting to {} {}:{}".format(self.name, self.HOST, self.socketPort))
+ if not (
+ self.serverSocket.state()
+ == QtNetwork.QAbstractSocket.SocketState.ConnectedState
+ ):
+ self.logger.info(
+ "Connecting to {} {}:{}".format(
+ self.name, self.HOST, self.socketPort,
+ ),
+ )
self.serverSocket.connectToHost(self.HOST, self.socketPort)
else:
self.send_pending()
@@ -111,19 +124,19 @@ def readDataFromServer(self):
def writeToServer(self, action, *args, **kw):
block = QtCore.QByteArray()
- out = QtCore.QDataStream(block, QtCore.QIODevice.ReadWrite)
+ out = QtCore.QDataStream(block, QtCore.QIODevice.OpenModeFlag.ReadWrite)
out.setVersion(QtCore.QDataStream.Qt_4_2)
out.writeUInt32(0)
out.writeQString(action)
for arg in args:
- if type(arg) is int:
+ if isinstance(arg, int):
out.writeInt(arg)
elif isinstance(arg, str):
out.writeQString(arg)
- elif type(arg) is float:
+ elif isinstance(arg, float):
out.writeFloat(arg)
- elif type(arg) is list:
+ elif isinstance(arg, list):
out.writeQVariantList(arg)
else:
out.writeQString(str(arg))
@@ -148,15 +161,26 @@ def receiveJSON(self, data_string, stream):
@QtCore.pyqtSlot('QAbstractSocket::SocketError')
def handleServerError(self, socketError):
"""
- Simple error handler that flags the whole operation as failed, not very graceful but what can you do...
+ Simple error handler that flags the whole operation as failed, not
+ very graceful but what can you do...
"""
- if socketError == QtNetwork.QAbstractSocket.RemoteHostClosedError:
- log("FA Server down: The server is down for maintenance, please try later.")
-
- elif socketError == QtNetwork.QAbstractSocket.HostNotFoundError:
- log("Connection to Host lost. Please check the host name and port settings.")
-
- elif socketError == QtNetwork.QAbstractSocket.ConnectionRefusedError:
+ if socketError == QtNetwork.QAbstractSocket.SocketError.RemoteHostClosedError:
+ log(
+ "FA Server down: The server is down for maintenance, please "
+ "try later.",
+ )
+
+ elif socketError == QtNetwork.QAbstractSocket.SocketError.HostNotFoundError:
+ log(
+ "Connection to Host lost. Please check the host name and port "
+ "settings.",
+ )
+
+ elif socketError == QtNetwork.QAbstractSocket.SocketError.ConnectionRefusedError:
log("The connection was refused by the peer.")
else:
- log("The following error occurred: %s." % self.serverSocket.errorString())
+ log(
+ "The following error occurred: {}.".format(
+ self.serverSocket.errorString(),
+ ),
+ )
diff --git a/src/stats/__init__.py b/src/stats/__init__.py
index 2fde42e57..d20f230c7 100644
--- a/src/stats/__init__.py
+++ b/src/stats/__init__.py
@@ -1,9 +1,15 @@
-from PyQt5 import QtCore
import logging
-import urllib.request, urllib.parse, urllib.error
-import util
-logger = logging.getLogger(__name__)
+from stats.itemviews.leaderboardtableview import LeaderboardTableView
+from stats.leaderboardlineedit import LeaderboardLineEdit
from ._statswidget import StatsWidget
+
+__all__ = (
+ "LeaderboardTableView",
+ "LeaderboardLineEdit",
+ "StatsWidget",
+)
+
+logger = logging.getLogger(__name__)
diff --git a/src/stats/_statswidget.py b/src/stats/_statswidget.py
index 19a84c8f4..a13bef632 100644
--- a/src/stats/_statswidget.py
+++ b/src/stats/_statswidget.py
@@ -1,14 +1,15 @@
-from PyQt5 import QtCore, QtWidgets, QtWebEngineWidgets
-import util
-from stats import mapstat
-from config import Settings
-import client
-from util.qt import injectWebviewCSS
+import logging
import time
+from PyQt6 import QtCore
+from PyQt6 import QtWidgets
+
+import util
+from api.stats_api import LeaderboardApiConnector
from ui.busy_widget import BusyWidget
-import logging
+from .leaderboard_widget import LeaderboardWidget
+
logger = logging.getLogger(__name__)
ANTIFLOOD = 0.1
@@ -20,197 +21,243 @@ class StatsWidget(BaseClass, FormClass, BusyWidget):
# signals
laddermaplist = QtCore.pyqtSignal(dict)
- laddermapstat = QtCore.pyqtSignal(dict)
def __init__(self, client):
super(BaseClass, self).__init__()
self.setupUi(self)
- self.client = client
-
- self.client.lobby_info.statsInfo.connect(self.processStatsInfos)
-
self.client = client
- self.webview = QtWebEngineWidgets.QWebEngineView()
-
- self.LadderRatings.layout().addWidget(self.webview)
-
self.selected_player = None
self.selected_player_loaded = False
- self.webview.loadFinished.connect(self.webview.show)
- self.webview.loadFinished.connect(self._injectCSS)
self.leagues.currentChanged.connect(self.leagueUpdate)
+ self.currentChanged.connect(self.busy_entered)
self.pagesDivisions = {}
self.pagesDivisionsResults = {}
self.pagesAllLeagues = {}
-
+
self.floodtimer = time.time()
-
+
self.currentLeague = 0
self.currentDivision = 0
-
- self.FORMATTER_LADDER = str(util.THEME.readfile("stats/formatters/ladder.qthtml"))
- self.FORMATTER_LADDER_HEADER = str(util.THEME.readfile("stats/formatters/ladder_header.qthtml"))
- util.THEME.setStyleSheet(self.leagues, "stats/formatters/style.css")
-
+ self.FORMATTER_LADDER = str(
+ util.THEME.readfile("stats/formatters/ladder.qthtml"),
+ )
+ self.FORMATTER_LADDER_HEADER = str(
+ util.THEME.readfile("stats/formatters/ladder_header.qthtml"),
+ )
+
+ util.THEME.stylesheets_reloaded.connect(self.load_stylesheet)
+ self.load_stylesheet()
+
# setup other tabs
- self.mapstat = mapstat.LadderMapStat(self.client, self)
+
+ self.apiConnector = LeaderboardApiConnector()
+ self.apiConnector.data_ready.connect(self.process_leaderboards_info)
+ self.apiConnector.requestData({"sort": "id"})
+
+ # hiding some non-functional tabs
+ self.removeTab(self.indexOf(self.ladderTab))
+ self.removeTab(self.indexOf(self.laddermapTab))
+
+ self.leaderboardNames = []
+ self.client.authorized.connect(self.onAuthorized)
+
+ def onAuthorized(self):
+ if not self.leaderboardNames:
+ self.refreshLeaderboards()
+
+ def refreshLeaderboards(self):
+ while self.client.replays.leaderboardList.count() != 1:
+ self.client.replays.leaderboardList.removeItem(1)
+ self.leaderboards.blockSignals(True)
+ while self.leaderboards.widget(0) is not None:
+ self.leaderboards.widget(0).deleteLater()
+ self.leaderboards.removeTab(0)
+ self.apiConnector.requestData(dict(sort="id"))
+ self.leaderboards.blockSignals(False)
+
+ def load_stylesheet(self):
+ self.setStyleSheet(
+ util.THEME.readstylesheet("stats/formatters/style.css"),
+ )
@QtCore.pyqtSlot(int)
def leagueUpdate(self, index):
self.currentLeague = index + 1
- leagueTab = self.leagues.widget(index).findChild(QtWidgets.QTabWidget,"league"+str(index))
+ leagueTab = self.leagues.widget(index).findChild(
+ QtWidgets.QTabWidget, "league" + str(index),
+ )
if leagueTab:
if leagueTab.currentIndex() == 0:
if time.time() - self.floodtimer > ANTIFLOOD:
- self.floodtimer = time.time()
- self.client.statsServer.send(dict(command="stats", type="league_table", league=self.currentLeague))
+ self.floodtimer = time.time()
+ self.client.statsServer.send(
+ dict(
+ command="stats",
+ type="league_table",
+ league=self.currentLeague,
+ ),
+ )
@QtCore.pyqtSlot(int)
def divisionsUpdate(self, index):
if index == 0:
if time.time() - self.floodtimer > ANTIFLOOD:
self.floodtimer = time.time()
- self.client.statsServer.send(dict(command="stats", type="league_table", league=self.currentLeague))
-
+ self.client.statsServer.send(
+ dict(
+ command="stats",
+ type="league_table",
+ league=self.currentLeague,
+ ),
+ )
+
elif index == 1:
tab = self.currentLeague - 1
if tab not in self.pagesDivisions:
- self.client.statsServer.send(dict(command="stats", type="divisions", league=self.currentLeague))
-
+ self.client.statsServer.send(
+ dict(
+ command="stats",
+ type="divisions",
+ league=self.currentLeague,
+ ),
+ )
+
@QtCore.pyqtSlot(int)
def divisionUpdate(self, index):
if time.time() - self.floodtimer > ANTIFLOOD:
self.floodtimer = time.time()
- self.client.statsServer.send(dict(command="stats", type="division_table",
- league=self.currentLeague, division=index))
-
+ self.client.statsServer.send(
+ dict(
+ command="stats",
+ type="division_table",
+ league=self.currentLeague,
+ division=index,
+ ),
+ )
+
def createDivisionsTabs(self, divisions):
userDivision = ""
me = self.client.me.player
if me.league is not None: # was me.division, but no there there
userDivision = me.league[1] # ? [0]=league and [1]=division
-
+
pages = QtWidgets.QTabWidget()
foundDivision = False
-
+
for division in divisions:
name = division["division"]
index = division["number"]
league = division["league"]
widget = QtWidgets.QTextBrowser()
-
+
if league not in self.pagesDivisionsResults:
self.pagesDivisionsResults[league] = {}
-
- self.pagesDivisionsResults[league][index] = widget
-
+
+ self.pagesDivisionsResults[league][index] = widget
+
pages.insertTab(index, widget, name)
-
+
if name == userDivision:
foundDivision = True
pages.setCurrentIndex(index)
- self.client.statsServer.send(dict(command="stats", type="division_table", league=league, division=index))
-
+ self.client.statsServer.send(
+ dict(
+ command="stats",
+ type="division_table",
+ league=league,
+ division=index,
+ ),
+ )
+
if not foundDivision:
- self.client.statsServer.send(dict(command="stats", type="division_table", league=league, division=0))
-
+ self.client.statsServer.send(
+ dict(
+ command="stats",
+ type="division_table",
+ league=league,
+ division=0,
+ ),
+ )
+
pages.currentChanged.connect(self.divisionUpdate)
return pages
def createResults(self, values, table):
-
+
formatter = self.FORMATTER_LADDER
formatter_header = self.FORMATTER_LADDER_HEADER
glist = []
append = glist.append
- append("