From 7f1e95c04ce08a905a3b9d1b511ec9fc79037f18 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Thu, 6 Jun 2024 00:02:29 -0700 Subject: [PATCH 01/46] Core: gitignore custom_worlds (#3479) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 022abe38fe40..0bba6f17264b 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ Output Logs/ /installdelete.iss /data/user.kv /datapackage +/custom_worlds # Byte-compiled / optimized / DLL files __pycache__/ From 808f2a8ff0de45ebda9f6bca3b2c0f7b54707dd0 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 6 Jun 2024 19:27:01 +0200 Subject: [PATCH 02/46] Core: update dependencies (#3477) --- requirements.txt | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/requirements.txt b/requirements.txt index d1a7b763f37f..db4f5445036a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,13 +2,13 @@ colorama>=0.4.6 websockets>=12.0 PyYAML>=6.0.1 jellyfish>=1.0.3 -jinja2>=3.1.3 -schema>=0.7.5 +jinja2>=3.1.4 +schema>=0.7.7 kivy>=2.3.0 bsdiff4>=1.2.4 -platformdirs>=4.1.0 -certifi>=2023.11.17 -cython>=3.0.8 +platformdirs>=4.2.2 +certifi>=2024.6.2 +cython>=3.0.10 cymem>=2.0.8 -orjson>=3.9.10 -typing_extensions>=4.7.0 +orjson>=3.10.3 +typing_extensions>=4.12.1 From 6bb1cce43f4f3dbbf489d46d7d3b6a14fd845b30 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Thu, 6 Jun 2024 11:36:14 -0700 Subject: [PATCH 03/46] Core: hot reload components from installed apworld (#3480) * Core: hot reload components from installed apworld * address PR reviews `Launcher` widget members default to `None` so they can be defined in `build` `Launcher._refresh_components` is not wrapped loaded world goes into `world_sources` so we can check if it's already loaded. (`WorldSource` can be ordered now without trying to compare `None` and `float`) (don't load empty directories so we don't detect them as worlds) * clarify that the installation is successful --- Launcher.py | 71 ++++++++++++++++++++++++---------- typings/kivy/uix/boxlayout.pyi | 6 +++ typings/kivy/uix/layout.pyi | 8 +++- typings/schema/__init__.pyi | 17 ++++++++ worlds/LauncherComponents.py | 23 ++++++++++- worlds/__init__.py | 12 ++++-- 6 files changed, 110 insertions(+), 27 deletions(-) create mode 100644 typings/kivy/uix/boxlayout.pyi create mode 100644 typings/schema/__init__.pyi diff --git a/Launcher.py b/Launcher.py index e26e4afc0f05..e4b65be93a68 100644 --- a/Launcher.py +++ b/Launcher.py @@ -19,7 +19,7 @@ import webbrowser from os.path import isfile from shutil import which -from typing import Sequence, Union, Optional +from typing import Callable, Sequence, Union, Optional import Utils import settings @@ -160,6 +160,9 @@ def launch(exe, in_terminal=False): subprocess.Popen(exe) +refresh_components: Optional[Callable[[], None]] = None + + def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget from kivy.core.window import Window @@ -170,11 +173,8 @@ class Launcher(App): base_title: str = "Archipelago Launcher" container: ContainerLayout grid: GridLayout - - _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} + _tool_layout: Optional[ScrollBox] = None + _client_layout: Optional[ScrollBox] = None def __init__(self, ctx=None): self.title = self.base_title @@ -182,18 +182,7 @@ def __init__(self, ctx=None): self.icon = r"data/icon.png" super().__init__() - 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)) - tool_layout = ScrollBox() - tool_layout.layout.orientation = "vertical" - self.grid.add_widget(tool_layout) - client_layout = ScrollBox() - client_layout.layout.orientation = "vertical" - self.grid.add_widget(client_layout) + def _refresh_components(self) -> None: def build_button(component: Component) -> Widget: """ @@ -218,14 +207,47 @@ def build_button(component: Component) -> Widget: return box_layout return button + # clear before repopulating + assert self._tool_layout and self._client_layout, "must call `build` first" + tool_children = reversed(self._tool_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) + + _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} + for (tool, client) in itertools.zip_longest(itertools.chain( - self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): + _tools.items(), _miscs.items(), _adjusters.items() + ), _clients.items()): # column 1 if tool: - tool_layout.layout.add_widget(build_button(tool[1])) + self._tool_layout.layout.add_widget(build_button(tool[1])) # column 2 if client: - client_layout.layout.add_widget(build_button(client[1])) + self._client_layout.layout.add_widget(build_button(client[1])) + + 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() + + global refresh_components + refresh_components = self._refresh_components Window.bind(on_drop_file=self._on_drop_file) @@ -254,10 +276,17 @@ def _stop(self, *largs): Launcher().run() + # avoiding Launcher reference leak + # and don't try to do something with widgets after window closed + global refresh_components + refresh_components = None + def run_component(component: Component, *args): if component.func: component.func(*args) + if refresh_components: + refresh_components() elif component.script_name: subprocess.run([*get_exe(component.script_name), *args]) else: diff --git a/typings/kivy/uix/boxlayout.pyi b/typings/kivy/uix/boxlayout.pyi new file mode 100644 index 000000000000..c63d691debdd --- /dev/null +++ b/typings/kivy/uix/boxlayout.pyi @@ -0,0 +1,6 @@ +from typing import Literal +from .layout import Layout + + +class BoxLayout(Layout): + orientation: Literal['horizontal', 'vertical'] diff --git a/typings/kivy/uix/layout.pyi b/typings/kivy/uix/layout.pyi index 2a418a1d8b50..c27f89086306 100644 --- a/typings/kivy/uix/layout.pyi +++ b/typings/kivy/uix/layout.pyi @@ -1,8 +1,14 @@ -from typing import Any +from typing import Any, Sequence + from .widget import Widget class Layout(Widget): + @property + def children(self) -> Sequence[Widget]: ... + def add_widget(self, widget: Widget) -> None: ... + def remove_widget(self, widget: Widget) -> None: ... + def do_layout(self, *largs: Any, **kwargs: Any) -> None: ... diff --git a/typings/schema/__init__.pyi b/typings/schema/__init__.pyi new file mode 100644 index 000000000000..d993ec22745f --- /dev/null +++ b/typings/schema/__init__.pyi @@ -0,0 +1,17 @@ +from typing import Any, Callable + + +class And: + def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ... + + +class Or: + def __init__(self, *args: object) -> None: ... + + +class Schema: + def __init__(self, __x: object) -> None: ... + + +class Optional(Schema): + ... diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 890b41aafa63..18c1a1661ef0 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -1,3 +1,4 @@ +import bisect import logging import pathlib import weakref @@ -94,9 +95,10 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path apworld_path = pathlib.Path(apworld_src) + module_name = pathlib.Path(apworld_path.name).stem try: import zipfile - zipfile.ZipFile(apworld_path).open(pathlib.Path(apworld_path.name).stem + "/__init__.py") + zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py") except ValueError as e: raise Exception("Archive appears invalid or damaged.") from e except KeyError as e: @@ -107,6 +109,9 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path raise Exception("Custom Worlds directory appears to not be writable.") for world_source in worlds.world_sources: if apworld_path.samefile(world_source.resolved_path): + # Note that this doesn't check if the same world is already installed. + # It only checks if the user is trying to install the apworld file + # that comes from the installation location (worlds or custom_worlds) raise Exception(f"APWorld is already installed at {world_source.resolved_path}.") # TODO: run generic test suite over the apworld. @@ -116,6 +121,22 @@ def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, path import shutil shutil.copyfile(apworld_path, target) + # If a module with this name is already loaded, then we can't load it now. + # TODO: We need to be able to unload a world module, + # so the user can update a world without restarting the application. + found_already_loaded = False + for loaded_world in worlds.world_sources: + loaded_name = pathlib.Path(loaded_world.path).stem + if module_name == loaded_name: + found_already_loaded = True + break + if found_already_loaded: + raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n" + "so a Launcher restart is required to use the new installation.") + world_source = worlds.WorldSource(str(target), is_zip=True) + bisect.insort(worlds.world_sources, world_source) + world_source.load() + return apworld_path, target diff --git a/worlds/__init__.py b/worlds/__init__.py index 4da9d8e87c9e..83ee96131aa2 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,11 +1,12 @@ import importlib +import logging import os import sys import warnings import zipimport import time import dataclasses -from typing import Dict, List, TypedDict, Optional +from typing import Dict, List, TypedDict from Utils import local_path, user_path @@ -48,7 +49,7 @@ class WorldSource: path: str # typically relative path from this module is_zip: bool = False relative: bool = True # relative to regular world import folder - time_taken: Optional[float] = None + time_taken: float = -1.0 def __repr__(self) -> str: return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})" @@ -92,7 +93,6 @@ def load(self) -> bool: print(f"Could not load world {self}:", file=file_like) traceback.print_exc(file=file_like) file_like.seek(0) - import logging logging.exception(file_like.read()) failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0]) return False @@ -107,7 +107,11 @@ def load(self) -> bool: if not entry.name.startswith(("_", ".")): file_name = entry.name if relative else os.path.join(folder, entry.name) if entry.is_dir(): - world_sources.append(WorldSource(file_name, relative=relative)) + init_file_path = os.path.join(entry.path, '__init__.py') + if os.path.isfile(init_file_path): + world_sources.append(WorldSource(file_name, relative=relative)) + else: + logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py") elif entry.is_file() and entry.name.endswith(".apworld"): world_sources.append(WorldSource(file_name, is_zip=True, relative=relative)) From 31419c84a4bad41b8d6ea3bd109b206e8eb4f43d Mon Sep 17 00:00:00 2001 From: Scipio Wright Date: Thu, 6 Jun 2024 16:56:35 -0400 Subject: [PATCH 04/46] TUNIC: Remove rule for west Quarry bomb wall (#3481) * Update west quarry bomb wall rule * Update west quarry bomb wall rule --- worlds/tunic/er_rules.py | 2 -- worlds/tunic/rules.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/worlds/tunic/er_rules.py b/worlds/tunic/er_rules.py index 08eb73a3b010..bbee212f5d5a 100644 --- a/worlds/tunic/er_rules.py +++ b/worlds/tunic/er_rules.py @@ -1462,8 +1462,6 @@ def set_er_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) # Quarry set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), lambda state: state.has(laurels, player)) - set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player), - lambda state: has_mask(state, player, options)) # Ziggurat set_rule(multiworld.get_location("Rooted Ziggurat Upper - Near Bridge Switch", player), diff --git a/worlds/tunic/rules.py b/worlds/tunic/rules.py index 0b65c8158e10..e0a2c305101b 100644 --- a/worlds/tunic/rules.py +++ b/worlds/tunic/rules.py @@ -304,8 +304,6 @@ def set_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) -> # Quarry set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player), lambda state: state.has(laurels, player)) - set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player), - lambda state: has_mask(state, player, options)) # nmg - kill boss scav with orb + firecracker, or similar set_rule(multiworld.get_location("Rooted Ziggurat Lower - Hexagon Blue", player), lambda state: has_sword(state, player) or (state.has(grapple, player) and options.logic_rules)) From 223f2f55230e06b93acd63cbfeed739b545a44b1 Mon Sep 17 00:00:00 2001 From: chandler05 <66492208+chandler05@users.noreply.github.com> Date: Thu, 6 Jun 2024 15:57:50 -0500 Subject: [PATCH 05/46] A Short Hike: Update installation instructions (#3474) * A Short Hike: Update installation instructions * Update setup_en.md * Update setup_en.md * Change link --- worlds/shorthike/docs/setup_en.md | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/worlds/shorthike/docs/setup_en.md b/worlds/shorthike/docs/setup_en.md index 85d5a8f5eb16..96e4d8dbbd1d 100644 --- a/worlds/shorthike/docs/setup_en.md +++ b/worlds/shorthike/docs/setup_en.md @@ -4,7 +4,6 @@ - A Short Hike: [Steam](https://store.steampowered.com/app/1055540/A_Short_Hike/) - The Epic Games Store or itch.io version of A Short Hike will also work. -- A Short Hike Modding Tools: [GitHub](https://github.com/BrandenEK/AShortHike.ModdingTools) - A Short Hike Randomizer: [GitHub](https://github.com/BrandenEK/AShortHike.Randomizer) ## Optional Software @@ -14,18 +13,13 @@ ## Installation -1. Open the [Modding Tools GitHub page](https://github.com/BrandenEK/AShortHike.ModdingTools/), and follow -the installation instructions. After this step, your `A Short Hike/` folder should have an empty `Modding/` subfolder. - -2. After the Modding Tools have been installed, download the -[Randomizer](https://github.com/BrandenEK/AShortHike.Randomizer/releases) zip, extract it, and move the contents -of the `Randomizer/` folder into your `Modding/` folder. After this step, your `Modding/` folder should have - `data/` and `plugins/` subfolders. +Open the [Randomizer Repository](https://github.com/BrandenEK/AShortHike.Randomizer) and follow +the installation instructions listed there. ## Connecting A Short Hike will prompt you with the server details when a new game is started or a previous one is continued. -Enter in the Server Port, Name, and Password (optional) in the popup menu that appears and hit connect. +Enter in the Server Address and Port, Name, and Password (optional) in the popup menu that appears and hit connect. ## Tracking From d72afe71004420bcedde9c34a2d869a74314fe2f Mon Sep 17 00:00:00 2001 From: Silent <110704408+silent-destroyer@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:45:22 -0400 Subject: [PATCH 06/46] Update setup_en.md (#3483) --- worlds/tunic/docs/setup_en.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/worlds/tunic/docs/setup_en.md b/worlds/tunic/docs/setup_en.md index 58cc1bcf25f2..f60506795af1 100644 --- a/worlds/tunic/docs/setup_en.md +++ b/worlds/tunic/docs/setup_en.md @@ -3,11 +3,12 @@ ## Required Software - [TUNIC](https://tunicgame.com/) for PC (Steam Deck also supported) -- [BepInEx (Unity IL2CPP)](https://github.com/BepInEx/BepInEx/releases/tag/v6.0.0-pre.1) - [TUNIC Randomizer Mod](https://github.com/silent-destroyer/tunic-randomizer/releases/latest) +- [BepInEx 6.0.0-pre.1 (Unity IL2CPP x64)](https://github.com/BepInEx/BepInEx/releases/tag/v6.0.0-pre.1) ## Optional Software -- [TUNIC Randomizer Map Tracker](https://github.com/SapphireSapphic/TunicTracker/releases/latest) (For use with EmoTracker/PopTracker) +- [TUNIC Randomizer Map Tracker](https://github.com/SapphireSapphic/TunicTracker/releases/latest) + - Requires [PopTracker](https://github.com/black-sliver/PopTracker/releases) - [TUNIC Randomizer Item Auto-tracker](https://github.com/radicoon/tunic-rando-tracker/releases/latest) - [Archipelago Text Client](https://github.com/ArchipelagoMW/Archipelago/releases/latest) @@ -27,7 +28,7 @@ Find your TUNIC game installation directory: BepInEx is a general purpose framework for modding Unity games, and is used to run the TUNIC Randomizer. -Download [BepInEx](https://github.com/BepInEx/BepInEx/releases/download/v6.0.0-pre.1/BepInEx_UnityIL2CPP_x64_6.0.0-pre.1.zip). +Download [BepInEx 6.0.0-pre.1 (Unity IL2CPP x64)](https://github.com/BepInEx/BepInEx/releases/tag/v6.0.0-pre.1). If playing on Steam Deck, follow this [guide to set up BepInEx via Proton](https://docs.bepinex.dev/articles/advanced/proton_wine.html). From 8c614865bb5094b6f2b520a12adaf7be61ae7a30 Mon Sep 17 00:00:00 2001 From: Trevor L <80716066+TRPG0@users.noreply.github.com> Date: Fri, 7 Jun 2024 11:11:35 -0600 Subject: [PATCH 07/46] Bomb Rush Cyberfunk: Fix missing location (#3475) --- worlds/bomb_rush_cyberfunk/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worlds/bomb_rush_cyberfunk/__init__.py b/worlds/bomb_rush_cyberfunk/__init__.py index 2d078ae3bda9..98926e335138 100644 --- a/worlds/bomb_rush_cyberfunk/__init__.py +++ b/worlds/bomb_rush_cyberfunk/__init__.py @@ -109,7 +109,7 @@ def generate_early(self): def create_items(self): rep_locations: int = 87 if self.options.skip_polo_photos: - rep_locations -= 18 + rep_locations -= 17 self.options.total_rep.round_to_nearest_step() rep_counts = self.options.total_rep.get_rep_item_counts(self.random, rep_locations) @@ -157,7 +157,7 @@ def create_regions(self): self.get_region(n).add_exits(region_exits[n]) for index, loc in enumerate(location_table): - if self.options.skip_polo_photos and "Polo" in loc["name"]: + if self.options.skip_polo_photos and "Polo" in loc["game_id"]: continue stage: Region = self.get_region(loc["stage"]) stage.add_locations({loc["name"]: base_id + index}) From b053fee3e5d552fe2fbce8d4c879e3f9737dbc52 Mon Sep 17 00:00:00 2001 From: qwint Date: Fri, 7 Jun 2024 12:12:10 -0500 Subject: [PATCH 08/46] HK: adds schema to validate plando charm costs (#3471) --- worlds/hk/Options.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/worlds/hk/Options.py b/worlds/hk/Options.py index 0ad1acff5df3..38be2cd794a1 100644 --- a/worlds/hk/Options.py +++ b/worlds/hk/Options.py @@ -2,6 +2,7 @@ import re from .ExtractedData import logic_options, starts, pool_options from .Rules import cost_terms +from schema import And, Schema, Optional from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink from .Charms import vanilla_costs, names as charm_names @@ -296,6 +297,9 @@ class PlandoCharmCosts(OptionDict): This is set after any random Charm Notch costs, if applicable.""" display_name = "Charm Notch Cost Plando" valid_keys = frozenset(charm_names) + schema = Schema({ + Optional(name): And(int, lambda n: 6 >= n >= 0) for name in charm_names + }) def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]: for name, cost in self.value.items(): From b3a2473853645931021c8d98a53419bd50424f1b Mon Sep 17 00:00:00 2001 From: Exempt-Medic <60412657+Exempt-Medic@users.noreply.github.com> Date: Fri, 7 Jun 2024 23:47:02 -0400 Subject: [PATCH 09/46] Docs: Fixing subject-verb agreement (#3491) --- WebHostLib/templates/weightedOptions/macros.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index a6e4545fdaf7..2682f9e8bc9c 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -34,7 +34,7 @@ Normal range: {{ option.range_start }} - {{ option.range_end }} {% if option.special_range_names %}

- The following values has special meaning, and may fall outside the normal range. + The following values have special meanings, and may fall outside the normal range.
    {% for name, value in option.special_range_names.items() %}
  • {{ value }}: {{ name }}
  • From 39deef5d09cc8c9bf1060e47c2843fa1d998bc44 Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Sat, 8 Jun 2024 04:54:14 -0400 Subject: [PATCH 10/46] Fix Choice and TextChoice options crashing WebHost if the option's default value is "random" (#3458) --- WebHostLib/templates/weightedOptions/macros.html | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/WebHostLib/templates/weightedOptions/macros.html b/WebHostLib/templates/weightedOptions/macros.html index 2682f9e8bc9c..55a56e32851d 100644 --- a/WebHostLib/templates/weightedOptions/macros.html +++ b/WebHostLib/templates/weightedOptions/macros.html @@ -18,7 +18,11 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name|lower else None) }} + {% if option.default != 'random' %} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} + {% else %} + {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {% endif %} {% endif %} {% endfor %} {{ RandomRow(option_name, option) }} @@ -92,7 +96,11 @@ {% for id, name in option.name_lookup.items() %} {% if name != 'random' %} - {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} + {% if option.default != 'random' %} + {{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.get_option_name(option.default)|lower == name else None) }} + {% else %} + {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {% endif %} {% endif %} {% endfor %} {{ RandomRow(option_name, option) }} From 89d584e47442e9c3f71ded587715e21202993875 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 8 Jun 2024 11:07:14 +0200 Subject: [PATCH 11/46] WebHost: allow getting checksum-specific datapackage via /api/datapackage/ (#3451) * WebHost: allow getting checksum-specific datapackage via /api/datapackage/ * match import style of /api/generate --- WebHostLib/api/__init__.py | 20 +------------------- WebHostLib/api/datapackage.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 WebHostLib/api/datapackage.py diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index 22d1f19f6bdf..4003243a281d 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -5,7 +5,6 @@ from flask import Blueprint, abort, url_for import worlds.Files -from .. import cache from ..models import Room, Seed api_endpoints = Blueprint('api', __name__, url_prefix="/api") @@ -49,21 +48,4 @@ def supports_apdeltapatch(game: str): } -@api_endpoints.route('/datapackage') -@cache.cached() -def get_datapackage(): - from worlds import network_data_package - return network_data_package - - -@api_endpoints.route('/datapackage_checksum') -@cache.cached() -def get_datapackage_checksums(): - from worlds import network_data_package - version_package = { - game: game_data["checksum"] for game, game_data in network_data_package["games"].items() - } - return version_package - - -from . import generate, user # trigger registration +from . import generate, user, datapackage # trigger registration diff --git a/WebHostLib/api/datapackage.py b/WebHostLib/api/datapackage.py new file mode 100644 index 000000000000..3fb472d95dfd --- /dev/null +++ b/WebHostLib/api/datapackage.py @@ -0,0 +1,32 @@ +from flask import abort + +from Utils import restricted_loads +from WebHostLib import cache +from WebHostLib.models import GameDataPackage +from . import api_endpoints + + +@api_endpoints.route('/datapackage') +@cache.cached() +def get_datapackage(): + from worlds import network_data_package + return network_data_package + + +@api_endpoints.route('/datapackage/') +@cache.memoize(timeout=3600) +def get_datapackage_by_checksum(checksum: str): + package = GameDataPackage.get(checksum=checksum) + if package: + return restricted_loads(package.data) + return abort(404) + + +@api_endpoints.route('/datapackage_checksum') +@cache.cached() +def get_datapackage_checksums(): + from worlds import network_data_package + version_package = { + game: game_data["checksum"] for game, game_data in network_data_package["games"].items() + } + return version_package From a0653cdfe0d2524856f6afc5620ed331dcb328ed Mon Sep 17 00:00:00 2001 From: qwint Date: Sat, 8 Jun 2024 10:31:27 -0500 Subject: [PATCH 12/46] HK: adds split movement items to skills item group (#3462) --- worlds/hk/Items.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/hk/Items.py b/worlds/hk/Items.py index 0d4ab3d55f1e..8515465826a5 100644 --- a/worlds/hk/Items.py +++ b/worlds/hk/Items.py @@ -64,3 +64,4 @@ class HKItemData(NamedTuple): }) item_name_groups['Horizontal'] = item_name_groups['Cloak'] | item_name_groups['CDash'] item_name_groups['Vertical'] = item_name_groups['Claw'] | {'Monarch_Wings'} +item_name_groups['Skills'] |= item_name_groups['Vertical'] | item_name_groups['Horizontal'] From 302017c69e80b20c9471b6c8e5882e49671b2370 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Sat, 8 Jun 2024 17:51:09 +0200 Subject: [PATCH 13/46] Test: hosting: handle writes during start_room (#3492) Note: maybe we'd also want to add such handling to WebHost itself, but this is out of scope for getting hosting test to work. --- test/hosting/webhost.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py index e1e31ae466c4..4db605e8c1ea 100644 --- a/test/hosting/webhost.py +++ b/test/hosting/webhost.py @@ -66,12 +66,19 @@ def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str: from time import sleep + import pony.orm + poll_interval = .2 print(f"Starting room {room_id}") no_timeout = timeout <= 0 while no_timeout or timeout > 0: - response = app_client.get(f"/room/{room_id}") + try: + response = app_client.get(f"/room/{room_id}") + except pony.orm.core.OptimisticCheckError: + # hoster wrote to room during our transaction + continue + assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}" match = re.search(r"/connect ([\w:.\-]+)", response.text) if match: From 0d9fce29c69caf0eb8e4f4eab1f1961a550c6d0c Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 8 Jun 2024 19:58:58 +0200 Subject: [PATCH 14/46] Core: load frozen decompressed worlds (#3488) --- worlds/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/worlds/__init__.py b/worlds/__init__.py index 83ee96131aa2..a0859290f90d 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -107,8 +107,9 @@ def load(self) -> bool: if not entry.name.startswith(("_", ".")): file_name = entry.name if relative else os.path.join(folder, entry.name) if entry.is_dir(): - init_file_path = os.path.join(entry.path, '__init__.py') - if os.path.isfile(init_file_path): + if os.path.isfile(os.path.join(entry.path, '__init__.py')): + world_sources.append(WorldSource(file_name, relative=relative)) + elif os.path.isfile(os.path.join(entry.path, '__init__.pyc')): world_sources.append(WorldSource(file_name, relative=relative)) else: logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py") From 76804d295b7c33efb4671938c042a1d1e3b770e6 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sat, 8 Jun 2024 20:04:17 +0200 Subject: [PATCH 15/46] Core: explicitly import importlib.util (#3224) --- worlds/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/worlds/__init__.py b/worlds/__init__.py index a0859290f90d..8d784a5ba438 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -1,4 +1,5 @@ import importlib +import importlib.util import logging import os import sys From c478e55d7a56f2485e903a38f1919df45c4a379e Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 9 Jun 2024 03:13:27 +0200 Subject: [PATCH 16/46] Generate: improve logging capture (#3484) --- Generate.py | 38 +++++++++++++++++++++++--------------- Utils.py | 1 + 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/Generate.py b/Generate.py index 67988bf8b30d..0cef081120e6 100644 --- a/Generate.py +++ b/Generate.py @@ -1,10 +1,12 @@ from __future__ import annotations import argparse +import copy import logging import os import random import string +import sys import urllib.parse import urllib.request from collections import Counter @@ -15,21 +17,16 @@ ModuleUpdate.update() -import copy import Utils import Options from BaseClasses import seeddigits, get_seed, PlandoOptions -from Main import main as ERmain -from settings import get_settings from Utils import parse_yamls, version_tuple, __version__, tuplize_version -from worlds.alttp.EntranceRandomizer import parse_arguments -from worlds.AutoWorld import AutoWorldRegister -from worlds import failed_world_loads def mystery_argparse(): - options = get_settings() - defaults = options.generator + from settings import get_settings + settings = get_settings() + defaults = settings.generator parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") parser.add_argument('--weights_file_path', default=defaults.weights_file_path, @@ -41,7 +38,7 @@ def mystery_argparse(): parser.add_argument('--seed', help='Define seed number to generate.', type=int) parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) parser.add_argument('--spoiler', type=int, default=defaults.spoiler) - parser.add_argument('--outputpath', default=options.general_options.output_path, + parser.add_argument('--outputpath', default=settings.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd parser.add_argument('--race', action='store_true', default=defaults.race) parser.add_argument('--meta_file_path', default=defaults.meta_file_path) @@ -61,20 +58,21 @@ def mystery_argparse(): 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, options + return args def get_seed_name(random_source) -> str: return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits) -def main(args=None, callback=ERmain): +def main(args=None): if not args: - args, options = mystery_argparse() - else: - options = get_settings() + args = mystery_argparse() seed = get_seed(args.seed) + # __name__ == "__main__" check so unittests that already imported worlds don't trip this. + if __name__ == "__main__" and "worlds" in sys.modules: + raise Exception("Worlds system should not be loaded before logging init.") Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) random.seed(seed) seed_name = get_seed_name(random) @@ -143,6 +141,9 @@ def main(args=None, callback=ERmain): raise Exception(f"No weights found. " f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"A mix is also permitted.") + + from worlds.AutoWorld import AutoWorldRegister + from worlds.alttp.EntranceRandomizer import parse_arguments erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.plando_options = args.plando @@ -234,7 +235,8 @@ def main(args=None, callback=ERmain): with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f: yaml.dump(important, f) - return callback(erargs, seed) + from Main import main as ERmain + return ERmain(erargs, seed) def read_weights_yamls(path) -> Tuple[Any, ...]: @@ -359,6 +361,8 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any: + from worlds import AutoWorldRegister + if not game: return get_choice(option_key, category_dict) if game in AutoWorldRegister.world_types: @@ -436,10 +440,13 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, except Exception as e: raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e else: + from worlds import AutoWorldRegister player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses): + from worlds import AutoWorldRegister + if "linked_options" in weights: weights = roll_linked_options(weights) @@ -466,6 +473,7 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b ret.game = get_choice("game", weights) if ret.game not in AutoWorldRegister.world_types: + from worlds import failed_world_loads picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0] if picks[0] in failed_world_loads: raise Exception(f"No functional world found to handle game {ret.game}. " diff --git a/Utils.py b/Utils.py index a7fd7f4f334c..f89330cf7c65 100644 --- a/Utils.py +++ b/Utils.py @@ -553,6 +553,7 @@ def _cleanup(): f"Archipelago ({__version__}) logging initialized" f" on {platform.platform()}" f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + f"{' (frozen)' if is_frozen() else ''}" ) From 2198a70251bae82114c9eb4691a04800ca4a610f Mon Sep 17 00:00:00 2001 From: Phaneros <31861583+MatthewMarinets@users.noreply.github.com> Date: Sat, 8 Jun 2024 19:08:47 -0700 Subject: [PATCH 17/46] Core: CommonClient: command history and echo (#3236) * client: Added command history access with up/down and command echo in common client * client: Changed command echo colour to orange * client: removed star import from typing * client: updated code style to match style guideline * client: adjusted ordering of calling parent constructor in command prompt input constructor * client: Fixed issues identified by beauxq in PR; fixed some typing issues * client: PR comments; replaced command history list with deque --- CommonClient.py | 5 ++++ NetUtils.py | 3 ++- data/client.kv | 1 + kvui.py | 62 +++++++++++++++++++++++++++++++++++++++++--- worlds/sc2/Client.py | 1 - 5 files changed, 67 insertions(+), 5 deletions(-) diff --git a/CommonClient.py b/CommonClient.py index 8af822cba571..8f1e64c0591b 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -493,6 +493,11 @@ def on_user_say(self, text: str) -> typing.Optional[str]: """Gets called before sending a Say to the server from the user. Returned text is sent, or sending is aborted if None is returned.""" return text + + def on_ui_command(self, text: str) -> None: + """Gets called by kivy when the user executes a command starting with `/` or `!`. + The command processor is still called; this is just intended for command echoing.""" + self.ui.print_json([{"text": text, "type": "color", "color": "orange"}]) def update_permissions(self, permissions: typing.Dict[str, int]): for permission_name, permission_flag in permissions.items(): diff --git a/NetUtils.py b/NetUtils.py index 076fdc3ba44f..f8d698c74fcc 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -198,7 +198,8 @@ class JSONtoTextParser(metaclass=HandlerMeta): "slateblue": "6D8BE8", "plum": "AF99EF", "salmon": "FA8072", - "white": "FFFFFF" + "white": "FFFFFF", + "orange": "FF7700", } def __init__(self, ctx): diff --git a/data/client.kv b/data/client.kv index bf98fa151770..dc8a5c9c9d72 100644 --- a/data/client.kv +++ b/data/client.kv @@ -13,6 +13,7 @@ plum: "AF99EF" # typically progression item 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