diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 80aaf70c215e..23c463fb947a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,10 +36,15 @@ jobs: run: | Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force + choco install innosetup --version=6.2.2 --allow-downgrade - name: Build run: | python -m pip install --upgrade pip python setup.py build_exe --yes + if ( $? -eq $false ) { + Write-Error "setup.py failed!" + exit 1 + } $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1] $ZIP_NAME="Archipelago_$NAME.7z" echo "$NAME -> $ZIP_NAME" @@ -49,12 +54,6 @@ jobs: Rename-Item "exe.$NAME" Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name - - name: Store 7z - uses: actions/upload-artifact@v4 - with: - name: ${{ env.ZIP_NAME }} - path: dist/${{ env.ZIP_NAME }} - retention-days: 7 # keep for 7 days, should be enough - name: Build Setup run: | & "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL @@ -65,11 +64,38 @@ jobs: $contents = Get-ChildItem -Path setups/*.exe -Force -Recurse $SETUP_NAME=$contents[0].Name echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV + - name: Check build loads expected worlds + shell: bash + run: | + cd build/exe* + mv Players/Templates/meta.yaml . + ls -1 Players/Templates | sort > setup-player-templates.txt + rm -R Players/Templates + timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true + ls -1 Players/Templates | sort > generated-player-templates.txt + cmp setup-player-templates.txt generated-player-templates.txt \ + || diff setup-player-templates.txt generated-player-templates.txt + mv meta.yaml Players/Templates/ + - name: Test Generate + shell: bash + run: | + cd build/exe* + cp Players/Templates/Clique.yaml Players/ + timeout 30 ./ArchipelagoGenerate + - name: Store 7z + uses: actions/upload-artifact@v4 + with: + name: ${{ env.ZIP_NAME }} + path: dist/${{ env.ZIP_NAME }} + compression-level: 0 # .7z is incompressible by zip + if-no-files-found: error + retention-days: 7 # keep for 7 days, should be enough - name: Store Setup uses: actions/upload-artifact@v4 with: name: ${{ env.SETUP_NAME }} path: setups/${{ env.SETUP_NAME }} + if-no-files-found: error retention-days: 7 # keep for 7 days, should be enough build-ubuntu2004: @@ -110,7 +136,7 @@ jobs: echo -e "setup.py dist output:\n `ls dist`" cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" - (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") + (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME") echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - copy code above to release.yml - @@ -118,15 +144,36 @@ jobs: run: | source venv/bin/activate python setup.py build_exe --yes + - name: Check build loads expected worlds + shell: bash + run: | + cd build/exe* + mv Players/Templates/meta.yaml . + ls -1 Players/Templates | sort > setup-player-templates.txt + rm -R Players/Templates + timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true + ls -1 Players/Templates | sort > generated-player-templates.txt + cmp setup-player-templates.txt generated-player-templates.txt \ + || diff setup-player-templates.txt generated-player-templates.txt + mv meta.yaml Players/Templates/ + - name: Test Generate + shell: bash + run: | + cd build/exe* + cp Players/Templates/Clique.yaml Players/ + timeout 30 ./ArchipelagoGenerate - name: Store AppImage uses: actions/upload-artifact@v4 with: name: ${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }} + if-no-files-found: error retention-days: 7 - name: Store .tar.gz uses: actions/upload-artifact@v4 with: name: ${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }} + compression-level: 0 # .gz is incompressible by zip + if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/ctest.yml b/.github/workflows/ctest.yml new file mode 100644 index 000000000000..9492c83c9e53 --- /dev/null +++ b/.github/workflows/ctest.yml @@ -0,0 +1,54 @@ +# Run CMake / CTest C++ unit tests + +name: ctest + +on: + push: + paths: + - '**.cc?' + - '**.cpp' + - '**.cxx' + - '**.hh?' + - '**.hpp' + - '**.hxx' + - '**.CMakeLists' + - '.github/workflows/ctest.yml' + pull_request: + paths: + - '**.cc?' + - '**.cpp' + - '**.cxx' + - '**.hh?' + - '**.hpp' + - '**.hxx' + - '**.CMakeLists' + - '.github/workflows/ctest.yml' + +jobs: + ctest: + runs-on: ${{ matrix.os }} + name: Test C++ ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + + steps: + - uses: actions/checkout@v4 + - uses: ilammy/msvc-dev-cmd@v1 + if: startsWith(matrix.os,'windows') + - uses: Bacondish2023/setup-googletest@v1 + with: + build-type: 'Release' + - name: Build tests + run: | + cd test/cpp + mkdir build + cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release + cmake --build build/ --config Release + ls + - name: Run tests + run: | + cd test/cpp + ctest --test-dir build/ -C Release --output-on-failure diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2d7f1253b760..3f8651d408e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,7 +69,7 @@ jobs: echo -e "setup.py dist output:\n `ls dist`" cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz" - (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") + (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME") echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - code above copied from build.yml - diff --git a/.gitignore b/.gitignore index 022abe38fe40..5686f43de380 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__/ @@ -177,6 +178,7 @@ dmypy.json cython_debug/ # Cython intermediates +_speedups.c _speedups.cpp _speedups.html diff --git a/AdventureClient.py b/AdventureClient.py index 7bfbd5ef6bd3..206c55df9abd 100644 --- a/AdventureClient.py +++ b/AdventureClient.py @@ -80,7 +80,7 @@ def __init__(self, server_address, password): self.local_item_locations = {} self.dragon_speed_info = {} - options = Utils.get_options() + options = Utils.get_settings() self.display_msgs = options["adventure_options"]["display_msgs"] async def server_auth(self, password_requested: bool = False): @@ -102,7 +102,7 @@ def _set_message(self, msg: str, msg_id: int): def on_package(self, cmd: str, args: dict): if cmd == 'Connected': self.locations_array = None - if Utils.get_options()["adventure_options"].get("death_link", False): + if Utils.get_settings()["adventure_options"].get("death_link", False): self.set_deathlink = True async_start(self.get_freeincarnates_used()) elif cmd == "RoomInfo": @@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext): async def run_game(romfile): - auto_start = Utils.get_options()["adventure_options"].get("rom_start", True) - rom_args = Utils.get_options()["adventure_options"].get("rom_args") + auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True) + rom_args = Utils.get_settings()["adventure_options"].get("rom_args") if auto_start is True: import webbrowser webbrowser.open(romfile) 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/Fill.py b/Fill.py index d8147b2eac80..4967ff073601 100644 --- a/Fill.py +++ b/Fill.py @@ -483,15 +483,15 @@ def mark_for_locking(location: Location): if panic_method == "swap": fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=True, - on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1) + name="Progression", single_player_placement=multiworld.players == 1) elif panic_method == "raise": fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, - on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1) + name="Progression", single_player_placement=multiworld.players == 1) elif panic_method == "start_inventory": fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool, swap=False, allow_partial=True, - on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1) + name="Progression", single_player_placement=multiworld.players == 1) if progitempool: for item in progitempool: logging.debug(f"Moved {item} to start_inventory to prevent fill failure.") diff --git a/Generate.py b/Generate.py index 67988bf8b30d..1fbb9e76a483 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,23 @@ 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): + # __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.") + if not args: - args, options = mystery_argparse() - else: - options = get_settings() + args = mystery_argparse() seed = get_seed(args.seed) + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) random.seed(seed) seed_name = get_seed_name(random) @@ -143,6 +143,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 +237,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 +363,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 +442,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 +475,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/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/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/Options.py b/Options.py index 40a6996d325a..b5fb25ea34a0 100644 --- a/Options.py +++ b/Options.py @@ -53,8 +53,8 @@ def __new__(mcs, name, bases, attrs): attrs["name_lookup"].update({option_id: name for name, option_id in new_options.items()}) options.update(new_options) # apply aliases, without name_lookup - aliases = {name[6:].lower(): option_id for name, option_id in attrs.items() if - name.startswith("alias_")} + aliases = attrs["aliases"] = {name[6:].lower(): option_id for name, option_id in attrs.items() if + name.startswith("alias_")} assert ( name in {"Option", "VerifyKeys"} or # base abstract classes don't need default @@ -126,10 +126,28 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): # can be weighted between selections supports_weighting = True + rich_text_doc: typing.Optional[bool] = None + """Whether the WebHost should render the Option's docstring as rich text. + + If this is True, the Option's docstring is interpreted as reStructuredText_, + the standard Python markup format. In the WebHost, it's rendered to HTML so + that lists, emphasis, and other rich text features are displayed properly. + + If this is False, the docstring is instead interpreted as plain text, and + displayed as-is on the WebHost with whitespace preserved. + + If this is None, it inherits the value of `World.rich_text_options_doc`. For + backwards compatibility, this defaults to False, but worlds are encouraged to + set it to True and use reStructuredText for their Option documentation. + + .. _reStructuredText: https://docutils.sourceforge.io/rst.html + """ + # filled by AssembleOptions: name_lookup: typing.ClassVar[typing.Dict[T, str]] # type: ignore # https://github.com/python/typing/discussions/1460 the reason for this type: ignore options: typing.ClassVar[typing.Dict[str, int]] + aliases: typing.ClassVar[typing.Dict[str, int]] def __repr__(self) -> str: return f"{self.__class__.__name__}({self.current_option_name})" @@ -735,6 +753,12 @@ def __init__(self, value: int) -> None: elif value > self.range_end and value not in self.special_range_names.values(): raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__} " + f"and is also not one of the supported named special values: {self.special_range_names}") + + # See docstring + for key in self.special_range_names: + if key != key.lower(): + raise Exception(f"{self.__class__.__name__} has an invalid special_range_names key: {key}. " + f"NamedRange keys must use only lowercase letters, and ideally should be snake_case.") self.value = value @classmethod @@ -1121,10 +1145,13 @@ def __len__(self) -> int: class Accessibility(Choice): """Set rules for reachability of your items/locations. - Locations: ensure everything can be reached and acquired. - Items: ensure all logically relevant items can be acquired. - Minimal: ensure what is needed to reach your goal can be acquired.""" + + - **Locations:** ensure everything can be reached and acquired. + - **Items:** ensure all logically relevant items can be acquired. + - **Minimal:** ensure what is needed to reach your goal can be acquired. + """ display_name = "Accessibility" + rich_text_doc = True option_locations = 0 option_items = 1 option_minimal = 2 @@ -1133,14 +1160,15 @@ class Accessibility(Choice): class ProgressionBalancing(NamedRange): - """ - A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. + """A system that can move progression earlier, to try and prevent the player from getting stuck and bored early. + A lower setting means more getting stuck. A higher setting means less getting stuck. """ default = 50 range_start = 0 range_end = 99 display_name = "Progression Balancing" + rich_text_doc = True special_range_names = { "disabled": 0, "normal": 50, @@ -1205,29 +1233,36 @@ def as_dict(self, *option_names: str, casing: str = "snake") -> typing.Dict[str, class LocalItems(ItemSet): """Forces these items to be in their native world.""" display_name = "Local Items" + rich_text_doc = True class NonLocalItems(ItemSet): """Forces these items to be outside their native world.""" display_name = "Non-local Items" + rich_text_doc = True class StartInventory(ItemDict): """Start with these items.""" verify_item_name = True display_name = "Start Inventory" + rich_text_doc = True class StartInventoryPool(StartInventory): """Start with these items and don't place them in the world. - The game decides what the replacement items will be.""" + + The game decides what the replacement items will be. + """ verify_item_name = True display_name = "Start Inventory from Pool" + rich_text_doc = True class StartHints(ItemSet): - """Start with these item's locations prefilled into the !hint command.""" + """Start with these item's locations prefilled into the ``!hint`` command.""" display_name = "Start Hints" + rich_text_doc = True class LocationSet(OptionSet): @@ -1236,28 +1271,33 @@ class LocationSet(OptionSet): class StartLocationHints(LocationSet): - """Start with these locations and their item prefilled into the !hint command""" + """Start with these locations and their item prefilled into the ``!hint`` command.""" display_name = "Start Location Hints" + rich_text_doc = True class ExcludeLocations(LocationSet): - """Prevent these locations from having an important item""" + """Prevent these locations from having an important item.""" display_name = "Excluded Locations" + rich_text_doc = True class PriorityLocations(LocationSet): - """Prevent these locations from having an unimportant item""" + """Prevent these locations from having an unimportant item.""" display_name = "Priority Locations" + rich_text_doc = True class DeathLink(Toggle): """When you die, everyone dies. Of course the reverse is true too.""" display_name = "Death Link" + rich_text_doc = True class ItemLinks(OptionList): """Share part of your item pool with other players.""" display_name = "Item Links" + rich_text_doc = True default = [] schema = Schema([ { @@ -1324,6 +1364,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P class Removed(FreeText): """This Option has been Removed.""" + rich_text_doc = True default = "" visibility = Visibility.none @@ -1426,14 +1467,18 @@ def dictify_range(option: Range): return data, notes + def yaml_dump_scalar(scalar) -> str: + # yaml dump may add end of document marker and newlines. + return yaml.dump(scalar).replace("...\n", "").strip() + for game_name, world in AutoWorldRegister.world_types.items(): if not world.hidden or generate_hidden: - grouped_options = get_option_groups(world) + option_groups = get_option_groups(world) with open(local_path("data", "options.yaml")) as f: file_data = f.read() res = Template(file_data).render( - option_groups=grouped_options, - __version__=__version__, game=game_name, yaml_dump=yaml.dump, + option_groups=option_groups, + __version__=__version__, game=game_name, yaml_dump=yaml_dump_scalar, dictify_range=dictify_range, ) 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 ''}" ) diff --git a/WebHost.py b/WebHost.py index afacd6288ec2..08ef3c430795 100644 --- a/WebHost.py +++ b/WebHost.py @@ -58,6 +58,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] worlds[game] = world base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs") + shutil.rmtree(base_target_path, ignore_errors=True) for game, world in worlds.items(): # copy files from world's docs folder to the generated folder target_path = os.path.join(base_target_path, game) 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 diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 53c3a6151b82..33339daa1983 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -3,6 +3,7 @@ import os from textwrap import dedent from typing import Dict, Union +from docutils.core import publish_parts import yaml from flask import redirect, render_template, request, Response @@ -66,6 +67,22 @@ def filter_dedent(text: str) -> str: return dedent(text).strip("\n ") +@app.template_filter("rst_to_html") +def filter_rst_to_html(text: str) -> str: + """Converts reStructuredText (such as a Python docstring) to HTML.""" + if text.startswith(" ") or text.startswith("\t"): + text = dedent(text) + elif "\n" in text: + lines = text.splitlines() + text = lines[0] + "\n" + dedent("\n".join(lines[1:])) + + return publish_parts(text, writer_name='html', settings=None, settings_overrides={ + 'raw_enable': False, + 'file_insertion_enabled': False, + 'output_encoding': 'unicode' + })['body'] + + @app.template_test("ordered") def test_ordered(obj): return isinstance(obj, collections.abc.Sequence) diff --git a/WebHostLib/static/styles/tooltip.css b/WebHostLib/static/styles/tooltip.css index 02992b188bc9..dc9026ce6c3d 100644 --- a/WebHostLib/static/styles/tooltip.css +++ b/WebHostLib/static/styles/tooltip.css @@ -12,12 +12,12 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, */ /* Base styles for the element that has a tooltip */ -[data-tooltip], .tooltip { +[data-tooltip], .tooltip-container { position: relative; } /* Base styles for the entire tooltip */ -[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after { +[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip { position: absolute; visibility: hidden; opacity: 0; @@ -39,14 +39,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, pointer-events: none; } -[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after{ +[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before, +.tooltip-container:hover .tooltip { visibility: visible; opacity: 1; word-break: break-word; } /** Directional arrow styles */ -.tooltip:before, [data-tooltip]:before { +[data-tooltip]:before, .tooltip-container:before { z-index: 10000; border: 6px solid transparent; background: transparent; @@ -54,7 +55,7 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, } /** Content styles */ -.tooltip:after, [data-tooltip]:after { +[data-tooltip]:after, .tooltip { width: 260px; z-index: 10000; padding: 8px; @@ -63,24 +64,26 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, background-color: hsla(0, 0%, 20%, 0.9); color: #fff; content: attr(data-tooltip); - white-space: pre-wrap; font-size: 14px; line-height: 1.2; } -[data-tooltip]:before, [data-tooltip]:after{ +[data-tooltip]:after { + white-space: pre-wrap; +} + +[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip { visibility: hidden; opacity: 0; pointer-events: none; } -[data-tooltip]:before, [data-tooltip]:after, .tooltip:before, .tooltip:after, -.tooltip-top:before, .tooltip-top:after { +[data-tooltip]:before, [data-tooltip]:after, .tooltip-container:before, .tooltip { bottom: 100%; left: 50%; } -[data-tooltip]:before, .tooltip:before, .tooltip-top:before { +[data-tooltip]:before, .tooltip-container:before { margin-left: -6px; margin-bottom: -12px; border-top-color: #000; @@ -88,19 +91,19 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, } /** Horizontally align tooltips on the top and bottom */ -[data-tooltip]:after, .tooltip:after, .tooltip-top:after { +[data-tooltip]:after, .tooltip { margin-left: -80px; } -[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip:hover:before, .tooltip:hover:after, -.tooltip-top:hover:before, .tooltip-top:hover:after { +[data-tooltip]:hover:before, [data-tooltip]:hover:after, .tooltip-container:hover:before, +.tooltip-container:hover .tooltip { -webkit-transform: translateY(-12px); -moz-transform: translateY(-12px); transform: translateY(-12px); } /** Tooltips on the left */ -.tooltip-left:before, .tooltip-left:after { +.tooltip-left:before, [data-tooltip].tooltip-left:after, .tooltip-left .tooltip { right: 100%; bottom: 50%; left: auto; @@ -115,14 +118,14 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, border-left-color: hsla(0, 0%, 20%, 0.9); } -.tooltip-left:hover:before, .tooltip-left:hover:after { +.tooltip-left:hover:before, [data-tooltip].tooltip-left:hover:after, .tooltip-left:hover .tooltip { -webkit-transform: translateX(-12px); -moz-transform: translateX(-12px); transform: translateX(-12px); } /** Tooltips on the bottom */ -.tooltip-bottom:before, .tooltip-bottom:after { +.tooltip-bottom:before, [data-tooltip].tooltip-bottom:after, .tooltip-bottom .tooltip { top: 100%; bottom: auto; left: 50%; @@ -136,14 +139,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, border-bottom-color: hsla(0, 0%, 20%, 0.9); } -.tooltip-bottom:hover:before, .tooltip-bottom:hover:after { +.tooltip-bottom:hover:before, [data-tooltip].tooltip-bottom:hover:after, +.tooltip-bottom:hover .tooltip { -webkit-transform: translateY(12px); -moz-transform: translateY(12px); transform: translateY(12px); } /** Tooltips on the right */ -.tooltip-right:before, .tooltip-right:after { +.tooltip-right:before, [data-tooltip].tooltip-right:after, .tooltip-right .tooltip { bottom: 50%; left: 100%; } @@ -156,7 +160,8 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, border-right-color: hsla(0, 0%, 20%, 0.9); } -.tooltip-right:hover:before, .tooltip-right:hover:after { +.tooltip-right:hover:before, [data-tooltip].tooltip-right:hover:after, +.tooltip-right:hover .tooltip { -webkit-transform: translateX(12px); -moz-transform: translateX(12px); transform: translateX(12px); @@ -168,7 +173,16 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, } /** Center content vertically for tooltips ont he left and right */ -.tooltip-left:after, .tooltip-right:after { +[data-tooltip].tooltip-left:after, [data-tooltip].tooltip-right:after, +.tooltip-left .tooltip, .tooltip-right .tooltip { margin-left: 0; margin-bottom: -16px; } + +.tooltip ul, .tooltip ol { + padding-left: 1rem; +} + +.tooltip :last-child { + margin-bottom: 0; +} diff --git a/WebHostLib/templates/playerOptions/macros.html b/WebHostLib/templates/playerOptions/macros.html index b34ac79a029e..415739b861a1 100644 --- a/WebHostLib/templates/playerOptions/macros.html +++ b/WebHostLib/templates/playerOptions/macros.html @@ -57,9 +57,9 @@ - + @@ -68,7 +72,7 @@ This option allows custom values only. Please enter your desired values below.
- +
@@ -85,14 +89,18 @@ Custom values are also allowed for this option. To create one, enter it into the input box below.
- +
{% 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.default == id else None) }} + {% else %} + {{ RangeRow(option_name, option, option.get_option_name(id), name) }} + {% endif %} {% endif %} {% endfor %} {{ RandomRow(option_name, option) }} diff --git a/_speedups.pyx b/_speedups.pyx index b4167ec5aa1e..4b083c2f9aef 100644 --- a/_speedups.pyx +++ b/_speedups.pyx @@ -1,5 +1,6 @@ #cython: language_level=3 -#distutils: language = c++ +#distutils: language = c +#distutils: depends = intset.h """ Provides faster implementation of some core parts. @@ -13,7 +14,6 @@ from cpython cimport PyObject from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING from cymem.cymem cimport Pool from libc.stdint cimport int64_t, uint32_t -from libcpp.set cimport set as std_set from collections import defaultdict cdef extern from *: @@ -31,6 +31,27 @@ ctypedef int64_t ap_id_t cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array cdef size_t INVALID_SIZE = (-1) # this is all 0xff... adding 1 results in 0, but it's not negative +# configure INTSET for player +cdef extern from *: + """ + #define INTSET_NAME ap_player_set + #define INTSET_TYPE uint32_t // has to match ap_player_t + """ + +# create INTSET for player +cdef extern from "intset.h": + """ + #undef INTSET_NAME + #undef INTSET_TYPE + """ + ctypedef struct ap_player_set: + pass + + ap_player_set* ap_player_set_new(size_t bucket_count) nogil + void ap_player_set_free(ap_player_set* set) nogil + bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil + bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil + cdef struct LocationEntry: # layout is so that @@ -185,7 +206,7 @@ cdef class LocationStore: def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]: cdef ap_id_t item = seeked_item_id cdef ap_player_t receiver - cdef std_set[ap_player_t] receivers + cdef ap_player_set* receivers cdef size_t slot_count = len(slots) if slot_count == 1: # specialized implementation for single slot @@ -197,13 +218,20 @@ cdef class LocationStore: yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags elif slot_count: # generic implementation with lookup in set - for receiver in slots: - receivers.insert(receiver) - with nogil: - for entry in self.entries[:self.entry_count]: - if entry.item == item and receivers.count(entry.receiver): - with gil: - yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags + receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB + if not receivers: + raise MemoryError() + try: + for receiver in slots: + if not ap_player_set_add(receivers, receiver): + raise MemoryError() + with nogil: + for entry in self.entries[:self.entry_count]: + if entry.item == item and ap_player_set_contains(receivers, entry.receiver): + with gil: + yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags + finally: + ap_player_set_free(receivers) def get_for_player(self, slot: int) -> Dict[int, Set[int]]: cdef ap_player_t receiver = slot diff --git a/_speedups.pyxbld b/_speedups.pyxbld index e1fe19b2efc6..974eaed03b6a 100644 --- a/_speedups.pyxbld +++ b/_speedups.pyxbld @@ -1,8 +1,10 @@ -# This file is required to get pyximport to work with C++. -# Switching from std::set to a pure C implementation is still on the table to simplify everything. +# This file is used when doing pyximport +import os def make_ext(modname, pyxfilename): from distutils.extension import Extension return Extension(name=modname, sources=[pyxfilename], - language='c++') + depends=["intset.h"], + include_dirs=[os.getcwd()], + language="c") 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