diff --git a/.github/pyright-config.json b/.github/pyright-config.json index de7758a715..b6561afa46 100644 --- a/.github/pyright-config.json +++ b/.github/pyright-config.json @@ -2,6 +2,7 @@ "include": [ "../BizHawkClient.py", "../Patch.py", + "../test/param.py", "../test/general/test_groups.py", "../test/general/test_helpers.py", "../test/general/test_memory.py", diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml index b59336fafe..6788abd30a 100644 --- a/.github/workflows/analyze-modified-files.yml +++ b/.github/workflows/analyze-modified-files.yml @@ -65,7 +65,7 @@ jobs: continue-on-error: false if: env.diff != '' && matrix.task == 'flake8' run: | - flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }} + flake8 --count --select=E9,F63,F7,F82 --ignore F824 --show-source --statistics ${{ env.diff }} - name: "flake8: Lint modified files" continue-on-error: true diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 27ca76e41f..7529f693bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,8 +99,8 @@ jobs: if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough - build-ubuntu2004: - runs-on: ubuntu-20.04 + build-ubuntu2204: + runs-on: ubuntu-22.04 steps: # - copy code below to release.yml - - uses: actions/checkout@v4 @@ -132,7 +132,7 @@ jobs: # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate - "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer + "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml index 9492c83c9e..610f6d7477 100644 --- a/.github/workflows/ctest.yml +++ b/.github/workflows/ctest.yml @@ -11,7 +11,7 @@ on: - '**.hh?' - '**.hpp' - '**.hxx' - - '**.CMakeLists' + - '**/CMakeLists.txt' - '.github/workflows/ctest.yml' pull_request: paths: @@ -21,7 +21,7 @@ on: - '**.hh?' - '**.hpp' - '**.hxx' - - '**.CMakeLists' + - '**/CMakeLists.txt' - '.github/workflows/ctest.yml' jobs: @@ -36,9 +36,9 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: ilammy/msvc-dev-cmd@v1 + - uses: ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 if: startsWith(matrix.os,'windows') - - uses: Bacondish2023/setup-googletest@v1 + - uses: Bacondish2023/setup-googletest@49065d1f7a6d21f6134864dd65980fe5dbe06c73 with: build-type: 'Release' - name: Build tests diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aec4f90998..20d4d2fe32 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,8 @@ jobs: # build-release-windows: # this is done by hand because of signing # build-release-macos: # LF volunteer - build-release-ubuntu2004: - runs-on: ubuntu-20.04 + build-release-ubuntu2204: + runs-on: ubuntu-22.04 steps: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV @@ -64,7 +64,7 @@ jobs: # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate - "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer + "${{ env.PYTHON }}" -m pip install --upgrade pip "PyGObject<3.51.0" charset-normalizer python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" diff --git a/.gitignore b/.gitignore index 791f7b1bb7..f50fc17e23 100644 --- a/.gitignore +++ b/.gitignore @@ -4,11 +4,13 @@ *_Spoiler.txt *.bmbp *.apbp +*.apcivvi *.apl2ac *.apm3 *.apmc *.apz5 *.aptloz +*.aptww *.apemerald *.pyc *.pyd diff --git a/AdventureClient.py b/AdventureClient.py index 24c6a4c4fc..91567fc0a0 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -511,7 +511,7 @@ if __name__ == '__main__': import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/BaseClasses.py b/BaseClasses.py index e19ba5f777..ec3fa9cef1 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -616,7 +616,7 @@ class MultiWorld(): locations: Set[Location] = set() events: Set[Location] = set() for location in self.get_filled_locations(): - if type(location.item.code) is int: + if type(location.item.code) is int and type(location.address) is int: locations.add(location) else: events.add(location) @@ -869,21 +869,40 @@ class CollectionState(): def has(self, item: str, player: int, count: int = 1) -> bool: return self.prog_items[player][item] >= count + # for loops are specifically used in all/any/count methods, instead of all()/any()/sum(), to avoid the overhead of + # creating and iterating generator instances. In `return all(player_prog_items[item] for item in items)`, the + # argument to all() would be a new generator instance, for example. def has_all(self, items: Iterable[str], player: int) -> bool: """Returns True if each item name of items is in state at least once.""" - return all(self.prog_items[player][item] for item in items) + player_prog_items = self.prog_items[player] + for item in items: + if not player_prog_items[item]: + return False + return True def has_any(self, items: Iterable[str], player: int) -> bool: """Returns True if at least one item name of items is in state at least once.""" - return any(self.prog_items[player][item] for item in items) + player_prog_items = self.prog_items[player] + for item in items: + if player_prog_items[item]: + return True + return False def has_all_counts(self, item_counts: Mapping[str, int], player: int) -> bool: """Returns True if each item name is in the state at least as many times as specified.""" - return all(self.prog_items[player][item] >= count for item, count in item_counts.items()) + player_prog_items = self.prog_items[player] + for item, count in item_counts.items(): + if player_prog_items[item] < count: + return False + return True def has_any_count(self, item_counts: Mapping[str, int], player: int) -> bool: """Returns True if at least one item name is in the state at least as many times as specified.""" - return any(self.prog_items[player][item] >= count for item, count in item_counts.items()) + player_prog_items = self.prog_items[player] + for item, count in item_counts.items(): + if player_prog_items[item] >= count: + return True + return False def count(self, item: str, player: int) -> int: return self.prog_items[player][item] @@ -911,11 +930,20 @@ class CollectionState(): def count_from_list(self, items: Iterable[str], player: int) -> int: """Returns the cumulative count of items from a list present in state.""" - return sum(self.prog_items[player][item_name] for item_name in items) + player_prog_items = self.prog_items[player] + total = 0 + for item_name in items: + total += player_prog_items[item_name] + return total def count_from_list_unique(self, items: Iterable[str], player: int) -> int: """Returns the cumulative count of items from a list present in state. Ignores duplicates of the same item.""" - return sum(self.prog_items[player][item_name] > 0 for item_name in items) + player_prog_items = self.prog_items[player] + total = 0 + for item_name in items: + if player_prog_items[item_name] > 0: + total += 1 + return total # item name group related def has_group(self, item_name_group: str, player: int, count: int = 1) -> bool: @@ -994,9 +1022,6 @@ class Entrance: connected_region: Optional[Region] = None randomization_group: int randomization_type: EntranceType - # LttP specific, TODO: should make a LttPEntrance - addresses = None - target = None def __init__(self, player: int, name: str = "", parent: Optional[Region] = None, randomization_group: int = 0, randomization_type: EntranceType = EntranceType.ONE_WAY) -> None: @@ -1015,10 +1040,8 @@ class Entrance: return False - def connect(self, region: Region, addresses: Any = None, target: Any = None) -> None: + def connect(self, region: Region) -> None: self.connected_region = region - self.target = target - self.addresses = addresses region.entrances.append(self) def is_valid_source_transition(self, er_state: "ERPlacementState") -> bool: @@ -1078,6 +1101,9 @@ class Region: def __len__(self) -> int: return self._list.__len__() + def __iter__(self): + return iter(self._list) + # This seems to not be needed, but that's a bit suspicious. # def __del__(self): # self.clear() @@ -1282,9 +1308,6 @@ class Location: multiworld = self.parent_region.multiworld if self.parent_region and self.parent_region.multiworld else None return multiworld.get_name_string_for_object(self) if multiworld else f'{self.name} (Player {self.player})' - def __hash__(self): - return hash((self.name, self.player)) - def __lt__(self, other: Location): return (self.player, self.name) < (other.player, other.name) @@ -1388,6 +1411,10 @@ class Item: def flags(self) -> int: return self.classification.as_flag() + @property + def is_event(self) -> bool: + return self.code is None + def __eq__(self, other: object) -> bool: if not isinstance(other, Item): return NotImplemented diff --git a/CommonClient.py b/CommonClient.py index f6b2623f8c..b622fb939b 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -413,7 +413,8 @@ class CommonContext: await self.server.socket.close() if self.server_task is not None: await self.server_task - self.ui.update_hints() + if self.ui: + self.ui.update_hints() async def send_msgs(self, msgs: typing.List[typing.Any]) -> None: """ `msgs` JSON serializable """ @@ -624,9 +625,6 @@ class CommonContext: def consume_network_data_package(self, data_package: dict): self.update_data_package(data_package) - current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {}) - current_cache.update(data_package["games"]) - Utils.persistent_store("datapackage", "games", current_cache) logger.info(f"Got new ID/Name DataPackage for {', '.join(data_package['games'])}") for game, game_data in data_package["games"].items(): Utils.store_data_package_for_checksum(game, game_data) @@ -709,8 +707,16 @@ class CommonContext: logger.exception(msg, exc_info=exc_info, extra={'compact_gui': True}) self._messagebox_connection_loss = self.gui_error(msg, exc_info[1]) - def make_gui(self) -> typing.Type["kvui.GameManager"]: - """To return the Kivy App class needed for run_gui so it can be overridden before being built""" + def make_gui(self) -> "type[kvui.GameManager]": + """ + To return the Kivy `App` class needed for `run_gui` so it can be overridden before being built + + Common changes are changing `base_title` to update the window title of the client and + updating `logging_pairs` to automatically make new tabs that can be filled with their respective logger. + + ex. `logging_pairs.append(("Foo", "Bar"))` + will add a "Bar" tab which follows the logger returned from `logging.getLogger("Foo")` + """ from kvui import GameManager class TextManager(GameManager): @@ -899,6 +905,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.disconnected_intentionally = True ctx.event_invalid_game() elif 'IncompatibleVersion' in errors: + ctx.disconnected_intentionally = True raise Exception('Server reported your client version as incompatible. ' 'This probably means you have to update.') elif 'InvalidItemsHandling' in errors: @@ -1087,7 +1094,7 @@ def run_as_textclient(*args): if password_requested and not self.password: await super(TextContext, self).server_auth(password_requested) await self.get_username() - await self.send_connect() + await self.send_connect(game="") def on_package(self, cmd: str, args: dict): if cmd == "Connected": @@ -1119,7 +1126,7 @@ def run_as_textclient(*args): args = handle_url_arg(args, parser=parser) # use colorama to display colored text highlighting on windows - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/FF1Client.py b/FF1Client.py index b7c58e2061..748a95b72c 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -261,7 +261,7 @@ if __name__ == '__main__': parser = get_base_parser() args = parser.parse_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/Fill.py b/Fill.py index d1773c8213..cce7aec209 100644 --- a/Fill.py +++ b/Fill.py @@ -75,9 +75,11 @@ def fill_restrictive(multiworld: MultiWorld, base_state: CollectionState, locati items_to_place.append(reachable_items[next_player].pop()) for item in items_to_place: - for p, pool_item in enumerate(item_pool): + # The items added into `reachable_items` are placed starting from the end of each deque in + # `reachable_items`, so the items being placed are more likely to be found towards the end of `item_pool`. + for p, pool_item in enumerate(reversed(item_pool), start=1): if pool_item is item: - item_pool.pop(p) + del item_pool[-p] break maximum_exploration_state = sweep_from_pool( @@ -348,10 +350,10 @@ def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, lo if (location.item is not None and location.item.advancement and location.address is not None and not location.locked and location.item.player not in minimal_players): pool.append(location.item) - state.remove(location.item) location.item = None if location in state.advancements: state.advancements.remove(location) + state.remove(location.item) locations.append(location) if pool and locations: locations.sort(key=lambda loc: loc.progress_type != LocationProgressType.PRIORITY) @@ -500,13 +502,15 @@ def distribute_items_restrictive(multiworld: MultiWorld, if prioritylocations: # "priority fill" - fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, + maximum_exploration_state = sweep_from_pool(multiworld.state) + fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority", one_item_per_player=True, allow_partial=True) if prioritylocations: # retry with one_item_per_player off because some priority fills can fail to fill with that optimization - fill_restrictive(multiworld, multiworld.state, prioritylocations, progitempool, + maximum_exploration_state = sweep_from_pool(multiworld.state) + fill_restrictive(multiworld, maximum_exploration_state, prioritylocations, progitempool, single_player_placement=single_player, swap=False, on_place=mark_for_locking, name="Priority Retry", one_item_per_player=False) accessibility_corrections(multiworld, multiworld.state, prioritylocations, progitempool) @@ -514,14 +518,15 @@ def distribute_items_restrictive(multiworld: MultiWorld, if progitempool: # "advancement/progression fill" + maximum_exploration_state = sweep_from_pool(multiworld.state) if panic_method == "swap": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=True, name="Progression", single_player_placement=single_player) elif panic_method == "raise": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, name="Progression", single_player_placement=single_player) elif panic_method == "start_inventory": - fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, + fill_restrictive(multiworld, maximum_exploration_state, defaultlocations, progitempool, swap=False, allow_partial=True, name="Progression", single_player_placement=single_player) if progitempool: for item in progitempool: diff --git a/Generate.py b/Generate.py index b057db25a3..82386644e7 100644 --- a/Generate.py +++ b/Generate.py @@ -54,12 +54,22 @@ def mystery_argparse(): parser.add_argument("--skip_output", action="store_true", help="Skips generation assertion and output stages and skips multidata and spoiler output. " "Intended for debugging and testing purposes.") + parser.add_argument("--spoiler_only", action="store_true", + help="Skips generation assertion and multidata, outputting only a spoiler log. " + "Intended for debugging and testing purposes.") args = parser.parse_args() + + if args.skip_output and args.spoiler_only: + parser.error("Cannot mix --skip_output and --spoiler_only") + elif args.spoiler == 0 and args.spoiler_only: + parser.error("Cannot use --spoiler_only when --spoiler=0. Use --skip_output or set --spoiler to a different value") + if not os.path.isabs(args.weights_file_path): args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) if not os.path.isabs(args.meta_file_path): args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path) args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando) + return args @@ -108,6 +118,8 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: raise Exception("Cannot mix --sameoptions with --meta") else: meta_weights = None + + player_id = 1 player_files = {} for file in os.scandir(args.player_files_path): @@ -164,6 +176,7 @@ def main(args=None) -> Tuple[argparse.Namespace, int]: erargs.outputpath = args.outputpath erargs.skip_prog_balancing = args.skip_prog_balancing erargs.skip_output = args.skip_output + erargs.spoiler_only = args.spoiler_only erargs.name = {} erargs.csv_output = args.csv_output @@ -279,22 +292,30 @@ def get_choice(option, root, value=None) -> Any: raise RuntimeError(f"All options specified in \"{option}\" are weighted as zero.") -class SafeDict(dict): - def __missing__(self, key): - return '{' + key + '}' +class SafeFormatter(string.Formatter): + def get_value(self, key, args, kwargs): + if isinstance(key, int): + if key < len(args): + return args[key] + else: + return "{" + str(key) + "}" + else: + return kwargs.get(key, "{" + key + "}") def handle_name(name: str, player: int, name_counter: Counter): name_counter[name.lower()] += 1 number = name_counter[name.lower()] new_name = "%".join([x.replace("%number%", "{number}").replace("%player%", "{player}") for x in name.split("%%")]) - new_name = string.Formatter().vformat(new_name, (), SafeDict(number=number, - NUMBER=(number if number > 1 else ''), - player=player, - PLAYER=(player if player > 1 else ''))) + + new_name = SafeFormatter().vformat(new_name, (), {"number": number, + "NUMBER": (number if number > 1 else ''), + "player": player, + "PLAYER": (player if player > 1 else '')}) # Run .strip twice for edge case where after the initial .slice new_name has a leading whitespace. # Could cause issues for some clients that cannot handle the additional whitespace. new_name = new_name.strip()[:16].strip() + if new_name == "Archipelago": raise Exception(f"You cannot name yourself \"{new_name}\"") return new_name diff --git a/Launcher.py b/Launcher.py index 22c0944ab1..29bd71764e 100644 --- a/Launcher.py +++ b/Launcher.py @@ -1,5 +1,5 @@ """ -Archipelago launcher for bundled app. +Archipelago Launcher * if run with APBP as argument, launch corresponding client. * if run with executable as argument, run it passing argv[2:] as arguments @@ -8,9 +8,7 @@ Archipelago launcher for bundled app. Scroll down to components= to add components to the launcher as well as setup.py """ - import argparse -import itertools import logging import multiprocessing import shlex @@ -20,10 +18,11 @@ import urllib.parse import webbrowser from os.path import isfile from shutil import which -from typing import Callable, Optional, Sequence, Tuple, Union +from typing import Callable, Optional, Sequence, Tuple, Union, Any if __name__ == "__main__": import ModuleUpdate + ModuleUpdate.update() import settings @@ -105,7 +104,8 @@ components.extend([ Component("Generate Template Options", func=generate_yamls), Component("Archipelago Website", func=lambda: webbrowser.open("https://archipelago.gg/")), Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), - Component("Unrated/18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Unrated/18+ Discord Server", icon="discord", + func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), Component("Browse Files", func=browse_files), ]) @@ -114,7 +114,7 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: url = urllib.parse.urlparse(path) queries = urllib.parse.parse_qs(url.query) launch_args = (path, *launch_args) - client_component = None + client_component = [] text_client_component = None if "game" in queries: game = queries["game"][0] @@ -122,49 +122,40 @@ def handle_uri(path: str, launch_args: Tuple[str, ...]) -> None: game = "Archipelago" for component in components: if component.supports_uri and component.game_name == game: - client_component = component + client_component.append(component) elif component.display_name == "Text Client": text_client_component = component - if client_component is None: + from kvui import MDButton, MDButtonText + from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText + from kivymd.uix.divider import MDDivider + + if not client_component: run_component(text_client_component, *launch_args) return + else: + popup_text = MDDialogSupportingText(text="Select client to open and connect with.") + component_buttons = [MDDivider()] + for component in [text_client_component, *client_component]: + component_buttons.append(MDButton( + MDButtonText(text=component.display_name), + on_release=lambda *args, comp=component: run_component(comp, *launch_args), + style="text" + )) + component_buttons.append(MDDivider()) - from kvui import App, Button, BoxLayout, Label, Window + MDDialog( + # Headline + MDDialogHeadlineText(text="Connect to Multiworld"), + # Text + popup_text, + # Content + MDDialogContentContainer( + *component_buttons, + orientation="vertical" + ), - class Popup(App): - def __init__(self): - self.title = "Connect to Multiworld" - self.icon = r"data/icon.png" - super().__init__() - - def build(self): - layout = BoxLayout(orientation="vertical") - layout.add_widget(Label(text="Select client to open and connect with.")) - button_row = BoxLayout(orientation="horizontal", size_hint=(1, 0.4)) - - text_client_button = Button( - text=text_client_component.display_name, - on_release=lambda *args: run_component(text_client_component, *launch_args) - ) - button_row.add_widget(text_client_button) - - game_client_button = Button( - text=client_component.display_name, - on_release=lambda *args: run_component(client_component, *launch_args) - ) - button_row.add_widget(game_client_button) - - layout.add_widget(button_row) - - return layout - - def _stop(self, *largs): - # see run_gui Launcher _stop comment for details - self.root_window.close() - super()._stop(*largs) - - Popup().run() + ).open() def identify(path: Union[None, str]) -> Tuple[Union[None, str], Union[None, Component]]: @@ -220,100 +211,171 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) +def create_shortcut(button: Any, component: Component) -> None: + from pyshortcuts import make_shortcut + script = sys.argv[0] + wkdir = Utils.local_path() + + script = f"{script} \"{component.display_name}\"" + make_shortcut(script, name=f"Archipelago {component.display_name}", icon=local_path("data", "icon.ico"), + startmenu=False, terminal=False, working_dir=wkdir) + button.menu.dismiss() + + refresh_components: Optional[Callable[[], None]] = None -def run_gui(): - from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget, ApAsyncImage +def run_gui(path: str, args: Any) -> None: + from kvui import (ThemedApp, MDFloatLayout, MDGridLayout, ScrollBox) + from kivy.properties import ObjectProperty from kivy.core.window import Window - from kivy.uix.relativelayout import RelativeLayout + from kivy.metrics import dp + from kivymd.uix.button import MDIconButton + from kivymd.uix.card import MDCard + from kivymd.uix.menu import MDDropdownMenu + from kivymd.uix.snackbar import MDSnackbar, MDSnackbarText - class Launcher(App): + from kivy.lang.builder import Builder + + class LauncherCard(MDCard): + component: Component | None + image: str + context_button: MDIconButton = ObjectProperty(None) + + def __init__(self, *args, component: Component | None = None, image_path: str = "", **kwargs): + self.component = component + self.image = image_path + super().__init__(args, kwargs) + + class Launcher(ThemedApp): base_title: str = "Archipelago Launcher" - container: ContainerLayout - grid: GridLayout - _tool_layout: Optional[ScrollBox] = None - _client_layout: Optional[ScrollBox] = None + top_screen: MDFloatLayout = ObjectProperty(None) + navigation: MDGridLayout = ObjectProperty(None) + grid: MDGridLayout = ObjectProperty(None) + button_layout: ScrollBox = ObjectProperty(None) + cards: list[LauncherCard] + current_filter: Sequence[str | Type] | None - def __init__(self, ctx=None): + def __init__(self, ctx=None, path=None, args=None): self.title = self.base_title + " " + Utils.__version__ self.ctx = ctx self.icon = r"data/icon.png" + self.favorites = [] + self.launch_uri = path + self.launch_args = args + self.cards = [] + self.current_filter = (Type.CLIENT, Type.TOOL, Type.ADJUSTER, Type.MISC) + persistent = Utils.persistent_load() + if "launcher" in persistent: + if "favorites" in persistent["launcher"]: + self.favorites.extend(persistent["launcher"]["favorites"]) + if "filter" in persistent["launcher"]: + if persistent["launcher"]["filter"]: + filters = [] + for filter in persistent["launcher"]["filter"].split(", "): + if filter == "favorites": + filters.append(filter) + else: + filters.append(Type[filter]) + self.current_filter = filters super().__init__() - def _refresh_components(self) -> None: + def set_favorite(self, caller): + if caller.component.display_name in self.favorites: + self.favorites.remove(caller.component.display_name) + caller.icon = "star-outline" + else: + self.favorites.append(caller.component.display_name) + caller.icon = "star" - def build_button(component: Component) -> Widget: + def build_card(self, component: Component) -> LauncherCard: + """ + Builds a card widget for a given component. + + :param component: The component associated with the button. + + :return: The created Card Widget. """ - Builds a button widget for a given component. + button_card = LauncherCard(component=component, + image_path=icon_paths[component.icon]) - Args: - component (Component): The component associated with the button. + def open_menu(caller): + caller.menu.open() - Returns: - None. The button is added to the parent grid layout. + menu_items = [ + { + "text": "Add shortcut on desktop", + "leading_icon": "laptop", + "on_release": lambda: create_shortcut(button_card.context_button, component) + } + ] + button_card.context_button.menu = MDDropdownMenu(caller=button_card.context_button, items=menu_items) + button_card.context_button.bind(on_release=open_menu) - """ - button = Button(text=component.display_name, size_hint_y=None, height=40) - button.component = component - button.bind(on_release=self.component_action) - if component.icon != "icon": - image = ApAsyncImage(source=icon_paths[component.icon], - size=(38, 38), size_hint=(None, 1), pos=(5, 0)) - box_layout = RelativeLayout(size_hint_y=None, height=40) - box_layout.add_widget(button) - box_layout.add_widget(image) - return box_layout - return button + return button_card + + def _refresh_components(self, type_filter: Sequence[str | Type] | None = None) -> None: + if not type_filter: + type_filter = [Type.CLIENT, Type.ADJUSTER, Type.TOOL, Type.MISC] + favorites = "favorites" in type_filter # clear before repopulating - assert self._tool_layout and self._client_layout, "must call `build` first" - tool_children = reversed(self._tool_layout.layout.children) + assert self.button_layout, "must call `build` first" + tool_children = reversed(self.button_layout.layout.children) for child in tool_children: - self._tool_layout.layout.remove_widget(child) - client_children = reversed(self._client_layout.layout.children) - for child in client_children: - self._client_layout.layout.remove_widget(child) + self.button_layout.layout.remove_widget(child) - _tools = {c.display_name: c for c in components if c.type == Type.TOOL} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} - _miscs = {c.display_name: c for c in components if c.type == Type.MISC} + cards = [card for card in self.cards if card.component.type in type_filter + or favorites and card.component.display_name in self.favorites] - for (tool, client) in itertools.zip_longest(itertools.chain( - _tools.items(), _miscs.items(), _adjusters.items() - ), _clients.items()): - # column 1 - if tool: - self._tool_layout.layout.add_widget(build_button(tool[1])) - # column 2 - if client: - self._client_layout.layout.add_widget(build_button(client[1])) + self.current_filter = type_filter + + for card in cards: + self.button_layout.layout.add_widget(card) + + top = self.button_layout.children[0].y + self.button_layout.children[0].height \ + - self.button_layout.height + scroll_percent = self.button_layout.convert_distance_to_scroll(0, top) + self.button_layout.scroll_y = max(0, min(1, scroll_percent[1])) + + def filter_clients(self, caller): + self._refresh_components(caller.type) def build(self): - self.container = ContainerLayout() - self.grid = GridLayout(cols=2) - self.container.add_widget(self.grid) - self.grid.add_widget(Label(text="General", size_hint_y=None, height=40)) - self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40)) - self._tool_layout = ScrollBox() - self._tool_layout.layout.orientation = "vertical" - self.grid.add_widget(self._tool_layout) - self._client_layout = ScrollBox() - self._client_layout.layout.orientation = "vertical" - self.grid.add_widget(self._client_layout) - - self._refresh_components() + self.top_screen = Builder.load_file(Utils.local_path("data/launcher.kv")) + self.grid = self.top_screen.ids.grid + self.navigation = self.top_screen.ids.navigation + self.button_layout = self.top_screen.ids.button_layout + self.set_colors() + self.top_screen.md_bg_color = self.theme_cls.backgroundColor global refresh_components refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) - return self.container + for component in components: + self.cards.append(self.build_card(component)) + + self._refresh_components(self.current_filter) + + # Uncomment to re-enable the Kivy console/live editor + # Ctrl-E to enable it, make sure numlock/capslock is disabled + # from kivy.modules.console import create_console + # create_console(Window, self.top_screen) + + return self.top_screen + + def on_start(self): + if self.launch_uri: + handle_uri(self.launch_uri, self.launch_args) + self.launch_uri = None + self.launch_args = None @staticmethod def component_action(button): + MDSnackbar(MDSnackbarText(text="Opening in a new window..."), y=dp(24), pos_hint={"center_x": 0.5}, + size_hint_x=0.5).open() if button.component.func: button.component.func() else: @@ -333,7 +395,13 @@ def run_gui(): self.root_window.close() super()._stop(*largs) - Launcher().run() + def on_stop(self): + Utils.persistent_store("launcher", "favorites", self.favorites) + Utils.persistent_store("launcher", "filter", ", ".join(filter.name if isinstance(filter, Type) else filter + for filter in self.current_filter)) + super().on_stop() + + Launcher(path=path, args=args).run() # avoiding Launcher reference leak # and don't try to do something with widgets after window closed @@ -360,16 +428,14 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): path = args.get("Patch|Game|Component|url", None) if path is not None: - if path.startswith("archipelago://"): - handle_uri(path, args.get("args", ())) - return - file, component = identify(path) - if file: - args['file'] = file - if component: - args['component'] = component - if not component: - logging.warning(f"Could not identify Component responsible for {path}") + if not path.startswith("archipelago://"): + file, component = identify(path) + if file: + args['file'] = file + if component: + args['component'] = component + if not component: + logging.warning(f"Could not identify Component responsible for {path}") if args["update_settings"]: update_settings() @@ -378,7 +444,7 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): elif "component" in args: run_component(args["component"], *args["args"]) elif not args["update_settings"]: - run_gui() + run_gui(path, args.get("args", ())) if __name__ == '__main__': @@ -400,6 +466,7 @@ if __name__ == '__main__': main(parser.parse_args()) from worlds.LauncherComponents import processes + for process in processes: # we await all child processes to close before we tear down the process host # this makes it feel like each one is its own program, as the Launcher is closed now diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py index e2e16922fa..69f50938d2 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -26,8 +26,10 @@ import typing from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, server_loop) from NetUtils import ClientStatus +from worlds.ladx import LinksAwakeningWorld from worlds.ladx.Common import BASE_ID as LABaseID from worlds.ladx.GpsTracker import GpsTracker +from worlds.ladx.TrackerConsts import storage_key from worlds.ladx.ItemTracker import ItemTracker from worlds.ladx.LADXR.checkMetadata import checkMetadataTable from worlds.ladx.Locations import get_locations_to_id, meta_to_name @@ -100,19 +102,23 @@ class LAClientConstants: WRamCheckSize = 0x4 WRamSafetyValue = bytearray([0]*WRamCheckSize) + wRamStart = 0xC000 + hRamStart = 0xFF80 + hRamSize = 0x80 + MinGameplayValue = 0x06 MaxGameplayValue = 0x1A VictoryGameplayAndSub = 0x0102 - class RAGameboy(): cache = [] - cache_start = 0 - cache_size = 0 last_cache_read = None socket = None def __init__(self, address, port) -> None: + self.cache_start = LAClientConstants.wRamStart + self.cache_size = LAClientConstants.hRamStart + LAClientConstants.hRamSize - LAClientConstants.wRamStart + self.address = address self.port = port self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -131,9 +137,14 @@ class RAGameboy(): async def get_retroarch_status(self): return await self.send_command("GET_STATUS") - def set_cache_limits(self, cache_start, cache_size): - self.cache_start = cache_start - self.cache_size = cache_size + def set_checks_range(self, checks_start, checks_size): + self.checks_start = checks_start + self.checks_size = checks_size + + def set_location_range(self, location_start, location_size, critical_addresses): + self.location_start = location_start + self.location_size = location_size + self.critical_location_addresses = critical_addresses def send(self, b): if type(b) is str: @@ -188,21 +199,57 @@ class RAGameboy(): if not await self.check_safe_gameplay(): return - cache = [] - remaining_size = self.cache_size - while remaining_size: - block = await self.async_read_memory(self.cache_start + len(cache), remaining_size) - remaining_size -= len(block) - cache += block + attempts = 0 + while True: + # RA doesn't let us do an atomic read of a large enough block of RAM + # Some bytes can't change in between reading location_block and hram_block + location_block = await self.read_memory_block(self.location_start, self.location_size) + hram_block = await self.read_memory_block(LAClientConstants.hRamStart, LAClientConstants.hRamSize) + verification_block = await self.read_memory_block(self.location_start, self.location_size) + + valid = True + for address in self.critical_location_addresses: + if location_block[address - self.location_start] != verification_block[address - self.location_start]: + valid = False + + if valid: + break + + attempts += 1 + + # Shouldn't really happen, but keep it from choking + if attempts > 5: + return + + checks_block = await self.read_memory_block(self.checks_start, self.checks_size) if not await self.check_safe_gameplay(): return - self.cache = cache + self.cache = bytearray(self.cache_size) + + start = self.checks_start - self.cache_start + self.cache[start:start + len(checks_block)] = checks_block + + start = self.location_start - self.cache_start + self.cache[start:start + len(location_block)] = location_block + + start = LAClientConstants.hRamStart - self.cache_start + self.cache[start:start + len(hram_block)] = hram_block + self.last_cache_read = time.time() + async def read_memory_block(self, address: int, size: int): + block = bytearray() + remaining_size = size + while remaining_size: + chunk = await self.async_read_memory(address + len(block), remaining_size) + remaining_size -= len(chunk) + block += chunk + + return block + async def read_memory_cache(self, addresses): - # TODO: can we just update once per frame? if not self.last_cache_read or self.last_cache_read + 0.1 < time.time(): await self.update_cache() if not self.cache: @@ -359,11 +406,12 @@ class LinksAwakeningClient(): auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() self.auth = auth - async def wait_and_init_tracker(self): + async def wait_and_init_tracker(self, magpie: MagpieBridge): await self.wait_for_game_ready() self.tracker = LocationTracker(self.gameboy) self.item_tracker = ItemTracker(self.gameboy) self.gps_tracker = GpsTracker(self.gameboy) + magpie.gps_tracker = self.gps_tracker async def recved_item_from_ap(self, item_id, from_player, next_index): # Don't allow getting an item until you've got your first check @@ -405,9 +453,11 @@ class LinksAwakeningClient(): return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 async def main_tick(self, item_get_cb, win_cb, deathlink_cb): + await self.gameboy.update_cache() await self.tracker.readChecks(item_get_cb) await self.item_tracker.readItems() await self.gps_tracker.read_location() + await self.gps_tracker.read_entrances() current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] if self.deathlink_debounce and current_health != 0: @@ -457,7 +507,7 @@ class LinksAwakeningContext(CommonContext): la_task = None client = None # TODO: does this need to re-read on reset? - found_checks = [] + found_checks = set() last_resend = time.time() magpie_enabled = False @@ -465,6 +515,10 @@ class LinksAwakeningContext(CommonContext): magpie_task = None won = False + @property + def slot_storage_key(self): + return f"{self.slot_info[self.slot].name}_{storage_key}" + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: self.client = LinksAwakeningClient() self.slot_data = {} @@ -476,9 +530,7 @@ class LinksAwakeningContext(CommonContext): def run_gui(self) -> None: import webbrowser - import kvui - from kvui import Button, GameManager - from kivy.uix.image import Image + from kvui import GameManager, ImageButton class LADXManager(GameManager): logging_pairs = [ @@ -491,23 +543,25 @@ class LinksAwakeningContext(CommonContext): b = super().build() if self.ctx.magpie_enabled: - button = Button(text="", size=(30, 30), size_hint_x=None, - on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) - image = Image(size=(16, 16), texture=magpie_logo()) - button.add_widget(image) - - def set_center(_, center): - image.center = center - button.bind(center=set_center) - + button = ImageButton(texture=magpie_logo(), fit_mode="cover", image_size=(32, 32), size_hint_x=None, + on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) self.connect_layout.add_widget(button) + return b self.ui = LADXManager(self) self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - async def send_checks(self): - message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] + async def send_new_entrances(self, entrances: typing.Dict[str, str]): + # Store the entrances we find on the server for future sessions + message = [{ + "cmd": "Set", + "key": self.slot_storage_key, + "default": {}, + "want_reply": False, + "operations": [{"operation": "update", "value": entrances}], + }] + await self.send_msgs(message) had_invalid_slot_data = None @@ -537,13 +591,19 @@ class LinksAwakeningContext(CommonContext): await self.send_msgs(message) self.won = True + async def request_found_entrances(self): + await self.send_msgs([{"cmd": "Get", "keys": [self.slot_storage_key]}]) + + # Ask for updates so that players can co-op entrances in a seed + await self.send_msgs([{"cmd": "SetNotify", "keys": [self.slot_storage_key]}]) + async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if self.ENABLE_DEATHLINK: self.client.pending_deathlink = True def new_checks(self, item_ids, ladxr_ids): - self.found_checks += item_ids - create_task_log_exception(self.send_checks()) + self.found_checks.update(item_ids) + create_task_log_exception(self.check_locations(self.found_checks)) if self.magpie_enabled: create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) @@ -571,12 +631,24 @@ class LinksAwakeningContext(CommonContext): if cmd == "Connected": self.game = self.slot_info[self.slot].game self.slot_data = args.get("slot_data", {}) - + # This is sent to magpie over local websocket to make its own connection + self.slot_data.update({ + "server_address": self.server_address, + "slot_name": self.player_names[self.slot], + "password": self.password, + }) + # TODO - use watcher_event if cmd == "ReceivedItems": for index, item in enumerate(args["items"], start=args["index"]): self.client.recvd_checks[index] = item + if cmd == "Retrieved" and self.magpie_enabled and self.slot_storage_key in args["keys"]: + self.client.gps_tracker.receive_found_entrances(args["keys"][self.slot_storage_key]) + + if cmd == "SetReply" and self.magpie_enabled and args["key"] == self.slot_storage_key: + self.client.gps_tracker.receive_found_entrances(args["value"]) + async def sync(self): sync_msg = [{'cmd': 'Sync'}] await self.send_msgs(sync_msg) @@ -589,6 +661,12 @@ class LinksAwakeningContext(CommonContext): checkMetadataTable[check.id])] for check in ladxr_checks] self.new_checks(checks, [check.id for check in ladxr_checks]) + for check in ladxr_checks: + if check.value and check.linkedItem: + linkedItem = check.linkedItem + if 'condition' not in linkedItem or linkedItem['condition'](self.slot_data): + self.client.item_tracker.setExtraItem(check.linkedItem['item'], check.linkedItem['qty']) + async def victory(): await self.send_victory() @@ -622,21 +700,38 @@ class LinksAwakeningContext(CommonContext): if not self.client.recvd_checks: await self.sync() - await self.client.wait_and_init_tracker() + await self.client.wait_and_init_tracker(self.magpie) + min_tick_duration = 0.1 + last_tick = time.time() while True: await self.client.main_tick(on_item_get, victory, deathlink) - await asyncio.sleep(0.1) + now = time.time() + tick_duration = now - last_tick + sleep_duration = max(min_tick_duration - tick_duration, 0) + await asyncio.sleep(sleep_duration) + + last_tick = now + if self.last_resend + 5.0 < now: self.last_resend = now - await self.send_checks() + await self.check_locations(self.found_checks) if self.magpie_enabled: try: self.magpie.set_checks(self.client.tracker.all_checks) await self.magpie.set_item_tracker(self.client.item_tracker) - await self.magpie.send_gps(self.client.gps_tracker) - self.magpie.slot_data = self.slot_data + if self.slot_data and "slot_data" in self.magpie.features and not self.magpie.has_sent_slot_data: + self.magpie.slot_data = self.slot_data + await self.magpie.send_slot_data() + + if self.client.gps_tracker.needs_found_entrances: + await self.request_found_entrances() + self.client.gps_tracker.needs_found_entrances = False + + new_entrances = await self.magpie.send_gps(self.client.gps_tracker) + if new_entrances: + await self.send_new_entrances(new_entrances) except Exception: # Don't let magpie errors take out the client pass @@ -647,8 +742,8 @@ class LinksAwakeningContext(CommonContext): await asyncio.sleep(1.0) def run_game(romfile: str) -> None: - auto_start = typing.cast(typing.Union[bool, str], - Utils.get_options()["ladx_options"].get("rom_start", True)) + auto_start = LinksAwakeningWorld.settings.rom_start + if auto_start is True: import webbrowser webbrowser.open(romfile) @@ -705,6 +800,6 @@ async def main(): await ctx.shutdown() if __name__ == '__main__': - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 7e33a3d5ef..963557e8da 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -33,10 +33,15 @@ WINDOW_MIN_HEIGHT = 525 WINDOW_MIN_WIDTH = 425 class AdjusterWorld(object): + class AdjusterSubWorld(object): + def __init__(self, random): + self.random = random + def __init__(self, sprite_pool): import random self.sprite_pool = {1: sprite_pool} self.per_slot_randoms = {1: random} + self.worlds = {1: self.AdjusterSubWorld(random)} class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): diff --git a/MMBN3Client.py b/MMBN3Client.py index 140a98745c..4945d49221 100644 --- a/MMBN3Client.py +++ b/MMBN3Client.py @@ -370,7 +370,7 @@ if __name__ == "__main__": import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/Main.py b/Main.py index d0e7a7f879..6f6a09619d 100644 --- a/Main.py +++ b/Main.py @@ -56,32 +56,18 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) - max_item = 0 - max_location = 0 - for cls in AutoWorld.AutoWorldRegister.world_types.values(): - if cls.item_id_to_name: - max_item = max(max_item, max(cls.item_id_to_name)) - max_location = max(max_location, max(cls.location_id_to_name)) - - item_digits = len(str(max_item)) - location_digits = len(str(max_location)) item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) - del max_item, max_location for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): if not cls.hidden and len(cls.item_names) > 0: - logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " - f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " - f"{max(cls.item_id_to_name):{item_digits}}) | " - f"{len(cls.location_names):{location_count}} " - f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " - f"{max(cls.location_id_to_name):{location_digits}})") + logger.info(f" {name:{longest_name}}: Items: {len(cls.item_names):{item_count}} | " + f"Locations: {len(cls.location_names):{location_count}}") - del item_digits, location_digits, item_count, location_count + del item_count, location_count # This assertion method should not be necessary to run if we are not outputting any multidata. - if not args.skip_output: + if not args.skip_output and not args.spoiler_only: AutoWorld.call_stage(multiworld, "assert_generate") AutoWorld.call_all(multiworld, "generate_early") @@ -224,6 +210,15 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info(f'Beginning output...') outfilebase = 'AP_' + multiworld.seed_name + if args.spoiler_only: + if args.spoiler > 1: + logger.info('Calculating playthrough.') + multiworld.spoiler.create_playthrough(create_paths=args.spoiler > 2) + + multiworld.spoiler.to_file(output_path('%s_Spoiler.txt' % outfilebase)) + logger.info('Done. Skipped multidata modification. Total time: %s', time.perf_counter() - start) + return multiworld + output = tempfile.TemporaryDirectory() with output as temp_dir: output_players = [player for player in multiworld.player_ids if AutoWorld.World.generate_output.__code__ diff --git a/MultiServer.py b/MultiServer.py index 9e0868b0f4..c9e0ad8bfa 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -28,9 +28,11 @@ ModuleUpdate.update() if typing.TYPE_CHECKING: import ssl + from NetUtils import ServerConnection -import websockets import colorama +import websockets +from websockets.extensions.permessage_deflate import PerMessageDeflate try: # ponyorm is a requirement for webhost, not default server, so may not be importable from pony.orm.dbapiprovider import OperationalError @@ -44,8 +46,9 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ SlotType, LocationStore, Hint, HintStatus from BaseClasses import ItemClassification -min_client_version = Version(0, 1, 6) -colorama.init() + +min_client_version = Version(0, 5, 0) +colorama.just_fix_windows_console() def remove_from_list(container, value): @@ -64,9 +67,13 @@ def pop_from_container(container, value): return container -def update_dict(dictionary, entries): - dictionary.update(entries) - return dictionary +def update_container_unique(container, entries): + if isinstance(container, list): + existing_container_as_set = set(container) + container.extend([entry for entry in entries if entry not in existing_container_as_set]) + else: + container.update(entries) + return container def queue_gc(): @@ -107,7 +114,7 @@ modify_functions = { # lists/dicts: "remove": remove_from_list, "pop": pop_from_container, - "update": update_dict, + "update": update_container_unique, } @@ -119,13 +126,14 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int: class Client(Endpoint): version = Version(0, 0, 0) - tags: typing.List[str] = [] + tags: typing.List[str] remote_items: bool remote_start_inventory: bool no_items: bool no_locations: bool + no_text: bool - def __init__(self, socket: websockets.WebSocketServerProtocol, ctx: Context): + def __init__(self, socket: "ServerConnection", ctx: Context) -> None: super().__init__(socket) self.auth = False self.team = None @@ -175,6 +183,7 @@ class Context: "compatibility": int} # team -> slot id -> list of clients authenticated to slot. clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] + endpoints: list[Client] locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] hints_used: typing.Dict[typing.Tuple[int, int], int] @@ -364,18 +373,28 @@ class Context: return True def broadcast_all(self, msgs: typing.List[dict]): - msgs = self.dumper(msgs) - endpoints = (endpoint for endpoint in self.endpoints if endpoint.auth) - async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) + msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) + data = self.dumper(msgs) + endpoints = ( + endpoint + for endpoint in self.endpoints + if endpoint.auth and not (msg_is_text and endpoint.no_text) + ) + async_start(self.broadcast_send_encoded_msgs(endpoints, data)) def broadcast_text_all(self, text: str, additional_arguments: dict = {}): self.logger.info("Notice (all): %s" % text) self.broadcast_all([{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) def broadcast_team(self, team: int, msgs: typing.List[dict]): - msgs = self.dumper(msgs) - endpoints = (endpoint for endpoint in itertools.chain.from_iterable(self.clients[team].values())) - async_start(self.broadcast_send_encoded_msgs(endpoints, msgs)) + msg_is_text = all(msg["cmd"] == "PrintJSON" for msg in msgs) + data = self.dumper(msgs) + endpoints = ( + endpoint + for endpoint in itertools.chain.from_iterable(self.clients[team].values()) + if not (msg_is_text and endpoint.no_text) + ) + async_start(self.broadcast_send_encoded_msgs(endpoints, data)) def broadcast(self, endpoints: typing.Iterable[Client], msgs: typing.List[dict]): msgs = self.dumper(msgs) @@ -389,13 +408,13 @@ class Context: await on_client_disconnected(self, endpoint) def notify_client(self, client: Client, text: str, additional_arguments: dict = {}): - if not client.auth: + if not client.auth or client.no_text: return self.logger.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments}])) def notify_client_multiple(self, client: Client, texts: typing.List[str], additional_arguments: dict = {}): - if not client.auth: + if not client.auth or client.no_text: return async_start(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }], **additional_arguments} @@ -760,7 +779,7 @@ class Context: self.on_new_hint(team, slot) for slot, hint_data in concerns.items(): if recipients is None or slot in recipients: - clients = self.clients[team].get(slot) + clients = filter(lambda c: not c.no_text, self.clients[team].get(slot, [])) if not clients: continue client_hints = [datum[1] for datum in sorted(hint_data, key=lambda x: x[0].finding_player != slot)] @@ -769,7 +788,7 @@ class Context: def get_hint(self, team: int, finding_player: int, seeked_location: int) -> typing.Optional[Hint]: for hint in self.hints[team, finding_player]: - if hint.location == seeked_location: + if hint.location == seeked_location and hint.finding_player == finding_player: return hint return None @@ -819,7 +838,7 @@ def update_aliases(ctx: Context, team: int): async_start(ctx.send_encoded_msgs(client, cmd)) -async def server(websocket, path: str = "/", ctx: Context = None): +async def server(websocket: "ServerConnection", path: str = "/", ctx: Context = None) -> None: client = Client(websocket, ctx) ctx.endpoints.append(client) @@ -910,6 +929,10 @@ async def on_client_joined(ctx: Context, client: Client): "If your client supports it, " "you may have additional local commands you can list with /help.", {"type": "Tutorial"}) + if not any(isinstance(extension, PerMessageDeflate) for extension in client.socket.extensions): + ctx.notify_client(client, "Warning: your client does not support compressed websocket connections! " + "It may stop working in the future. If you are a player, please report this to the " + "client's developer.") ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) @@ -1117,7 +1140,7 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] for finding_player, location_id, item_id, receiving_player, item_flags \ in ctx.locations.find_item(slots, seeked_item_id): - prev_hint = ctx.get_hint(team, slot, location_id) + prev_hint = ctx.get_hint(team, finding_player, location_id) if prev_hint: hints.append(prev_hint) else: @@ -1803,7 +1826,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): ctx.clients[team][slot].append(client) client.version = args['version'] client.tags = args['tags'] - client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags + client.no_locations = "TextOnly" in client.tags or "Tracker" in client.tags + # set NoText for old PopTracker clients that predate the tag to save traffic + client.no_text = "NoText" in client.tags or ("PopTracker" in client.tags and client.version < (0, 5, 1)) connected_packet = { "cmd": "Connected", "team": client.team, "slot": client.slot, @@ -1876,6 +1901,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): client.tags = args["tags"] if set(old_tags) != set(client.tags): client.no_locations = 'TextOnly' in client.tags or 'Tracker' in client.tags + client.no_text = "NoText" in client.tags or ( + "PopTracker" in client.tags and client.version < (0, 5, 1) + ) ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has changed tags " f"from {old_tags} to {client.tags}.", @@ -2014,7 +2042,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): value = func(value, operation["value"]) ctx.stored_data[args["key"]] = args["value"] = value targets = set(ctx.stored_data_notification_clients[args["key"]]) - if args.get("want_reply", True): + if args.get("want_reply", False): targets.add(client) if targets: ctx.broadcast(targets, [args]) diff --git a/NetUtils.py b/NetUtils.py index d58bbe81e3..f2ae2a63a0 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -5,17 +5,18 @@ import enum import warnings from json import JSONEncoder, JSONDecoder -import websockets +if typing.TYPE_CHECKING: + from websockets import WebSocketServerProtocol as ServerConnection from Utils import ByValue, Version class HintStatus(ByValue, enum.IntEnum): - HINT_FOUND = 0 - HINT_UNSPECIFIED = 1 + HINT_UNSPECIFIED = 0 HINT_NO_PRIORITY = 10 HINT_AVOID = 20 HINT_PRIORITY = 30 + HINT_FOUND = 40 class JSONMessagePart(typing.TypedDict, total=False): @@ -151,7 +152,7 @@ decode = JSONDecoder(object_hook=_object_hook).decode class Endpoint: - socket: websockets.WebSocketServerProtocol + socket: "ServerConnection" def __init__(self, socket): self.socket = socket diff --git a/OoTClient.py b/OoTClient.py index 1154904173..6a87b9e722 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -346,7 +346,7 @@ if __name__ == '__main__': import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/Options.py b/Options.py index d9122d444c..95b9b468c6 100644 --- a/Options.py +++ b/Options.py @@ -1579,10 +1579,11 @@ def dump_player_options(multiworld: MultiWorld) -> None: player_output = { "Game": multiworld.game[player], "Name": multiworld.get_player_name(player), + "ID": player, } output.append(player_output) for option_key, option in world.options_dataclass.type_hints.items(): - if issubclass(Removed, option): + if option.visibility == Visibility.none: continue display_name = getattr(option, "display_name", option_key) player_output[display_name] = getattr(world.options, option_key).current_option_name @@ -1591,7 +1592,7 @@ def dump_player_options(multiworld: MultiWorld) -> None: game_option_names.append(display_name) with open(output_path(f"generate_{multiworld.seed_name}.csv"), mode="w", newline="") as file: - fields = ["Game", "Name", *all_option_names] + fields = ["ID", "Game", "Name", *all_option_names] writer = DictWriter(file, fields) writer.writeheader() writer.writerows(output) diff --git a/README.md b/README.md index d60f1b9665..5e14ef5de3 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ Currently, the following games are supported: * Saving Princess * Castlevania: Circle of the Moon * Inscryption +* Civilization VI +* The Legend of Zelda: The Wind Waker For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/SNIClient.py b/SNIClient.py index 9140c73c14..1156bf6040 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -735,6 +735,6 @@ async def main() -> None: if __name__ == '__main__': - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main()) colorama.deinit() diff --git a/UndertaleClient.py b/UndertaleClient.py index dfacee148a..1c522fac92 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -500,7 +500,7 @@ def main(): import colorama - colorama.init() + colorama.just_fix_windows_console() asyncio.run(_main()) colorama.deinit() diff --git a/Utils.py b/Utils.py index 0aa81af150..202b8da178 100644 --- a/Utils.py +++ b/Utils.py @@ -47,7 +47,7 @@ class Version(typing.NamedTuple): return ".".join(str(item) for item in self) -__version__ = "0.6.0" +__version__ = "0.6.2" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -443,7 +443,8 @@ class RestrictedUnpickler(pickle.Unpickler): else: mod = importlib.import_module(module) obj = getattr(mod, name) - if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection)): + if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, + self.options_module.PlandoText)): return obj # Forbid everything else. raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") diff --git a/WargrooveClient.py b/WargrooveClient.py index f9971f7a6c..595a221cd2 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -214,17 +214,11 @@ class WargrooveContext(CommonContext): def run_gui(self): """Import kivy UI system and start running it as self.ui_task.""" from kvui import GameManager, HoverBehavior, ServerToolTip - from kivy.uix.tabbedpanel import TabbedPanelItem + from kivymd.uix.tab import MDTabsItem, MDTabsItemText from kivy.lang import Builder - from kivy.uix.button import Button from kivy.uix.togglebutton import ToggleButton from kivy.uix.boxlayout import BoxLayout - from kivy.uix.gridlayout import GridLayout - from kivy.uix.image import AsyncImage, Image - from kivy.uix.stacklayout import StackLayout from kivy.uix.label import Label - from kivy.properties import ColorProperty - from kivy.uix.image import Image import pkgutil class TrackerLayout(BoxLayout): @@ -446,6 +440,6 @@ if __name__ == '__main__': parser = get_base_parser(description="Wargroove Client, for text interfacing.") args, rest = parser.parse_known_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/WebHostLib/api/user.py b/WebHostLib/api/user.py index 0ddb6fe83e..2524cc40a6 100644 --- a/WebHostLib/api/user.py +++ b/WebHostLib/api/user.py @@ -28,6 +28,6 @@ def get_seeds(): response.append({ "seed_id": seed.id, "creation_time": seed.creation_time, - "players": get_players(seed.slots), + "players": get_players(seed), }) return jsonify(response) diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 8ba093e014..b330146277 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -9,7 +9,7 @@ from threading import Event, Thread from typing import Any from uuid import UUID -from pony.orm import db_session, select, commit +from pony.orm import db_session, select, commit, PrimaryKey from Utils import restricted_loads from .locker import Locker, AlreadyRunningException @@ -36,12 +36,21 @@ def handle_generation_failure(result: BaseException): logging.exception(e) +def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: + from setproctitle import setproctitle + + setproctitle(f"Generator ({sid})") + res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) + setproctitle(f"Generator (idle)") + return res + + def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): try: meta = json.loads(generation.meta) options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") - pool.apply_async(gen_game, (options,), + pool.apply_async(_mp_gen_game, (options,), {"meta": meta, "sid": generation.id, "owner": generation.owner}, @@ -55,6 +64,10 @@ def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): def init_generator(config: dict[str, Any]) -> None: + from setproctitle import setproctitle + + setproctitle("Generator (idle)") + try: import resource except ModuleNotFoundError: diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index a2eef108b0..2ebb40d673 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -117,6 +117,7 @@ class WebHostContext(Context): self.gamespackage = {"Archipelago": static_gamespackage.get("Archipelago", {})} # this may be modified by _load self.item_name_groups = {"Archipelago": static_item_name_groups.get("Archipelago", {})} self.location_name_groups = {"Archipelago": static_location_name_groups.get("Archipelago", {})} + missing_checksum = False for game in list(multidata.get("datapackage", {})): game_data = multidata["datapackage"][game] @@ -132,11 +133,13 @@ class WebHostContext(Context): continue else: self.logger.warning(f"Did not find game_data_package for {game}: {game_data['checksum']}") + else: + missing_checksum = True # Game rolled on old AP and will load data package from multidata self.gamespackage[game] = static_gamespackage.get(game, {}) self.item_name_groups[game] = static_item_name_groups.get(game, {}) self.location_name_groups[game] = static_location_name_groups.get(game, {}) - if not game_data_packages: + if not game_data_packages and not missing_checksum: # all static -> use the static dicts directly self.gamespackage = static_gamespackage self.item_name_groups = static_item_name_groups @@ -224,6 +227,9 @@ def set_up_logging(room_id) -> logging.Logger: def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], host: str, rooms_to_run: multiprocessing.Queue, rooms_shutting_down: multiprocessing.Queue): + from setproctitle import setproctitle + + setproctitle(name) Utils.init_logging(name) try: import resource @@ -244,8 +250,23 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, raise Exception("Worlds system should not be loaded in the custom server.") import gc - ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None - del cert_file, cert_key_file, ponyconfig + + if not cert_file: + def get_ssl_context(): + return None + else: + load_date = None + ssl_context = load_server_cert(cert_file, cert_key_file) + + def get_ssl_context(): + nonlocal load_date, ssl_context + today = datetime.date.today() + if load_date != today: + ssl_context = load_server_cert(cert_file, cert_key_file) + load_date = today + return ssl_context + + del ponyconfig gc.collect() # free intermediate objects used during setup loop = asyncio.get_event_loop() @@ -260,12 +281,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict, assert ctx.server is None try: ctx.server = websockets.serve( - functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) + functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=get_ssl_context()) await ctx.server except OSError: # likely port in use ctx.server = websockets.serve( - functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) + functools.partial(server, ctx=ctx), ctx.host, 0, ssl=get_ssl_context()) await ctx.server port = 0 diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 0bd9f7e5e0..34033a0854 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -135,6 +135,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non {"bosses", "items", "connections", "texts"})) erargs.skip_prog_balancing = False erargs.skip_output = False + erargs.spoiler_only = False erargs.csv_output = False name_counter = Counter() diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index 6be0e470b3..98731b65bd 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -35,6 +35,12 @@ def start_playing(): @app.route('/games//info/') @cache.cached() def game_info(game, lang): + try: + world = AutoWorldRegister.world_types[game] + if lang not in world.web.game_info_languages: + raise KeyError("Sorry, this game's info page is not available in that language yet.") + except KeyError: + return abort(404) return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game)) @@ -52,6 +58,12 @@ def games(): @app.route('/tutorial///') @cache.cached() def tutorial(game, file, lang): + try: + world = AutoWorldRegister.world_types[game] + if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]: + raise KeyError("Sorry, the tutorial is not available in that language yet.") + except KeyError: + return abort(404) return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game)) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 15b7bd61ce..711762ee5f 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -6,7 +6,7 @@ from typing import Dict, Union from docutils.core import publish_parts import yaml -from flask import redirect, render_template, request, Response +from flask import redirect, render_template, request, Response, abort import Options from Utils import local_path @@ -142,7 +142,10 @@ def weighted_options_old(): @app.route("/games//weighted-options") @cache.cached() def weighted_options(game: str): - return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + try: + return render_options_page("weightedOptions/weightedOptions.html", game, is_complex=True) + except KeyError: + return abort(404) @app.route("/games//generate-weighted-yaml", methods=["POST"]) @@ -197,7 +200,10 @@ def generate_weighted_yaml(game: str): @app.route("/games//player-options") @cache.cached() def player_options(game: str): - return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) + try: + return render_options_page("playerOptions/playerOptions.html", game, is_complex=False) + except KeyError: + return abort(404) # YAML generator for player-options diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index b7b14dea1e..a9cd33dd6d 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,11 +1,12 @@ -flask>=3.0.3 -werkzeug>=3.0.6 +flask>=3.1.0 +werkzeug>=3.1.3 pony>=0.7.19 -waitress>=3.0.0 +waitress>=3.0.2 Flask-Caching>=2.3.0 -Flask-Compress>=1.15 -Flask-Limiter>=3.8.0 -bokeh>=3.5.2 -markupsafe>=2.1.5 +Flask-Compress>=1.17 +Flask-Limiter>=3.12 +bokeh>=3.6.3 +markupsafe>=3.0.2 Markdown>=3.7 mdx-breakless-lists>=1.0.1 +setproctitle>=1.3.5 diff --git a/WebHostLib/static/assets/faq/en.md b/WebHostLib/static/assets/faq/en.md index e64535b42d..96e526612b 100644 --- a/WebHostLib/static/assets/faq/en.md +++ b/WebHostLib/static/assets/faq/en.md @@ -22,7 +22,7 @@ players to rely upon each other to complete their game. While a multiworld game traditionally requires all players to be playing the same game, a multi-game multiworld allows players to randomize any of the supported games, and send items between them. This allows players of different -games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworld. +games to interact with one another in a single multiplayer environment. Archipelago supports multi-game multiworlds. Here is a list of our [Supported Games](https://archipelago.gg/games). ## Can I generate a single-player game with Archipelago? diff --git a/WebHostLib/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js index b8c56905a5..797c9f6448 100644 --- a/WebHostLib/static/assets/gameInfo.js +++ b/WebHostLib/static/assets/gameInfo.js @@ -23,7 +23,6 @@ window.addEventListener('load', () => { showdown.setOption('strikethrough', true); showdown.setOption('literalMidWordUnderscores', true); gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results); - adjustHeaderWidth(); // Reset the id of all header divs to something nicer for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) { @@ -42,10 +41,5 @@ window.addEventListener('load', () => { scrollTarget?.scrollIntoView(); } }); - }).catch((error) => { - console.error(error); - gameInfo.innerHTML = - `

