diff --git a/plugins/utilities.json b/plugins/utilities.json index 3eea4a5..8f801af 100644 --- a/plugins/utilities.json +++ b/plugins/utilities.json @@ -3,6 +3,30 @@ "description": "Utilities", "plugins_base_url": "https://github.com/bombsquad-community/plugin-manager/{content_type}/{tag}/plugins/utilities", "plugins": { + "random_join": { + "description": "Come visit the unknown servers around all the world! Plugin designed not to join servers with similar names more frequently than rare ones. Have fun!", + "external_url": "", + "authors": [ + { + "name": "maxick", + "email": "", + "discord": "maxick#9227" + }, + { + "name": "LoupGarou", + "email": "LoupGarou5418@outlook.com", + "discord": "ʟօʊքɢǟʀօʊ#3063" + } + ], + "versions": { + "1.0.0": { + "api_version": 7, + "commit_sha": "2454845", + "released_on": "26-12-2022", + "md5sum": "7bac6bfe837ff89e7da10a0ab45691d1" + } + } + }, "share_replay": { "description": "Export replays to mods folder and share them with friends or have a backup", "external_url": "", diff --git a/plugins/utilities/random_join.py b/plugins/utilities/random_join.py new file mode 100644 index 0000000..955b1a2 --- /dev/null +++ b/plugins/utilities/random_join.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +import _ba +import ba +import ba.internal +import random +from bastd.ui.gather.publictab import PublicGatherTab, PartyEntry, PingThread +if TYPE_CHECKING: + from typing import Callable + +ClassType = TypeVar('ClassType') +MethodType = TypeVar('Methodtype') + + +def override(cls: ClassType) -> Callable[[MethodType], MethodType]: + def decorator(newfunc: MethodType) -> MethodType: + funcname = newfunc.__code__.co_name + if hasattr(cls, funcname): + oldfunc = getattr(cls, funcname) + setattr(cls, f'_old_{funcname}', oldfunc) + + setattr(cls, funcname, newfunc) + return newfunc + + return decorator + +# Can this stuff break mro? (P.S. yes, so we're not using super() anymore). +# Although it gives nice auto-completion. +# And anyways, why not just GatherPublicTab = NewGatherPublicTab? +# But hmm, if we imagine someone used `from blah.blah import Blah`, using +# `blah.Blah = NewBlah` AFTERWARDS would be meaningless. + + +class NewPublicGatherTab(PublicGatherTab, PingThread): + + @override(PublicGatherTab) + def _build_join_tab(self, region_width: float, + region_height: float, + oldfunc: Callable = None) -> None: + # noinspection PyUnresolvedReferences + self._old__build_join_tab(region_width, region_height) + + # Copy-pasted from original function. + c_width = region_width + c_height = region_height - 20 + sub_scroll_height = c_height - 125 + sub_scroll_width = 830 + v = c_height - 35 + v -= 60 + + self._random_join_button = ba.buttonwidget( + parent=self._container, + label='random', + size=(90, 45), + position=(710, v + 10), + on_activate_call=ba.WeakCall(self._join_random_server), + ) + ba.widget(edit=self._random_join_button, up_widget=self._host_text, + left_widget=self._filter_text) + + # We could place it somewhere under plugin settings which is kind of + # official way to customise plugins. Although it's too deep: + # Gather Window -> Main Menu -> Settings -> Advanced -(scroll)-> + # Plugins -(scroll probably)-> RandomJoin Settings. + self._random_join_settings_button = ba.buttonwidget( + parent=self._container, + icon=ba.gettexture('settingsIcon'), + size=(40, 40), + position=(820, v + 13), + on_activate_call=ba.WeakCall(self._show_random_join_settings), + ) + + @override(PublicGatherTab) + def _show_random_join_settings(self) -> None: + RandomJoinSettingsPopup( + origin_widget=self._random_join_settings_button) + + @override(PublicGatherTab) + def _get_parties_list(self) -> list[PartyEntry]: + if (self._parties_sorted and + (randomjoin.maximum_ping == 9999 or + # Ensure that we've pinged at least 10%. + len([p for k, p in self._parties_sorted + if p.ping is not None]) > len(self._parties_sorted) / 10)): + randomjoin.cached_parties = [p for k, p in self._parties_sorted] + return randomjoin.cached_parties + + @override(PublicGatherTab) + def _join_random_server(self) -> None: + name_prefixes = set() + parties = [p for p in self._get_parties_list() if + (p.size >= randomjoin.minimum_players + and p.size < p.size_max and (randomjoin.maximum_ping == 9999 + or (p.ping is not None + and p.ping <= randomjoin.maximum_ping)))] + + if not parties: + ba.screenmessage('No suitable servers found; wait', + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + return + + for party in parties: + name_prefixes.add(party.name[:6]) + + random.choice(list(name_prefixes)) + + party = random.choice( + [p for p in parties if p.name[:6] in name_prefixes]) + + ba.internal.connect_to_party(party.address, party.port) + + +class RandomJoinSettingsPopup(ba.Window): + def __init__(self, origin_widget: ba.Widget) -> None: + c_width = 600 + c_height = 400 + uiscale = ba.app.ui.uiscale + super().__init__(root_widget=ba.containerwidget( + scale=( + 1.8 + if uiscale is ba.UIScale.SMALL + else 1.55 + if uiscale is ba.UIScale.MEDIUM + else 1.0 + ), + scale_origin_stack_offset=origin_widget.get_screen_space_center(), + stack_offset=(0, -10) + if uiscale is ba.UIScale.SMALL + else (0, 15) + if uiscale is ba.UIScale.MEDIUM + else (0, 0), + size=(c_width, c_height), + transition='in_scale', + )) + + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + h_align='center', + v_align='center', + text='Random Join Settings', + scale=1.5, + color=(0.6, 1.0, 0.6), + maxwidth=c_width * 0.8, + position=(c_width * 0.5, c_height - 60), + ) + + v = c_height - 120 + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + h_align='right', + v_align='center', + text='Maximum ping', + maxwidth=c_width * 0.3, + position=(c_width * 0.4, v), + ) + self._maximum_ping_edit = ba.textwidget( + parent=self._root_widget, + size=(c_width * 0.3, 40), + h_align='left', + v_align='center', + text=str(randomjoin.maximum_ping), + editable=True, + description='Maximum ping (ms)', + position=(c_width * 0.6, v - 20), + autoselect=True, + max_chars=4, + ) + v -= 60 + ba.textwidget( + parent=self._root_widget, + size=(0, 0), + h_align='right', + v_align='center', + text='Minimum players', + maxwidth=c_width * 0.3, + position=(c_width * 0.4, v), + ) + self._minimum_players_edit = ba.textwidget( + parent=self._root_widget, + size=(c_width * 0.3, 40), + h_align='left', + v_align='center', + text=str(randomjoin.minimum_players), + editable=True, + description='Minimum number of players', + position=(c_width * 0.6, v - 20), + autoselect=True, + max_chars=4, + ) + v -= 60 + + # Cancel button. + self.cancel_button = btn = ba.buttonwidget( + parent=self._root_widget, + label=ba.Lstr(resource='cancelText'), + size=(180, 60), + color=(1.0, 0.2, 0.2), + position=(40, 30), + on_activate_call=self._cancel, + autoselect=True, + ) + ba.containerwidget(edit=self._root_widget, cancel_button=btn) + + # Save button. + self.savebtn = btn = ba.buttonwidget( + parent=self._root_widget, + label=ba.Lstr(resource='saveText'), + size=(180, 60), + position=(c_width - 200, 30), + on_activate_call=self._save, + autoselect=True, + ) + ba.containerwidget(edit=self._root_widget, start_button=btn) + + def _save(self) -> None: + errored = False + minimum_players: int | None = None + maximum_ping: int | None = None + try: + minimum_players = int( + ba.textwidget(query=self._minimum_players_edit)) + except ValueError: + ba.screenmessage('"Minimum players" should be integer', + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + errored = True + try: + maximum_ping = int( + ba.textwidget(query=self._maximum_ping_edit)) + except ValueError: + ba.screenmessage('"Maximum ping" should be integer', + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + errored = True + if errored: + return + + assert minimum_players is not None + assert maximum_ping is not None + + if minimum_players < 0: + ba.screenmessage('"Minimum players" should be at least 0', + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + errored = True + + if maximum_ping <= 0: + ba.screenmessage('"Maximum ping" should be greater than 0', + color=(1, 0, 0)) + ba.playsound(ba.getsound('error')) + ba.screenmessage('(use 9999 as dont-care value)', + color=(1, 0, 0)) + errored = True + + if errored: + return + + randomjoin.maximum_ping = maximum_ping + randomjoin.minimum_players = minimum_players + + randomjoin.commit_config() + ba.playsound(ba.getsound('shieldUp')) + self._transition_out() + + def _cancel(self) -> None: + ba.playsound(ba.getsound('shieldDown')) + self._transition_out() + + def _transition_out(self) -> None: + ba.containerwidget(edit=self._root_widget, transition='out_scale') + + +class RandomJoin: + def __init__(self) -> None: + self.cached_parties: list[PartyEntry] = [] + self.maximum_ping: int = 9999 + self.minimum_players: int = 2 + self.load_config() + + def load_config(self) -> None: + cfg = ba.app.config.get('Random Join', { + 'maximum_ping': self.maximum_ping, + 'minimum_players': self.minimum_players, + }) + try: + self.maximum_ping = cfg['maximum_ping'] + self.minimum_players = cfg['minimum_players'] + except KeyError: + ba.screenmessage('Error: RandomJoin config is broken, resetting..', + color=(1, 0, 0), log=True) + ba.playsound(ba.getsound('error')) + self.commit_config() + + def commit_config(self) -> None: + ba.app.config['Random Join'] = { + 'maximum_ping': self.maximum_ping, + 'minimum_players': self.minimum_players, + } + ba.app.config.commit() + + +randomjoin = RandomJoin() + + +# ba_meta require api 7 +# ba_meta export ba.Plugin +class RandomJoinPlugin(ba.Plugin): + def on_app_running(self) -> None: + # I feel bad that all patching logic happens not here. + pass