diff --git a/BaseClasses.py b/BaseClasses.py index 3d0004806c..4db3917985 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1106,6 +1106,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() @@ -1310,9 +1313,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) @@ -1416,6 +1416,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 ae411838d8..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) diff --git a/Fill.py b/Fill.py index d1773c8213..fe39b74fbe 100644 --- a/Fill.py +++ b/Fill.py @@ -348,10 +348,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 +500,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 +516,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..609c109470 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,7 +8,7 @@ Archipelago launcher for bundled app. Scroll down to components= to add components to the launcher as well as setup.py """ - +import os import argparse import itertools import logging @@ -20,10 +20,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 +106,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 +116,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 +124,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 + from kvui import MDButton, MDButtonText + from kivymd.uix.dialog import MDDialog, MDDialogHeadlineText, MDDialogContentContainer, MDDialogSupportingText + from kivymd.uix.divider import MDDivider + if client_component is None: 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 +213,163 @@ 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, MDButton, MDLabel, MDButtonText, ScrollBox, ApAsyncImage) + 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.relativelayout import MDRelativeLayout + 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) + + 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) + + 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 +389,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 +422,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 +438,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 +460,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 2f2c94f68f..bdfaa74625 100644 --- a/LinksAwakeningClient.py +++ b/LinksAwakeningClient.py @@ -139,7 +139,7 @@ class RAGameboy(): 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 @@ -237,7 +237,7 @@ class RAGameboy(): 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 @@ -245,7 +245,7 @@ class RAGameboy(): 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): @@ -514,8 +514,8 @@ class LinksAwakeningContext(CommonContext): magpie_task = None won = False - @property - def slot_storage_key(self): + @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: @@ -529,9 +529,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 = [ @@ -544,21 +542,15 @@ 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_new_entrances(self, entrances: typing.Dict[str, str]): # Store the entrances we find on the server for future sessions message = [{ @@ -597,12 +589,12 @@ class LinksAwakeningContext(CommonContext): logger.info("victory!") 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]}]) + # 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: @@ -638,12 +630,18 @@ 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]) @@ -722,8 +720,10 @@ class LinksAwakeningContext(CommonContext): try: self.magpie.set_checks(self.client.tracker.all_checks) await self.magpie.set_item_tracker(self.client.item_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 diff --git a/Main.py b/Main.py index d0e7a7f879..528db10c64 100644 --- a/Main.py +++ b/Main.py @@ -81,7 +81,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No del item_digits, location_digits, 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 +224,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 f9ed34e2f7..05e93e678d 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -66,9 +66,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(): @@ -109,7 +113,7 @@ modify_functions = { # lists/dicts: "remove": remove_from_list, "pop": pop_from_container, - "update": update_dict, + "update": update_container_unique, } @@ -2037,7 +2041,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/Utils.py b/Utils.py index c7f13f144d..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") diff --git a/WargrooveClient.py b/WargrooveClient.py index f900e05e3f..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): 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/static/assets/gameInfo.js b/WebHostLib/static/assets/gameInfo.js index b8c56905a5..1d6d136135 100644 --- a/WebHostLib/static/assets/gameInfo.js +++ b/WebHostLib/static/assets/gameInfo.js @@ -42,10 +42,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/tutorial.js b/WebHostLib/static/assets/tutorial.js index 1db08d85b3..d527966005 100644 --- a/WebHostLib/static/assets/tutorial.js +++ b/WebHostLib/static/assets/tutorial.js @@ -49,10 +49,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/data/client.kv b/data/client.kv index f0f31769e4..0974258d6c 100644 --- a/data/client.kv +++ b/data/client.kv @@ -14,23 +14,50 @@ 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 -