This page is out of logic!

-

Click here to return to safety.

`; }); }); diff --git a/WebHostLib/static/assets/hostGame.js b/WebHostLib/static/assets/hostGame.js index db1ab1ddde..01a8da06e5 100644 --- a/WebHostLib/static/assets/hostGame.js +++ b/WebHostLib/static/assets/hostGame.js @@ -6,6 +6,4 @@ window.addEventListener('load', () => { document.getElementById('file-input').addEventListener('change', () => { document.getElementById('host-game-form').submit(); }); - - adjustFooterHeight(); }); diff --git a/WebHostLib/static/assets/styleController.js b/WebHostLib/static/assets/styleController.js deleted file mode 100644 index 924e86ee26..0000000000 --- a/WebHostLib/static/assets/styleController.js +++ /dev/null @@ -1,47 +0,0 @@ -const adjustFooterHeight = () => { - // If there is no footer on this page, do nothing - const footer = document.getElementById('island-footer'); - if (!footer) { return; } - - // If the body is taller than the window, also do nothing - if (document.body.offsetHeight > window.innerHeight) { - footer.style.marginTop = '0'; - return; - } - - // Add a margin-top to the footer to position it at the bottom of the screen - const sibling = footer.previousElementSibling; - const margin = (window.innerHeight - sibling.offsetTop - sibling.offsetHeight - footer.offsetHeight); - if (margin < 1) { - footer.style.marginTop = '0'; - return; - } - footer.style.marginTop = `${margin}px`; -}; - -const adjustHeaderWidth = () => { - // If there is no header, do nothing - const header = document.getElementById('base-header'); - if (!header) { return; } - - const tempDiv = document.createElement('div'); - tempDiv.style.width = '100px'; - tempDiv.style.height = '100px'; - tempDiv.style.overflow = 'scroll'; - tempDiv.style.position = 'absolute'; - tempDiv.style.top = '-500px'; - document.body.appendChild(tempDiv); - const scrollbarWidth = tempDiv.offsetWidth - tempDiv.clientWidth; - document.body.removeChild(tempDiv); - - const documentRoot = document.compatMode === 'BackCompat' ? document.body : document.documentElement; - const margin = (documentRoot.scrollHeight > documentRoot.clientHeight) ? 0-scrollbarWidth : 0; - document.getElementById('base-header-right').style.marginRight = `${margin}px`; -}; - -window.addEventListener('load', () => { - window.addEventListener('resize', adjustFooterHeight); - window.addEventListener('resize', adjustHeaderWidth); - adjustFooterHeight(); - adjustHeaderWidth(); -}); diff --git a/WebHostLib/static/assets/tutorial.js b/WebHostLib/static/assets/tutorial.js index 1db08d85b3..c9022719fb 100644 --- a/WebHostLib/static/assets/tutorial.js +++ b/WebHostLib/static/assets/tutorial.js @@ -25,7 +25,6 @@ window.addEventListener('load', () => { showdown.setOption('literalMidWordUnderscores', true); showdown.setOption('disableForced4SpacesIndentedSublists', true); tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results); - adjustHeaderWidth(); const title = document.querySelector('h1') if (title) { @@ -49,10 +48,5 @@ window.addEventListener('load', () => { scrollTarget?.scrollIntoView(); } }); - }).catch((error) => { - console.error(error); - tutorialWrapper.innerHTML = - `

This page is out of logic!

-

Click here to return to safety.

`; }); }); diff --git a/WebHostLib/static/styles/globalStyles.css b/WebHostLib/static/styles/globalStyles.css index 1a0144830e..adcee6581b 100644 --- a/WebHostLib/static/styles/globalStyles.css +++ b/WebHostLib/static/styles/globalStyles.css @@ -36,6 +36,13 @@ html{ body{ margin: 0; + display: flex; + flex-direction: column; + min-height: calc(100vh - 110px); +} + +main { + flex-grow: 1; } a{ diff --git a/WebHostLib/static/styles/timespinnerTracker.css b/WebHostLib/static/styles/timespinnerTracker.css index 007c6a19ba..640b584684 100644 --- a/WebHostLib/static/styles/timespinnerTracker.css +++ b/WebHostLib/static/styles/timespinnerTracker.css @@ -75,6 +75,27 @@ #inventory-table img.acquired.green{ /*32CD32*/ filter: hue-rotate(84deg) saturate(10) brightness(0.7); } +#inventory-table img.acquired.hotpink{ /*FF69B4*/ + filter: sepia(100%) hue-rotate(300deg) saturate(10); +} +#inventory-table img.acquired.lightsalmon{ /*FFA07A*/ + filter: sepia(100%) hue-rotate(347deg) saturate(10); +} +#inventory-table img.acquired.crimson{ /*DB143B*/ + filter: sepia(100%) hue-rotate(318deg) saturate(10) brightness(0.86); +} + +#inventory-table span{ + color: #B4B4A0; + font-size: 40px; + max-width: 40px; + max-height: 40px; + filter: grayscale(100%) contrast(75%) brightness(30%); +} + +#inventory-table span.acquired{ + filter: none; +} #inventory-table div.image-stack{ display: grid; diff --git a/WebHostLib/templates/404.html b/WebHostLib/templates/404.html index 9d567510ee..6c91fed4ac 100644 --- a/WebHostLib/templates/404.html +++ b/WebHostLib/templates/404.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} Page Not Found (404) @@ -13,5 +14,4 @@ The page you're looking for doesn't exist.
Click here to return to safety. - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/hostGame.html b/WebHostLib/templates/hostGame.html index 2bcb993af5..d7d0a96331 100644 --- a/WebHostLib/templates/hostGame.html +++ b/WebHostLib/templates/hostGame.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Upload Multidata @@ -27,6 +28,4 @@ - - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/landing.html b/WebHostLib/templates/landing.html index b489ef18ac..e7d0569e6c 100644 --- a/WebHostLib/templates/landing.html +++ b/WebHostLib/templates/landing.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Archipelago @@ -57,5 +58,4 @@ - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/pageWrapper.html b/WebHostLib/templates/pageWrapper.html index c7dda523ef..4347b4add3 100644 --- a/WebHostLib/templates/pageWrapper.html +++ b/WebHostLib/templates/pageWrapper.html @@ -5,26 +5,29 @@ - {% block head %} Archipelago {% endblock %} +
+ {% with messages = get_flashed_messages() %} + {% if messages %} +
+ {% for message in messages | unique %} +
{{ message }}
+ {% endfor %} +
+ {% endif %} + {% endwith %} -{% with messages = get_flashed_messages() %} - {% if messages %} -
- {% for message in messages | unique %} -
{{ message }}
- {% endfor %} -
+ {% block body %} + {% endblock %} +
+ + {% if show_footer %} + {% include "islandFooter.html" %} {% endif %} -{% endwith %} - -{% block body %} -{% endblock %} - diff --git a/WebHostLib/templates/playerOptions/macros.html b/WebHostLib/templates/playerOptions/macros.html index 64f0f140de..972f03175d 100644 --- a/WebHostLib/templates/playerOptions/macros.html +++ b/WebHostLib/templates/playerOptions/macros.html @@ -213,7 +213,7 @@ {% endmacro %} {% macro RandomizeButton(option_name, option) %} -
+
- {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/startPlaying.html b/WebHostLib/templates/startPlaying.html index ab2f021d61..9e09474bd0 100644 --- a/WebHostLib/templates/startPlaying.html +++ b/WebHostLib/templates/startPlaying.html @@ -1,4 +1,5 @@ {% extends 'pageWrapper.html' %} +{% set show_footer = True %} {% block head %} Start Playing @@ -26,6 +27,4 @@

- - {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/tracker__Timespinner.html b/WebHostLib/templates/tracker__Timespinner.html index b118c33833..aa8567659c 100644 --- a/WebHostLib/templates/tracker__Timespinner.html +++ b/WebHostLib/templates/tracker__Timespinner.html @@ -99,6 +99,52 @@ {% endif %} + {% if 'PrismBreak' in options or 'LockKeyAmadeus' in options or 'GateKeep' in options %} +
+ {% if 'PrismBreak' in options %} +
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ {% endif %} + {% if 'LockKeyAmadeus' in options %} +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ {% endif %} + {% if 'GateKeep' in options %} +
+ +
+ {% endif %} +
+ {% endif %} diff --git a/WebHostLib/templates/userContent.html b/WebHostLib/templates/userContent.html index 4e3747f4f9..fa60deacd8 100644 --- a/WebHostLib/templates/userContent.html +++ b/WebHostLib/templates/userContent.html @@ -29,7 +29,8 @@

User Content

- Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately. + Below is a list of all the content you have generated on this site. Rooms and seeds are listed separately.
+ Sessions can be saved or synced across devices using the Sessions Page.

Your Rooms

{% if rooms %} diff --git a/WebHostLib/templates/viewSeed.html b/WebHostLib/templates/viewSeed.html index a8478c95c3..70ffe23b7b 100644 --- a/WebHostLib/templates/viewSeed.html +++ b/WebHostLib/templates/viewSeed.html @@ -1,5 +1,6 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} View Seed {{ seed.id|suuid }} @@ -50,5 +51,4 @@
- {% include 'islandFooter.html' %} {% endblock %} diff --git a/WebHostLib/templates/waitSeed.html b/WebHostLib/templates/waitSeed.html index 9041b901b5..f2729353a6 100644 --- a/WebHostLib/templates/waitSeed.html +++ b/WebHostLib/templates/waitSeed.html @@ -1,9 +1,12 @@ {% extends 'pageWrapper.html' %} {% import "macros.html" as macros %} +{% set show_footer = True %} {% block head %} Generation in Progress - + {% endblock %} @@ -15,5 +18,34 @@ Waiting for game to generate, this page auto-refreshes to check. - {% include 'islandFooter.html' %} + {% endblock %} diff --git a/WebHostLib/templates/weightedOptions/weightedOptions.html b/WebHostLib/templates/weightedOptions/weightedOptions.html index b3aefd4835..59dbba127d 100644 --- a/WebHostLib/templates/weightedOptions/weightedOptions.html +++ b/WebHostLib/templates/weightedOptions/weightedOptions.html @@ -100,7 +100,7 @@ {% else %}
- This option is not supported. Please edit your .yaml file manually. + This option cannot be modified here. Please edit your .yaml file manually.
{% endif %} diff --git a/WebHostLib/tracker.py b/WebHostLib/tracker.py index 043764a53b..3748de97a4 100644 --- a/WebHostLib/tracker.py +++ b/WebHostLib/tracker.py @@ -1071,6 +1071,11 @@ if "Timespinner" in network_data_package["games"]: "Plasma Orb": "https://timespinnerwiki.com/mediawiki/images/4/44/Plasma_Orb.png", "Kobo": "https://timespinnerwiki.com/mediawiki/images/c/c6/Familiar_Kobo.png", "Merchant Crow": "https://timespinnerwiki.com/mediawiki/images/4/4e/Familiar_Crow.png", + "Laser Access": "https://timespinnerwiki.com/mediawiki/images/9/99/Historical_Documents.png", + "Lab Glasses": "https://timespinnerwiki.com/mediawiki/images/4/4a/Lab_Glasses.png", + "Eye Orb": "https://timespinnerwiki.com/mediawiki/images/a/a4/Eye_Orb.png", + "Lab Coat": "https://timespinnerwiki.com/mediawiki/images/5/51/Lab_Coat.png", + "Demon": "https://timespinnerwiki.com/mediawiki/images/f/f8/Familiar_Demon.png", } timespinner_location_ids = { @@ -1118,6 +1123,9 @@ if "Timespinner" in network_data_package["games"]: timespinner_location_ids["Ancient Pyramid"] += [ 1337237, 1337238, 1337239, 1337240, 1337241, 1337242, 1337243, 1337244, 1337245] + if (slot_data["PyramidStart"]): + timespinner_location_ids["Ancient Pyramid"] += [ + 1337233, 1337234, 1337235] display_data = {} diff --git a/Zelda1Client.py b/Zelda1Client.py index 1154804fbf..4473b3f3c7 100644 --- a/Zelda1Client.py +++ b/Zelda1Client.py @@ -386,7 +386,7 @@ if __name__ == '__main__': parser.add_argument('diff_file', default="", type=str, nargs="?", help='Path to a Archipelago Binary Patch file') args = parser.parse_args() - colorama.init() + colorama.just_fix_windows_console() asyncio.run(main(args)) colorama.deinit() diff --git a/data/client.kv b/data/client.kv index f0f31769e4..562986cd17 100644 --- a/data/client.kv +++ b/data/client.kv @@ -14,23 +14,60 @@ salmon: "FA8072" # typically trap item white: "FFFFFF" # not used, if you want to change the generic text color change color in Label orange: "FF7700" # Used for command echo -