diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000000..d4c8702da0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,35 @@ +name: Bug Report +description: File a bug report. +title: "Bug: " +labels: + - bug / fix +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report! If this bug occurred during local generation check your + Archipelago install for a log (probably `C:\ProgramData\Archipelago\logs`) + and upload it with this report, as well as all yaml files used. + - type: textarea + id: what-happened + attributes: + label: What happened? + validations: + required: true + - type: textarea + id: expected-results + attributes: + label: What were the expected results? + validations: + required: true + - type: dropdown + id: version + attributes: + label: Software + description: Where did this bug occur? + options: + - Website + - Local generation + - While playing + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000000..84cee1b7f1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,17 @@ +name: Feature Request +description: Request a feature! +title: "Category: " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + Please replace `Category` in the title with what this feature will be targeting, such as Core generation, + website, documentation, or a game. + Note: this is not for requesting new games to be added. If you would like to request a game, the best place to + ask is about it is in the [discord](https://archipelago.gg/discord). + - type: textarea + id: feature + attributes: + label: What feature would you like to see? \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/task.yaml b/.github/ISSUE_TEMPLATE/task.yaml new file mode 100644 index 0000000000..fb677c684f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/task.yaml @@ -0,0 +1,10 @@ +name: Task +description: Submit a task to be done. If this is not targeting core, it should likely be elsewhere. +title: "Core: " +labels: + - core + - enhancement +body: + - type: textarea + attributes: + label: What task needs to be completed? \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000..c7c6471dd0 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +Please format your title with what portion of the project this pull request is +targeting and what it's changing. + +ex. "MyGame4: implement new game" or "Docs: add new guide for customizing MyGame3" + +## What is this fixing or adding? + + +## How was this tested? + + +## If this makes graphical changes, please attach screenshots. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4138f93f04..be053bdc2d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,11 @@ name: Build on: workflow_dispatch +env: + SNI_VERSION: v0.0.84 + ENEMIZER_VERSION: 7.1 + APPIMAGETOOL_VERSION: 13 + jobs: # build-release-macos: # LF volunteer @@ -17,9 +22,9 @@ jobs: python-version: '3.8' - name: Download run-time dependencies run: | - Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-windows-amd64.zip -OutFile sni.zip + Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip Expand-Archive -Path sni.zip -DestinationPath SNI -Force - Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/7.0.1/win-x64.zip -OutFile enemizer.zip + 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 - name: Build run: | @@ -43,6 +48,7 @@ jobs: build-ubuntu1804: runs-on: ubuntu-18.04 steps: + # - copy code below to release.yml - - uses: actions/checkout@v2 - name: Install base dependencies run: | @@ -56,18 +62,18 @@ jobs: - name: Install build-time dependencies run: | echo "PYTHON=python3.9" >> $GITHUB_ENV - wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage + wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool chmod a+rx appimagetool - name: Download run-time dependencies run: | - wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz + wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz tar xf sni-*.tar.xz rm sni-*.tar.xz mv sni-* SNI - wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z + wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z - name: Build run: | @@ -84,6 +90,7 @@ jobs: (cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME") echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV + # - copy code above to release.yml - - name: Store AppImage uses: actions/upload-artifact@v2 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d7cc3c7439..28adb50026 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,8 +18,8 @@ jobs: python-version: 3.9 - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install flake8 pytest + python -m pip install --upgrade pip wheel + pip install flake8 pytest pytest-subtests if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa82883ff1..23f018caf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,11 @@ on: tags: - '*.*.*' +env: + SNI_VERSION: v0.0.84 + ENEMIZER_VERSION: 7.1 + APPIMAGETOOL_VERSION: 13 + jobs: create-release: runs-on: ubuntu-latest @@ -44,22 +49,23 @@ jobs: - name: Install build-time dependencies run: | echo "PYTHON=python3.9" >> $GITHUB_ENV - wget -nv https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage + wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage chmod a+rx appimagetool-x86_64.AppImage ./appimagetool-x86_64.AppImage --appimage-extract echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool chmod a+rx appimagetool - name: Download run-time dependencies run: | - wget -nv https://github.com/alttpo/sni/releases/download/v0.0.82/sni-v0.0.82-manylinux2014-amd64.tar.xz + wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz tar xf sni-*.tar.xz rm sni-*.tar.xz mv sni-* SNI - wget -nv https://github.com/Ijwu/Enemizer/releases/download/7.0.1/ubuntu.16.04-x64.7z + wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z - name: Build run: | - "${{ env.PYTHON }}" -m pip install --upgrade pip setuptools virtualenv PyGObject # pygobject should probably move to requirements + # pygobject is an optional dependency for kivy that's not in requirements + "${{ env.PYTHON }}" -m pip install --upgrade pip virtualenv PyGObject setuptools "${{ env.PYTHON }}" -m venv venv source venv/bin/activate pip install -r requirements.txt diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 1c8ab10c70..4d0ceaec87 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -32,8 +32,8 @@ jobs: python-version: ${{ matrix.python.version }} - name: Install dependencies run: | - python -m pip install --upgrade pip - pip install flake8 pytest + python -m pip install --upgrade pip wheel + pip install flake8 pytest pytest-subtests python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" - name: Unittests run: | diff --git a/.gitignore b/.gitignore index 58122d64a2..8a7246210f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ README.html .vs/ EnemizerCLI/ /Players/ +/SNI/ /options.yaml /config.yaml /logs/ diff --git a/BaseClasses.py b/BaseClasses.py index cea1d48e6f..df8ac02071 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -955,6 +955,13 @@ class Region: return True return False + def get_connecting_entrance(self, is_main_entrance: typing.Callable[[Entrance], bool]) -> Entrance: + for entrance in self.entrances: + if is_main_entrance(entrance): + return entrance + for entrance in self.entrances: # BFS might be better here, trying DFS for now. + return entrance.parent_region.get_connecting_entrance(is_main_entrance) + def __repr__(self): return self.__str__() @@ -1422,7 +1429,6 @@ class Spoiler(): "f" in self.world.shop_shuffle[player])) outfile.write('Custom Potion Shop: %s\n' % bool_to_text("w" in self.world.shop_shuffle[player])) - outfile.write('Boss shuffle: %s\n' % self.world.boss_shuffle[player]) outfile.write('Enemy health: %s\n' % self.world.enemy_health[player]) outfile.write('Enemy damage: %s\n' % self.world.enemy_damage[player]) outfile.write('Prize shuffle %s\n' % diff --git a/CommonClient.py b/CommonClient.py index f830035425..94d4359dd1 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -5,6 +5,7 @@ import urllib.parse import sys import typing import time +import functools import ModuleUpdate ModuleUpdate.update() @@ -17,7 +18,8 @@ if __name__ == "__main__": Utils.init_logging("TextClient", exception_logger="Client") from MultiServer import CommandProcessor -from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission, NetworkSlot +from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, \ + ClientStatus, Permission, NetworkSlot, RawJSONtoTextParser from Utils import Version, stream_input from worlds import network_data_package, AutoWorldRegister import os @@ -152,8 +154,9 @@ class CommonContext: # locations locations_checked: typing.Set[int] # local state locations_scouted: typing.Set[int] - missing_locations: typing.Set[int] + missing_locations: typing.Set[int] # server state checked_locations: typing.Set[int] # server state + server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations locations_info: typing.Dict[int, NetworkItem] # internals @@ -184,8 +187,9 @@ class CommonContext: self.locations_checked = set() # local state self.locations_scouted = set() self.items_received = [] - self.missing_locations = set() + self.missing_locations = set() # server state self.checked_locations = set() # server state + self.server_locations = set() # all locations the server knows of, missing_location | checked_locations self.locations_info = {} self.input_queue = asyncio.Queue() @@ -202,6 +206,10 @@ class CommonContext: # execution self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") + @functools.cached_property + def raw_text_parser(self) -> RawJSONtoTextParser: + return RawJSONtoTextParser(self) + @property def total_locations(self) -> typing.Optional[int]: """Will return None until connected.""" @@ -345,6 +353,8 @@ class CommonContext: cache_package = Utils.persistent_load().get("datapackage", {}).get("games", {}) needed_updates: typing.Set[str] = set() for game in relevant_games: + if game not in remote_datepackage_versions: + continue remote_version: int = remote_datepackage_versions[game] if remote_version == 0: # custom datapackage for this game @@ -632,6 +642,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): # when /missing is used for the client side view of what is missing. ctx.missing_locations = set(args["missing_locations"]) ctx.checked_locations = set(args["checked_locations"]) + ctx.server_locations = ctx.missing_locations | ctx. checked_locations elif cmd == 'ReceivedItems': start_index = args["index"] diff --git a/FF1Client.py b/FF1Client.py index c280fa3035..5a56d0dd08 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -1,4 +1,5 @@ import asyncio +import copy import json import time from asyncio import StreamReader, StreamWriter @@ -6,7 +7,7 @@ from typing import List import Utils -from CommonClient import CommonContext, server_loop, gui_enabled, console_loop, ClientCommandProcessor, logger, \ +from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ get_base_parser SYSTEM_MESSAGE_ID = 0 @@ -64,7 +65,7 @@ class FF1Context(CommonContext): def _set_message(self, msg: str, msg_id: int): if DISPLAY_MSGS: - self.messages[(time.time(), msg_id)] = msg + self.messages[time.time(), msg_id] = msg def on_package(self, cmd: str, args: dict): if cmd == 'Connected': @@ -73,32 +74,28 @@ class FF1Context(CommonContext): msg = args['text'] if ': !' not in msg: self._set_message(msg, SYSTEM_MESSAGE_ID) - elif cmd == "ReceivedItems": - msg = f"Received {', '.join([self.item_names[item.item] for item in args['items']])}" - self._set_message(msg, SYSTEM_MESSAGE_ID) - elif cmd == 'PrintJSON': - print_type = args['type'] - item = args['item'] - receiving_player_id = args['receiving'] - receiving_player_name = self.player_names[receiving_player_id] - sending_player_id = item.player - sending_player_name = self.player_names[item.player] - if print_type == 'Hint': - msg = f"Hint: Your {self.item_names[item.item]} is at" \ - f" {self.player_names[item.player]}'s {self.location_names[item.location]}" - self._set_message(msg, item.item) - elif print_type == 'ItemSend' and receiving_player_id != self.slot: - if sending_player_id == self.slot: - if receiving_player_id == self.slot: - msg = f"You found your own {self.item_names[item.item]}" - else: - msg = f"You sent {self.item_names[item.item]} to {receiving_player_name}" - else: - if receiving_player_id == sending_player_id: - msg = f"{sending_player_name} found their {self.item_names[item.item]}" - else: - msg = f"{sending_player_name} sent {self.item_names[item.item]} to " \ - f"{receiving_player_name}" + + def on_print_json(self, args: dict): + if self.ui: + self.ui.print_json(copy.deepcopy(args["data"])) + else: + text = self.jsontotextparser(copy.deepcopy(args["data"])) + logger.info(text) + relevant = args.get("type", None) in {"Hint", "ItemSend"} + if relevant: + item = args["item"] + # goes to this world + if self.slot_concerns_self(args["receiving"]): + relevant = True + # found in this world + elif self.slot_concerns_self(item.player): + relevant = True + # not related + else: + relevant = False + if relevant: + item = args["item"] + msg = self.raw_text_parser(copy.deepcopy(args["data"])) self._set_message(msg, item.item) def run_gui(self): diff --git a/Fill.py b/Fill.py index e44c80e720..c62eaabde8 100644 --- a/Fill.py +++ b/Fill.py @@ -136,33 +136,98 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: itempool.extend(unplaced_items) +def remaining_fill(world: MultiWorld, + locations: typing.List[Location], + itempool: typing.List[Item]) -> None: + unplaced_items: typing.List[Item] = [] + placements: typing.List[Location] = [] + swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() + while locations and itempool: + item_to_place = itempool.pop() + spot_to_fill: typing.Optional[Location] = None + + for i, location in enumerate(locations): + if location.item_rule(item_to_place): + # popping by index is faster than removing by content, + spot_to_fill = locations.pop(i) + # skipping a scan for the element + break + + else: + # we filled all reachable spots. + # try swapping this item with previously placed items + + for (i, location) in enumerate(placements): + placed_item = location.item + # Unplaceable items can sometimes be swapped infinitely. Limit the + # number of times we will swap an individual item to prevent this + + if swapped_items[placed_item.player, + placed_item.name] > 1: + continue + + location.item = None + placed_item.location = None + if location.item_rule(item_to_place): + # Add this item to the existing placement, and + # add the old item to the back of the queue + spot_to_fill = placements.pop(i) + + swapped_items[placed_item.player, + placed_item.name] += 1 + + itempool.append(placed_item) + + break + + # Item can't be placed here, restore original item + location.item = placed_item + placed_item.location = location + + if spot_to_fill is None: + # Can't place this item, move on to the next + unplaced_items.append(item_to_place) + continue + + world.push_item(spot_to_fill, item_to_place, False) + placements.append(spot_to_fill) + + if unplaced_items and locations: + # There are leftover unplaceable items and locations that won't accept them + raise FillError(f'No more spots to place {unplaced_items}, locations {locations} are invalid. ' + f'Already placed {len(placements)}: {", ".join(str(place) for place in placements)}') + + itempool.extend(unplaced_items) + + +def fast_fill(world: MultiWorld, + item_pool: typing.List[Item], + fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]: + placing = min(len(item_pool), len(fill_locations)) + for item, location in zip(item_pool, fill_locations): + world.push_item(location, item, False) + return item_pool[placing:], fill_locations[placing:] + + def distribute_items_restrictive(world: MultiWorld) -> None: fill_locations = sorted(world.get_unfilled_locations()) world.random.shuffle(fill_locations) - # get items to distribute itempool = sorted(world.itempool) world.random.shuffle(itempool) progitempool: typing.List[Item] = [] - nonexcludeditempool: typing.List[Item] = [] - localrestitempool: typing.Dict[int, typing.List[Item]] = {player: [] for player in range(1, world.players + 1)} - nonlocalrestitempool: typing.List[Item] = [] - restitempool: typing.List[Item] = [] + usefulitempool: typing.List[Item] = [] + filleritempool: typing.List[Item] = [] for item in itempool: if item.advancement: progitempool.append(item) - elif item.useful: # this only gets nonprogression items which should not appear in excluded locations - nonexcludeditempool.append(item) - elif item.name in world.local_items[item.player].value: - localrestitempool[item.player].append(item) - elif item.name in world.non_local_items[item.player].value: - nonlocalrestitempool.append(item) + elif item.useful: + usefulitempool.append(item) else: - restitempool.append(item) + filleritempool.append(item) - call_all(world, "fill_hook", progitempool, nonexcludeditempool, - localrestitempool, nonlocalrestitempool, restitempool, fill_locations) + call_all(world, "fill_hook", progitempool, usefulitempool, filleritempool, fill_locations) locations: typing.Dict[LocationProgressType, typing.List[Location]] = { loc_type: [] for loc_type in LocationProgressType} @@ -184,50 +249,16 @@ def distribute_items_restrictive(world: MultiWorld) -> None: raise FillError( f'Not enough locations for progress items. There are {len(progitempool)} more items than locations') - if nonexcludeditempool: - world.random.shuffle(defaultlocations) - # needs logical fill to not conflict with local items - fill_restrictive( - world, world.state, defaultlocations, nonexcludeditempool) - if nonexcludeditempool: - raise FillError( - f'Not enough locations for non-excluded items. There are {len(nonexcludeditempool)} more items than locations') + remaining_fill(world, excludedlocations, filleritempool) + if excludedlocations: + raise FillError( + f"Not enough filler items for excluded locations. There are {len(excludedlocations)} more locations than items") - defaultlocations = defaultlocations + excludedlocations - world.random.shuffle(defaultlocations) + restitempool = usefulitempool + filleritempool - if any(localrestitempool.values()): # we need to make sure some fills are limited to certain worlds - local_locations: typing.Dict[int, typing.List[Location]] = {player: [] for player in world.player_ids} - for location in defaultlocations: - local_locations[location.player].append(location) - for player_locations in local_locations.values(): - world.random.shuffle(player_locations) + remaining_fill(world, defaultlocations, restitempool) - for player, items in localrestitempool.items(): # items already shuffled - player_local_locations = local_locations[player] - for item_to_place in items: - if not player_local_locations: - logging.warning(f"Ran out of local locations for player {player}, " - f"cannot place {item_to_place}.") - break - spot_to_fill = player_local_locations.pop() - world.push_item(spot_to_fill, item_to_place, False) - defaultlocations.remove(spot_to_fill) - - for item_to_place in nonlocalrestitempool: - for i, location in enumerate(defaultlocations): - if location.player != item_to_place.player: - world.push_item(defaultlocations.pop(i), item_to_place, False) - break - else: - raise Exception(f"Could not place non_local_item {item_to_place} among {defaultlocations}. " - f"Too many non-local items for too few remaining locations.") - - world.random.shuffle(defaultlocations) - - restitempool, defaultlocations = fast_fill( - world, restitempool, defaultlocations) - unplaced = progitempool + restitempool + unplaced = restitempool unfilled = defaultlocations if unplaced or unfilled: @@ -241,15 +272,6 @@ def distribute_items_restrictive(world: MultiWorld) -> None: logging.info(f'Per-Player counts: {print_data})') -def fast_fill(world: MultiWorld, - item_pool: typing.List[Item], - fill_locations: typing.List[Location]) -> typing.Tuple[typing.List[Item], typing.List[Location]]: - placing = min(len(item_pool), len(fill_locations)) - for item, location in zip(item_pool, fill_locations): - world.push_item(location, item, False) - return item_pool[placing:], fill_locations[placing:] - - def flood_items(world: MultiWorld) -> None: # get items to distribute world.random.shuffle(world.itempool) diff --git a/Generate.py b/Generate.py index 1cad836345..f048e54383 100644 --- a/Generate.py +++ b/Generate.py @@ -23,7 +23,6 @@ from worlds.alttp.EntranceRandomizer import parse_arguments from Main import main as ERmain from BaseClasses import seeddigits, get_seed import Options -from worlds.alttp import Bosses from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister import copy @@ -63,7 +62,7 @@ class PlandoSettings(enum.IntFlag): def __str__(self) -> str: if self.value: - return ", ".join((flag.name for flag in PlandoSettings if self.value & flag.value)) + return ", ".join(flag.name for flag in PlandoSettings if self.value & flag.value) return "Off" @@ -84,11 +83,6 @@ 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('--lttp_rom', default=options["lttp_options"]["rom_file"], - help="Path to the 1.0 JP LttP Baserom.") # absolute, relative to cwd or relative to app path - parser.add_argument('--sm_rom', default=options["sm_options"]["rom_file"], - help="Path to the 1.0 JP SM Baserom.") - parser.add_argument('--enemizercli', default=resolve_path(defaults["enemizer_path"], local_path)) parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_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"]) @@ -183,10 +177,6 @@ def main(args=None, callback=ERmain): Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) - erargs.lttp_rom = args.lttp_rom - erargs.sm_rom = args.sm_rom - erargs.enemizercli = args.enemizercli - settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) for fname, yamls in weights_cache.items()} @@ -346,19 +336,6 @@ def prefer_int(input_data: str) -> Union[str, int]: return input_data -available_boss_names: Set[str] = {boss.lower() for boss in Bosses.boss_table if boss not in - {'Agahnim', 'Agahnim2', 'Ganon'}} -available_boss_locations: Set[str] = {f"{loc.lower()}{f' {level}' if level else ''}" for loc, level in - Bosses.boss_location_table} - -boss_shuffle_options = {None: 'none', - 'none': 'none', - 'basic': 'basic', - 'full': 'full', - 'chaos': 'chaos', - 'singularity': 'singularity' - } - goals = { 'ganon': 'ganon', 'crystals': 'crystals', @@ -465,42 +442,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict: return weights -def get_plando_bosses(boss_shuffle: str, plando_options: Set[str]) -> str: - if boss_shuffle in boss_shuffle_options: - return boss_shuffle_options[boss_shuffle] - elif PlandoSettings.bosses in plando_options: - options = boss_shuffle.lower().split(";") - remainder_shuffle = "none" # vanilla - bosses = [] - for boss in options: - if boss in boss_shuffle_options: - remainder_shuffle = boss_shuffle_options[boss] - elif "-" in boss: - loc, boss_name = boss.split("-") - if boss_name not in available_boss_names: - raise ValueError(f"Unknown Boss name {boss_name}") - if loc not in available_boss_locations: - raise ValueError(f"Unknown Boss Location {loc}") - level = '' - if loc.split(" ")[-1] in {"top", "middle", "bottom"}: - # split off level - loc = loc.split(" ") - level = f" {loc[-1]}" - loc = " ".join(loc[:-1]) - loc = loc.title().replace("Of", "of") - if not Bosses.can_place_boss(boss_name.title(), loc, level): - raise ValueError(f"Cannot place {boss_name} at {loc}{level}") - bosses.append(boss) - elif boss not in available_boss_names: - raise ValueError(f"Unknown Boss name or Boss shuffle option {boss}.") - else: - bosses.append(boss) - return ";".join(bosses + [remainder_shuffle]) - else: - raise Exception(f"Boss Shuffle {boss_shuffle} is unknown and boss plando is turned off.") - - -def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option)): +def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, option: type(Options.Option), plando_options: PlandoSettings): if option_key in game_weights: try: if not option.supports_weighting: @@ -511,10 +453,9 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str, except Exception as e: raise Exception(f"Error generating option {option_key} in {ret.game}") from e else: - if hasattr(player_option, "verify"): - player_option.verify(AutoWorldRegister.world_types[ret.game]) + player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options) else: - setattr(ret, option_key, option(option.default)) + setattr(ret, option_key, option.from_any(option.default)) # call the from_any here to support default "random" def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings.bosses): @@ -558,11 +499,11 @@ def roll_settings(weights: dict, plando_options: PlandoSettings = PlandoSettings if ret.game in AutoWorldRegister.world_types: for option_key, option in world_type.option_definitions.items(): - handle_option(ret, game_weights, option_key, option) + handle_option(ret, game_weights, option_key, option, plando_options) for option_key, option in Options.per_game_common_options.items(): # skip setting this option if already set from common_options, defaulting to root option if not (option_key in Options.common_options and option_key not in game_weights): - handle_option(ret, game_weights, option_key, option) + handle_option(ret, game_weights, option_key, option, plando_options) if PlandoSettings.items in plando_options: ret.plando_items = game_weights.get("plando_items", []) if ret.game == "Minecraft" or ret.game == "Ocarina of Time": @@ -645,8 +586,6 @@ def roll_alttp_settings(ret: argparse.Namespace, weights, plando_options): ret.item_functionality = get_choice_legacy('item_functionality', weights) - boss_shuffle = get_choice_legacy('boss_shuffle', weights) - ret.shufflebosses = get_plando_bosses(boss_shuffle, plando_options) ret.enemy_damage = {None: 'default', 'default': 'default', diff --git a/Launcher.py b/Launcher.py index 53032ea251..8a3d53f866 100644 --- a/Launcher.py +++ b/Launcher.py @@ -10,16 +10,21 @@ Scroll down to components= to add components to the launcher as well as setup.py import argparse -from os.path import isfile -import sys -from typing import Iterable, Sequence, Callable, Union, Optional -import subprocess import itertools -from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox,\ - is_windows, is_macos, is_linux -from shutil import which import shlex +import subprocess +import sys from enum import Enum, auto +from os.path import isfile +from shutil import which +from typing import Iterable, Sequence, Callable, Union, Optional + +if __name__ == "__main__": + import ModuleUpdate + ModuleUpdate.update() + +from Utils import is_frozen, user_path, local_path, init_logging, open_filename, messagebox, \ + is_windows, is_macos, is_linux def open_host_yaml(): @@ -65,6 +70,7 @@ def browse_files(): webbrowser.open(file) +# noinspection PyArgumentList class Type(Enum): TOOL = auto() FUNC = auto() # not a real component diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 3de6e3b13a..469e8920b3 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -83,9 +83,9 @@ def main(): parser.add_argument('--ow_palettes', default='default', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', 'sick']) - parser.add_argument('--link_palettes', default='default', - choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', - 'sick']) + # parser.add_argument('--link_palettes', default='default', + # choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', + # 'sick']) parser.add_argument('--shield_palettes', default='default', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', 'sick']) @@ -752,6 +752,7 @@ class SpriteSelector(): self.window['pady'] = 5 self.spritesPerRow = 32 self.all_sprites = [] + self.invalid_sprites = [] self.sprite_pool = spritePool def open_custom_sprite_dir(_evt): @@ -833,6 +834,13 @@ class SpriteSelector(): self.window.focus() tkinter_center_window(self.window) + if self.invalid_sprites: + invalid = sorted(self.invalid_sprites) + logging.warning(f"The following sprites are invalid: {', '.join(invalid)}") + msg = f"{invalid[0]} " + msg += f"and {len(invalid)-1} more are invalid" if len(invalid) > 1 else "is invalid" + messagebox.showerror("Invalid sprites detected", msg, parent=self.window) + def remove_from_sprite_pool(self, button, spritename): self.callback(("remove", spritename)) self.spritePoolButtons.buttons.remove(button) @@ -897,7 +905,13 @@ class SpriteSelector(): sprites = [] for file in os.listdir(path): - sprites.append((file, Sprite(os.path.join(path, file)))) + if file == '.gitignore': + continue + sprite = Sprite(os.path.join(path, file)) + if sprite.valid: + sprites.append((file, sprite)) + else: + self.invalid_sprites.append(file) sprites.sort(key=lambda s: str.lower(s[1].name or "").strip()) diff --git a/Main.py b/Main.py index 48095e06bd..bbd0c805df 100644 --- a/Main.py +++ b/Main.py @@ -12,7 +12,7 @@ from typing import Dict, Tuple, Optional, Set from BaseClasses import MultiWorld, CollectionState, Region, RegionType, LocationProgressType, Location from worlds.alttp.Items import item_name_groups -from worlds.alttp.Regions import lookup_vanilla_location_to_entrance +from worlds.alttp.Regions import is_main_entrance from Fill import distribute_items_restrictive, flood_items, balance_multiworld_progression, distribute_planned from worlds.alttp.Shops import SHOP_ID_START, total_shop_slots, FillDisabledShopSlots from Utils import output_path, get_options, __version__, version_tuple @@ -70,7 +70,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.required_medallions = args.required_medallions.copy() world.game = args.game.copy() world.player_name = args.name.copy() - world.enemizer = args.enemizercli world.sprite = args.sprite.copy() world.glitch_triforce = args.glitch_triforce # This is enabled/disabled globally, no per player option. @@ -250,24 +249,9 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No output_file_futures.append( pool.submit(AutoWorld.call_single, world, "generate_output", player, temp_dir)) - def get_entrance_to_region(region: Region): - for entrance in region.entrances: - if entrance.parent_region.type in (RegionType.DarkWorld, RegionType.LightWorld, RegionType.Generic): - return entrance - for entrance in region.entrances: # BFS might be better here, trying DFS for now. - return get_entrance_to_region(entrance.parent_region) - # collect ER hint info - er_hint_data = {player: {} for player in world.get_game_players("A Link to the Past") if - world.shuffle[player] != "vanilla" or world.retro_caves[player]} - - for region in world.regions: - if region.player in er_hint_data and region.locations: - main_entrance = get_entrance_to_region(region) - for location in region.locations: - if type(location.address) == int: # skips events and crystals - if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name: - er_hint_data[region.player][location.address] = main_entrance.name + er_hint_data: Dict[int, Dict[int, str]] = {} + AutoWorld.call_all(world, 'extend_hint_information', er_hint_data) checks_in_area = {player: {area: list() for area in ordered_areas} for player in range(1, world.players + 1)} @@ -277,22 +261,23 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for location in world.get_filled_locations(): if type(location.address) is int: - main_entrance = get_entrance_to_region(location.parent_region) if location.game != "A Link to the Past": checks_in_area[location.player]["Light World"].append(location.address) - elif location.parent_region.dungeon: - dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', - 'Inverted Ganons Tower': 'Ganons Tower'} \ - .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) - checks_in_area[location.player][dungeonname].append(location.address) - elif location.parent_region.type == RegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif location.parent_region.type == RegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - elif main_entrance.parent_region.type == RegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif main_entrance.parent_region.type == RegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) + else: + main_entrance = location.parent_region.get_connecting_entrance(is_main_entrance) + if location.parent_region.dungeon: + dungeonname = {'Inverted Agahnims Tower': 'Agahnims Tower', + 'Inverted Ganons Tower': 'Ganons Tower'} \ + .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) + checks_in_area[location.player][dungeonname].append(location.address) + elif location.parent_region.type == RegionType.LightWorld: + checks_in_area[location.player]["Light World"].append(location.address) + elif location.parent_region.type == RegionType.DarkWorld: + checks_in_area[location.player]["Dark World"].append(location.address) + elif main_entrance.parent_region.type == RegionType.LightWorld: + checks_in_area[location.player]["Light World"].append(location.address) + elif main_entrance.parent_region.type == RegionType.DarkWorld: + checks_in_area[location.player]["Dark World"].append(location.address) checks_in_area[location.player]["Total"] += 1 oldmancaves = [] @@ -306,7 +291,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No player = region.player location_id = SHOP_ID_START + total_shop_slots + index - main_entrance = get_entrance_to_region(region) + main_entrance = region.get_connecting_entrance(is_main_entrance) if main_entrance.parent_region.type == RegionType.LightWorld: checks_in_area[player]["Light World"].append(location_id) else: @@ -341,7 +326,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for player, world_precollected in world.precollected_items.items()} precollected_hints = {player: set() for player in range(1, world.players + 1 + len(world.groups))} - for slot in world.player_ids: slot_data[slot] = world.worlds[slot].fill_slot_data() diff --git a/MultiServer.py b/MultiServer.py index 8a1844bf92..9f0865d425 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -36,6 +36,7 @@ from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, Networ SlotType min_client_version = Version(0, 1, 6) +print_command_compatability_threshold = Version(0, 3, 5) # Remove backwards compatibility around 0.3.7 colorama.init() # functions callable on storable data on the server by clients @@ -125,6 +126,7 @@ class Context: location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') all_item_and_group_names: typing.Dict[str, typing.Set[str]] forced_auto_forfeits: typing.Dict[str, bool] + non_hintable_names: typing.Dict[str, typing.Set[str]] def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, hint_cost: int, item_cheat: bool, forfeit_mode: str = "disabled", collect_mode="disabled", @@ -195,7 +197,7 @@ class Context: self.item_name_groups = {} self.all_item_and_group_names = {} self.forced_auto_forfeits = collections.defaultdict(lambda: False) - self.non_hintable_names = {} + self.non_hintable_names = collections.defaultdict(frozenset) self._load_game_data() self._init_game_data() @@ -220,11 +222,11 @@ class Context: self.all_item_and_group_names[game_name] = \ set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) - def item_names_for_game(self, game: str) -> typing.Dict[str, int]: - return self.gamespackage[game]["item_name_to_id"] + def item_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: + return self.gamespackage[game]["item_name_to_id"] if game in self.gamespackage else None - def location_names_for_game(self, game: str) -> typing.Dict[str, int]: - return self.gamespackage[game]["location_name_to_id"] + def location_names_for_game(self, game: str) -> typing.Optional[typing.Dict[str, int]]: + return self.gamespackage[game]["location_name_to_id"] if game in self.gamespackage else None # General networking async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bool: @@ -291,20 +293,27 @@ class Context: # text - def notify_all(self, text): + def notify_all(self, text: str): logging.info("Notice (all): %s" % text) - self.broadcast_all([{"cmd": "Print", "text": text}]) + broadcast_text_all(self, text) def notify_client(self, client: Client, text: str): if not client.auth: return logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text)) - asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}])) + if client.version >= print_command_compatability_threshold: + asyncio.create_task(self.send_msgs(client, [{"cmd": "PrintJSON", "data": [{ "text": text }]}])) + else: + asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text}])) def notify_client_multiple(self, client: Client, texts: typing.List[str]): if not client.auth: return - asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts])) + if client.version >= print_command_compatability_threshold: + asyncio.create_task(self.send_msgs(client, + [{"cmd": "PrintJSON", "data": [{ "text": text }]} for text in texts])) + else: + asyncio.create_task(self.send_msgs(client, [{"cmd": "Print", "text": text} for text in texts])) # loading @@ -585,6 +594,7 @@ class Context: forfeit_player(self, client.team, client.slot) elif self.forced_auto_forfeits[self.games[client.slot]]: forfeit_player(self, client.team, client.slot) + self.save() # save goal completion flag def notify_hints(ctx: Context, team: int, hints: typing.List[NetUtils.Hint], only_new: bool = False): @@ -721,20 +731,37 @@ async def on_client_left(ctx: Context, client: Client): ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) -async def countdown(ctx: Context, timer): - ctx.notify_all(f'[Server]: Starting countdown of {timer}s') +async def countdown(ctx: Context, timer: int): + broadcast_countdown(ctx, timer, f"[Server]: Starting countdown of {timer}s") if ctx.countdown_timer: ctx.countdown_timer = timer # timer is already running, set it to a different time else: ctx.countdown_timer = timer while ctx.countdown_timer > 0: - ctx.notify_all(f'[Server]: {ctx.countdown_timer}') + broadcast_countdown(ctx, ctx.countdown_timer, f"[Server]: {ctx.countdown_timer}") ctx.countdown_timer -= 1 await asyncio.sleep(1) - ctx.notify_all(f'[Server]: GO') + broadcast_countdown(ctx, 0, f"[Server]: GO") ctx.countdown_timer = 0 +def broadcast_text_all(ctx: Context, text: str, additional_arguments: dict = {}): + old_clients, new_clients = [], [] + + for teams in ctx.clients.values(): + for clients in teams.values(): + for client in clients: + new_clients.append(client) if client.version >= print_command_compatability_threshold \ + else old_clients.append(client) + + ctx.broadcast(old_clients, [{"cmd": "Print", "text": text }]) + ctx.broadcast(new_clients, [{**{"cmd": "PrintJSON", "data": [{ "text": text }]}, **additional_arguments}]) + + +def broadcast_countdown(ctx: Context, timer: int, message: str): + broadcast_text_all(ctx, message, {"type": "Countdown", "countdown": timer}) + + def get_players_string(ctx: Context): auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth} @@ -874,14 +901,14 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations: typi ctx.save() -def collect_hints(ctx: Context, team: int, slot: int, item_name: str) -> typing.List[NetUtils.Hint]: +def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, str]) -> typing.List[NetUtils.Hint]: hints = [] slots: typing.Set[int] = {slot} for group_id, group in ctx.groups.items(): if slot in group: slots.add(group_id) - seeked_item_id = ctx.item_names_for_game(ctx.games[slot])[item_name] + seeked_item_id = item if isinstance(item, int) else ctx.item_names_for_game(ctx.games[slot])[item] for finding_player, check_data in ctx.locations.items(): for location_id, (item_id, receiving_player, item_flags) in check_data.items(): if receiving_player in slots and item_id == seeked_item_id: @@ -1309,13 +1336,33 @@ class ClientMessageProcessor(CommonCommandProcessor): self.output(f"A hint costs {self.ctx.get_hint_cost(self.client.slot)} points. " f"You have {points_available} points.") return True + + elif input_text.isnumeric(): + game = self.ctx.games[self.client.slot] + hint_id = int(input_text) + hint_name = self.ctx.item_names[hint_id] \ + if not for_location and hint_id in self.ctx.item_names \ + else self.ctx.location_names[hint_id] \ + if for_location and hint_id in self.ctx.location_names \ + else None + if hint_name in self.ctx.non_hintable_names[game]: + self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") + hints = [] + elif not for_location: + hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_id) + else: + hints = collect_hint_location_id(self.ctx, self.client.team, self.client.slot, hint_id) + else: game = self.ctx.games[self.client.slot] + if game not in self.ctx.all_item_and_group_names: + self.output("Can't look up item/location for unknown game. Hint for ID instead.") + return False names = self.ctx.location_names_for_game(game) \ if for_location else \ self.ctx.all_item_and_group_names[game] - hint_name, usable, response = get_intended_text(input_text, - names) + hint_name, usable, response = get_intended_text(input_text, names) + if usable: if hint_name in self.ctx.non_hintable_names[game]: self.output(f"Sorry, \"{hint_name}\" is marked as non-hintable.") @@ -1329,63 +1376,65 @@ class ClientMessageProcessor(CommonCommandProcessor): hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) else: # location name hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) - cost = self.ctx.get_hint_cost(self.client.slot) - if hints: - new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot] - old_hints = set(hints) - new_hints - if old_hints: - notify_hints(self.ctx, self.client.team, list(old_hints)) - if not new_hints: - self.output("Hint was previously used, no points deducted.") - if new_hints: - found_hints = [hint for hint in new_hints if hint.found] - not_found_hints = [hint for hint in new_hints if not hint.found] - if not not_found_hints: # everything's been found, no need to pay - can_pay = 1000 - elif cost: - can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call - else: - can_pay = 1000 - - self.ctx.random.shuffle(not_found_hints) - # By popular vote, make hints prefer non-local placements - not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player)) - - hints = found_hints - while can_pay > 0: - if not not_found_hints: - break - hint = not_found_hints.pop() - hints.append(hint) - can_pay -= 1 - self.ctx.hints_used[self.client.team, self.client.slot] += 1 - points_available = get_client_points(self.ctx, self.client) - - if not_found_hints: - if hints and cost and int((points_available // cost) == 0): - self.output( - f"There may be more hintables, however, you cannot afford to pay for any more. " - f" You have {points_available} and need at least " - f"{self.ctx.get_hint_cost(self.client.slot)}.") - elif hints: - self.output( - "There may be more hintables, you can rerun the command to find more.") - else: - self.output(f"You can't afford the hint. " - f"You have {points_available} points and need at least " - f"{self.ctx.get_hint_cost(self.client.slot)}.") - notify_hints(self.ctx, self.client.team, hints) - self.ctx.save() - return True - - else: - self.output("Nothing found. Item/Location may not exist.") - return False else: self.output(response) return False + if hints: + cost = self.ctx.get_hint_cost(self.client.slot) + new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot] + old_hints = set(hints) - new_hints + if old_hints: + notify_hints(self.ctx, self.client.team, list(old_hints)) + if not new_hints: + self.output("Hint was previously used, no points deducted.") + if new_hints: + found_hints = [hint for hint in new_hints if hint.found] + not_found_hints = [hint for hint in new_hints if not hint.found] + + if not not_found_hints: # everything's been found, no need to pay + can_pay = 1000 + elif cost: + can_pay = int((points_available // cost) > 0) # limit to 1 new hint per call + else: + can_pay = 1000 + + self.ctx.random.shuffle(not_found_hints) + # By popular vote, make hints prefer non-local placements + not_found_hints.sort(key=lambda hint: int(hint.receiving_player != hint.finding_player)) + + hints = found_hints + while can_pay > 0: + if not not_found_hints: + break + hint = not_found_hints.pop() + hints.append(hint) + can_pay -= 1 + self.ctx.hints_used[self.client.team, self.client.slot] += 1 + points_available = get_client_points(self.ctx, self.client) + + if not_found_hints: + if hints and cost and int((points_available // cost) == 0): + self.output( + f"There may be more hintables, however, you cannot afford to pay for any more. " + f" You have {points_available} and need at least " + f"{self.ctx.get_hint_cost(self.client.slot)}.") + elif hints: + self.output( + "There may be more hintables, you can rerun the command to find more.") + else: + self.output(f"You can't afford the hint. " + f"You have {points_available} points and need at least " + f"{self.ctx.get_hint_cost(self.client.slot)}.") + notify_hints(self.ctx, self.client.team, hints) + self.ctx.save() + return True + + else: + self.output("Nothing found. Item/Location may not exist.") + return False + @mark_raw def _cmd_hint(self, item_name: str = "") -> bool: """Use !hint {item_name}, @@ -1833,17 +1882,25 @@ class ServerCommandProcessor(CommonCommandProcessor): seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) if usable: team, slot = self.ctx.player_name_lookup[seeked_player] - item_name = " ".join(item_name) game = self.ctx.games[slot] - item_name, usable, response = get_intended_text(item_name, self.ctx.all_item_and_group_names[game]) + full_name = " ".join(item_name) + + if full_name.isnumeric(): + item, usable, response = int(full_name), True, None + elif game in self.ctx.all_item_and_group_names: + item, usable, response = get_intended_text(full_name, self.ctx.all_item_and_group_names[game]) + else: + self.output("Can't look up item for unknown game. Hint for ID instead.") + return False + if usable: - if item_name in self.ctx.item_name_groups[game]: + if game in self.ctx.item_name_groups and item in self.ctx.item_name_groups[game]: hints = [] - for item_name_from_group in self.ctx.item_name_groups[game][item_name]: + for item_name_from_group in self.ctx.item_name_groups[game][item]: if item_name_from_group in self.ctx.item_names_for_game(game): # ensure item has an ID hints.extend(collect_hints(self.ctx, team, slot, item_name_from_group)) - else: # item name - hints = collect_hints(self.ctx, team, slot, item_name) + else: # item name or id + hints = collect_hints(self.ctx, team, slot, item) if hints: notify_hints(self.ctx, team, hints) @@ -1864,11 +1921,22 @@ class ServerCommandProcessor(CommonCommandProcessor): seeked_player, usable, response = get_intended_text(player_name, self.ctx.player_names.values()) if usable: team, slot = self.ctx.player_name_lookup[seeked_player] - location_name = " ".join(location_name) - location_name, usable, response = get_intended_text(location_name, - self.ctx.location_names_for_game(self.ctx.games[slot])) + game = self.ctx.games[slot] + full_name = " ".join(location_name) + + if full_name.isnumeric(): + location, usable, response = int(full_name), True, None + elif self.ctx.location_names_for_game(game) is not None: + location, usable, response = get_intended_text(full_name, self.ctx.location_names_for_game(game)) + else: + self.output("Can't look up location for unknown game. Hint for ID instead.") + return False + if usable: - hints = collect_hint_location_name(self.ctx, team, slot, location_name) + if isinstance(location, int): + hints = collect_hint_location_id(self.ctx, team, slot, location) + else: + hints = collect_hint_location_name(self.ctx, team, slot, location) if hints: notify_hints(self.ctx, team, hints) else: @@ -2018,15 +2086,28 @@ async def main(args: argparse.Namespace): args.auto_shutdown, args.compatibility, args.log_network) data_filename = args.multidata - try: - if not data_filename: + if not data_filename: + try: filetypes = (("Multiworld data", (".archipelago", ".zip")),) data_filename = Utils.open_filename("Select multiworld data", filetypes) + except Exception as e: + if isinstance(e, ImportError) or (e.__class__.__name__ == "TclError" and "no display" in str(e)): + if not isinstance(e, ImportError): + logging.error(f"Failed to load tkinter ({e})") + logging.info("Pass a multidata filename on command line to run headless.") + exit(1) + raise + + if not data_filename: + logging.info("No file selected. Exiting.") + exit(1) + + try: ctx.load(data_filename, args.use_embedded_options) except Exception as e: - logging.exception('Failed to read multiworld data (%s)' % e) + logging.exception(f"Failed to read multiworld data ({e})") raise ctx.init_save(not args.disable_save) diff --git a/Options.py b/Options.py index a4f559a532..49f044d8cd 100644 --- a/Options.py +++ b/Options.py @@ -26,15 +26,31 @@ class AssembleOptions(abc.ABCMeta): 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_")} assert "random" not in aliases, "Choice option 'random' cannot be manually assigned." + # auto-alias Off and On being parsed as True and False + if "off" in options: + options["false"] = options["off"] + if "on" in options: + options["true"] = options["on"] + options.update(aliases) + if "verify" not in attrs: + # not overridden by class -> look up bases + verifiers = [f for f in (getattr(base, "verify", None) for base in bases) if f] + if len(verifiers) > 1: # verify multiple bases/mixins + def verify(self, *args, **kwargs) -> None: + for f in verifiers: + f(self, *args, **kwargs) + attrs["verify"] = verify + else: + assert verifiers, "class Option is supposed to implement def verify" + # auto-validate schema on __init__ if "schema" in attrs.keys(): @@ -112,6 +128,41 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): def from_any(cls, data: typing.Any) -> Option[T]: raise NotImplementedError + if typing.TYPE_CHECKING: + from Generate import PlandoSettings + from worlds.AutoWorld import World + + def verify(self, world: World, player_name: str, plando_options: PlandoSettings) -> None: + pass + else: + def verify(self, *args, **kwargs) -> None: + pass + + +class FreeText(Option): + """Text option that allows users to enter strings. + Needs to be validated by the world or option definition.""" + + def __init__(self, value: str): + assert isinstance(value, str), "value of FreeText must be a string" + self.value = value + + @property + def current_key(self) -> str: + return self.value + + @classmethod + def from_text(cls, text: str) -> FreeText: + return cls(text) + + @classmethod + def from_any(cls, data: typing.Any) -> FreeText: + return cls.from_text(str(data)) + + @classmethod + def get_option_name(cls, value: T) -> str: + return value + class NumericOption(Option[int], numbers.Integral): # note: some of the `typing.Any`` here is a result of unresolved issue in python standards @@ -298,7 +349,7 @@ class Toggle(NumericOption): if type(data) == str: return cls.from_text(data) else: - return cls(data) + return cls(int(data)) @classmethod def get_option_name(cls, value): @@ -368,6 +419,53 @@ class Choice(NumericOption): __hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__ +class TextChoice(Choice): + """Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string""" + + def __init__(self, value: typing.Union[str, int]): + assert isinstance(value, str) or isinstance(value, int), \ + f"{value} is not a valid option for {self.__class__.__name__}" + self.value = value + super(TextChoice, self).__init__() + + @property + def current_key(self) -> str: + if isinstance(self.value, str): + return self.value + else: + return self.name_lookup[self.value] + + @classmethod + def from_text(cls, text: str) -> TextChoice: + if text.lower() == "random": # chooses a random defined option but won't use any free text options + return cls(random.choice(list(cls.name_lookup))) + for option_name, value in cls.options.items(): + if option_name.lower() == text.lower(): + return cls(value) + return cls(text) + + @classmethod + def get_option_name(cls, value: T) -> str: + if isinstance(value, str): + return value + return cls.name_lookup[value] + + def __eq__(self, other: typing.Any): + if isinstance(other, self.__class__): + return other.value == self.value + elif isinstance(other, str): + if other in self.options: + return other == self.current_key + return other == self.value + elif isinstance(other, int): + assert other in self.name_lookup, f"compared against an int that could never be equal. {self} == {other}" + return other == self.value + elif isinstance(other, bool): + return other == bool(self.value) + else: + raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}") + + class Range(NumericOption): range_start = 0 range_end = 1 @@ -385,7 +483,7 @@ class Range(NumericOption): if text.startswith("random"): return cls.weighted_range(text) elif text == "default" and hasattr(cls, "default"): - return cls(cls.default) + return cls.from_any(cls.default) elif text == "high": return cls(cls.range_end) elif text == "low": @@ -396,7 +494,7 @@ class Range(NumericOption): and text in ("true", "false"): # these are the conditions where "true" and "false" make sense if text == "true": - return cls(cls.default) + return cls.from_any(cls.default) else: # "false" return cls(0) return cls(int(text)) @@ -507,7 +605,7 @@ class VerifyKeys: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " f"Allowed keys: {cls.valid_keys}.") - def verify(self, world): + def verify(self, world, player_name: str, plando_options) -> None: if self.convert_name_groups and self.verify_item_name: new_value = type(self.value)() # empty container of whatever value is for item_name in self.value: @@ -600,10 +698,7 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys): @classmethod def from_any(cls, data: typing.Any): - if type(data) == list: - cls.verify_keys(data) - return cls(data) - elif type(data) == set: + if isinstance(data, (list, set, frozenset)): cls.verify_keys(data) return cls(data) return cls.from_text(str(data)) @@ -732,8 +827,8 @@ class ItemLinks(OptionList): pool |= {item_name} return pool - def verify(self, world): - super(ItemLinks, self).verify(world) + def verify(self, world, player_name: str, plando_options) -> None: + super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() for link in self.value: if link["name"] in existing_links: diff --git a/Patch.py b/Patch.py index f90e376656..aaa4fc2404 100644 --- a/Patch.py +++ b/Patch.py @@ -17,7 +17,7 @@ ModuleUpdate.update() import Utils -current_patch_version = 4 +current_patch_version = 5 class AutoPatchRegister(type): @@ -128,6 +128,7 @@ class APDeltaPatch(APContainer, metaclass=AutoPatchRegister): manifest = super(APDeltaPatch, self).get_manifest() manifest["base_checksum"] = self.hash manifest["result_file_ending"] = self.result_file_ending + manifest["patch_file_ending"] = self.patch_file_ending return manifest @classmethod diff --git a/README.md b/README.md index 9403159c74..c8362dddd0 100644 --- a/README.md +++ b/README.md @@ -61,26 +61,10 @@ This project makes use of multiple other projects. We wouldn't be here without t * [Ocarina of Time Randomizer](https://github.com/TestRunnerSRL/OoT-Randomizer) ## Contributing -Contributions are welcome. We have a few asks of any new contributors. - -* Ensure that all changes which affect logic are covered by unit tests. -* Do not introduce any unit test failures/regressions. - -Otherwise, we tend to judge code on a case to case basis. It is a generally good idea to stick to PEP-8 guidelines to ensure consistency with existing code. (And to make the linter happy.) - -For adding a new game to Archipelago and other documentation on how Archipelago functions, please see [the docs folder](docs/) for the relevant information and feel free to ask any questions in the #archipelago-dev channel in our discord. +For contribution guidelines, please see our [Contributing doc.](/docs/contributing.md) ## FAQ -For frequently asked questions see the website's [FAQ Page](https://archipelago.gg/faq/en/) +For Frequently asked questions, please see the website's [FAQ Page.](https://archipelago.gg/faq/en/) ## Code of Conduct -We conduct ourselves openly and inclusively here. Please do not contribute to an environment which makes other people uncomfortable. This means that we expect all contributors or participants here to: - -* Be welcoming and inclusive in tone and language. -* Be respectful of others and their abilities. -* Show empathy when speaking with others. -* Be gracious and accept feedback and constructive criticism. - -These guidelines apply to all channels of communication within this GitHub repository. Please be respectful in both public channels, such as issues, and private, such as private messaging or emails. - -Any incidents of abuse may be reported directly to Ijwu at hmfarran@gmail.com. +Please refer to our [code of conduct.](/docs/code_of_conduct.md) diff --git a/SNIClient.py b/SNIClient.py index aad231691b..477cde86a2 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -15,9 +15,6 @@ import typing from json import loads, dumps -import ModuleUpdate -ModuleUpdate.update() - from Utils import init_logging, messagebox if __name__ == "__main__": @@ -149,8 +146,8 @@ class Context(CommonContext): def event_invalid_slot(self): if self.snes_socket is not None and not self.snes_socket.closed: asyncio.create_task(self.snes_socket.close()) - raise Exception('Invalid ROM detected, ' - 'please verify that you have loaded the correct rom and reconnect your snes (/snes)') + raise Exception("Invalid ROM detected, " + "please verify that you have loaded the correct rom and reconnect your snes (/snes)") async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -158,7 +155,7 @@ class Context(CommonContext): if self.rom is None: self.awaiting_rom = True snes_logger.info( - 'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)') + "No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)") return self.awaiting_rom = False self.auth = self.rom @@ -262,7 +259,7 @@ async def deathlink_kill_player(ctx: Context): SNES_RECONNECT_DELAY = 5 -# LttP +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 @@ -293,21 +290,24 @@ SHOP_LEN = (len(Shops.shop_table) * 3) + 5 DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte # SM -SM_ROMNAME_START = 0x007FC0 +SM_ROMNAME_START = ROM_START + 0x007FC0 SM_INGAME_MODES = {0x07, 0x09, 0x0b} SM_ENDGAME_MODES = {0x26, 0x27} SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} -SM_RECV_PROGRESS_ADDR = SRAM_START + 0x2000 # 2 bytes -SM_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -SM_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte +# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue +SM_RECV_QUEUE_START = SRAM_START + 0x2000 +SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 +SM_SEND_QUEUE_START = SRAM_START + 0x2700 +SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 +SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte # SMZ3 -SMZ3_ROMNAME_START = 0x00FFC0 +SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b} SMZ3_ENDGAME_MODES = {0x26, 0x27} @@ -1083,6 +1083,9 @@ async def game_watcher(ctx: Context): if ctx.awaiting_rom: await ctx.server_auth(False) + elif ctx.server is None: + snes_logger.warning("ROM detected but no active multiworld server connection. " + + "Connect using command: /connect server:port") if ctx.auth and ctx.auth != ctx.rom: snes_logger.warning("ROM change detected, please reconnect to the multiworld server") @@ -1159,6 +1162,9 @@ async def game_watcher(ctx: Context): await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) await track_locations(ctx, roomid, roomdata) elif ctx.game == GAME_SM: + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + continue gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): currently_dead = gamemode[0] in SM_DEATH_MODES @@ -1169,25 +1175,25 @@ async def game_watcher(ctx: Context): ctx.finished_game = True continue - data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x680, 4) + data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) if data is None: continue recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) + recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT while (recv_index < recv_item): itemAdress = recv_index * 8 - message = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8) + message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8) # worldId = message[0] | (message[1] << 8) # unused # itemId = message[2] | (message[3] << 8) # unused itemIndex = (message[4] | (message[5] << 8)) >> 3 recv_index += 1 - snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x680, + snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - from worlds.sm.Locations import locations_start_id + from worlds.sm import locations_start_id location_id = locations_start_id + itemIndex ctx.locations_checked.add(location_id) @@ -1196,15 +1202,14 @@ async def game_watcher(ctx: Context): f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - data = await snes_read(ctx, SM_RECV_PROGRESS_ADDR + 0x600, 4) + data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) if data is None: continue - # recv_itemOutPtr = data[0] | (data[1] << 8) # unused - itemOutPtr = data[2] | (data[3] << 8) + itemOutPtr = data[0] | (data[1] << 8) - from worlds.sm.Items import items_start_id - from worlds.sm.Locations import locations_start_id + from worlds.sm import items_start_id + from worlds.sm import locations_start_id if itemOutPtr < len(ctx.items_received): item = ctx.items_received[itemOutPtr] itemId = item.item - items_start_id @@ -1214,10 +1219,10 @@ async def game_watcher(ctx: Context): locationId = 0x00 #backward compat playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes( + snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes( [playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF])) itemOutPtr += 1 - snes_buffered_write(ctx, SM_RECV_PROGRESS_ADDR + 0x602, + snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) logging.info('Received %s from %s (%s) (%d/%d in list)' % ( color(ctx.item_names[item.item], 'red', 'bold'), @@ -1225,6 +1230,9 @@ async def game_watcher(ctx: Context): ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) await snes_flush_writes(ctx) elif ctx.game == GAME_SMZ3: + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + continue currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) if (currentGame is not None): if (currentGame[0] != 0): @@ -1260,7 +1268,8 @@ async def game_watcher(ctx: Context): snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) from worlds.smz3.TotalSMZ3.Location import locations_start_id - location_id = locations_start_id + itemIndex + from worlds.smz3 import convertLocSMZ3IDToAPID + location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex) ctx.locations_checked.add(location_id) location = ctx.location_names[location_id] diff --git a/Starcraft2Client.py b/Starcraft2Client.py index dc63e9a456..d91adffb08 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -1,31 +1,32 @@ from __future__ import annotations -import multiprocessing -import logging import asyncio +import copy +import ctypes +import logging +import multiprocessing import os.path +import re +import sys +import typing +import queue +from pathlib import Path import nest_asyncio import sc2 - -from sc2.main import run_game -from sc2.data import Race from sc2.bot_ai import BotAI +from sc2.data import Race +from sc2.main import run_game from sc2.player import Bot -from worlds.sc2wol.Regions import MissionInfo -from worlds.sc2wol.MissionTables import lookup_id_to_mission -from worlds.sc2wol.Items import lookup_id_to_name, item_table -from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET -from worlds.sc2wol import SC2WoLWorld - -from pathlib import Path -import re +import NetUtils from MultiServer import mark_raw -import ctypes -import sys - from Utils import init_logging, is_windows +from worlds.sc2wol import SC2WoLWorld +from worlds.sc2wol.Items import lookup_id_to_name, item_table, ItemData, type_flaggroups +from worlds.sc2wol.Locations import SC2WOL_LOC_ID_OFFSET +from worlds.sc2wol.MissionTables import lookup_id_to_mission +from worlds.sc2wol.Regions import MissionInfo if __name__ == "__main__": init_logging("SC2Client", exception_logger="Client") @@ -35,10 +36,12 @@ sc2_logger = logging.getLogger("Starcraft2") import colorama -from NetUtils import * +from NetUtils import ClientStatus, RawJSONtoTextParser from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser nest_asyncio.apply() +max_bonus: int = 8 +victory_modulo: int = 100 class StarcraftClientProcessor(ClientCommandProcessor): @@ -98,13 +101,13 @@ class StarcraftClientProcessor(ClientCommandProcessor): def _cmd_available(self) -> bool: """Get what missions are currently available to play""" - request_available_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui) + request_available_missions(self.ctx) return True def _cmd_unfinished(self) -> bool: """Get what missions are currently available to play and have not had all locations checked""" - request_unfinished_missions(self.ctx.checked_locations, self.ctx.mission_req_table, self.ctx.ui, self.ctx) + request_unfinished_missions(self.ctx) return True @mark_raw @@ -125,18 +128,19 @@ class SC2Context(CommonContext): items_handling = 0b111 difficulty = -1 all_in_choice = 0 - mission_req_table = None - items_rec_to_announce = [] - rec_announce_pos = 0 - items_sent_to_announce = [] - sent_announce_pos = 0 - announcements = [] - announcement_pos = 0 + mission_req_table: typing.Dict[str, MissionInfo] = {} + announcements = queue.Queue() sc2_run_task: typing.Optional[asyncio.Task] = None - missions_unlocked = False + missions_unlocked: bool = False # allow launching missions ignoring requirements current_tooltip = None last_loc_list = None difficulty_override = -1 + mission_id_to_location_ids: typing.Dict[int, typing.List[int]] = {} + last_bot: typing.Optional[ArchipelagoBot] = None + + def __init__(self, *args, **kwargs): + super(SC2Context, self).__init__(*args, **kwargs) + self.raw_text_parser = RawJSONtoTextParser(self) async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -149,30 +153,35 @@ class SC2Context(CommonContext): self.difficulty = args["slot_data"]["game_difficulty"] self.all_in_choice = args["slot_data"]["all_in_map"] slot_req_table = args["slot_data"]["mission_req"] - self.mission_req_table = {} - # Compatibility for 0.3.2 server data. - if "category" not in next(iter(slot_req_table)): - for i, mission_data in enumerate(slot_req_table.values()): - mission_data["category"] = wol_default_categories[i] - for mission in slot_req_table: - self.mission_req_table[mission] = MissionInfo(**slot_req_table[mission]) + self.mission_req_table = { + mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table + } + + self.build_location_to_mission_mapping() # Look for and set SC2PATH. # check_game_install_path() returns True if and only if it finds + sets SC2PATH. if "SC2PATH" not in os.environ and check_game_install_path(): check_mod_install() - if cmd in {"PrintJSON"}: - if "receiving" in args: - if self.slot_concerns_self(args["receiving"]): - self.announcements.append(args["data"]) - return - if "item" in args: - if self.slot_concerns_self(args["item"].player): - self.announcements.append(args["data"]) + def on_print_json(self, args: dict): + # goes to this world + if "receiving" in args and self.slot_concerns_self(args["receiving"]): + relevant = True + # found in this world + elif "item" in args and self.slot_concerns_self(args["item"].player): + relevant = True + # not related + else: + relevant = False + + if relevant: + self.announcements.put(self.raw_text_parser(copy.deepcopy(args["data"]))) + + super(SC2Context, self).on_print_json(args) def run_gui(self): - from kvui import GameManager, HoverBehavior, ServerToolTip, fade_in_animation + from kvui import GameManager, HoverBehavior, ServerToolTip from kivy.app import App from kivy.clock import Clock from kivy.uix.tabbedpanel import TabbedPanelItem @@ -190,6 +199,7 @@ class SC2Context(CommonContext): class MissionButton(HoverableButton): tooltip_text = StringProperty("Test") + ctx: SC2Context def __init__(self, *args, **kwargs): super(HoverableButton, self).__init__(*args, **kwargs) @@ -210,10 +220,7 @@ class SC2Context(CommonContext): self.ctx.current_tooltip = self.layout def on_leave(self): - if self.ctx.current_tooltip: - App.get_running_app().root.remove_widget(self.ctx.current_tooltip) - - self.ctx.current_tooltip = None + self.ctx.ui.clear_tooltip() @property def ctx(self) -> CommonContext: @@ -235,13 +242,20 @@ class SC2Context(CommonContext): mission_panel = None last_checked_locations = {} mission_id_to_button = {} - launching = False + launching: typing.Union[bool, int] = False # if int -> mission ID refresh_from_launching = True first_check = True + ctx: SC2Context def __init__(self, ctx): super().__init__(ctx) + def clear_tooltip(self): + if self.ctx.current_tooltip: + App.get_running_app().root.remove_widget(self.ctx.current_tooltip) + + self.ctx.current_tooltip = None + def build(self): container = super().build() @@ -256,7 +270,7 @@ class SC2Context(CommonContext): def build_mission_table(self, dt): if (not self.launching and (not self.last_checked_locations == self.ctx.checked_locations or - not self.refresh_from_launching)) or self.first_check: + not self.refresh_from_launching)) or self.first_check: self.refresh_from_launching = True self.mission_panel.clear_widgets() @@ -267,12 +281,7 @@ class SC2Context(CommonContext): self.mission_id_to_button = {} categories = {} - available_missions = [] - unfinished_locations = initialize_blank_mission_dict(self.ctx.mission_req_table) - unfinished_missions = calc_unfinished_missions(self.ctx.checked_locations, - self.ctx.mission_req_table, - self.ctx, available_missions=available_missions, - unfinished_locations=unfinished_locations) + available_missions, unfinished_missions = calc_unfinished_missions(self.ctx) # separate missions into categories for mission in self.ctx.mission_req_table: @@ -283,34 +292,40 @@ class SC2Context(CommonContext): for category in categories: category_panel = MissionCategory() - category_panel.add_widget(Label(text=category, size_hint_y=None, height=50, outline_width=1)) + category_panel.add_widget( + Label(text=category, size_hint_y=None, height=50, outline_width=1)) - # Map is completed for mission in categories[category]: - text = mission - tooltip = "" + text: str = mission + tooltip: str = "" # Map has uncollected locations if mission in unfinished_missions: text = f"[color=6495ED]{text}[/color]" - tooltip = f"Uncollected locations:\n" - tooltip += "\n".join(location for location in unfinished_locations[mission]) elif mission in available_missions: text = f"[color=FFFFFF]{text}[/color]" # Map requirements not met else: text = f"[color=a9a9a9]{text}[/color]" tooltip = f"Requires: " - if len(self.ctx.mission_req_table[mission].required_world) > 0: - tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission-1] for + if self.ctx.mission_req_table[mission].required_world: + tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for req_mission in self.ctx.mission_req_table[mission].required_world) - if self.ctx.mission_req_table[mission].number > 0: + if self.ctx.mission_req_table[mission].number: tooltip += " and " - if self.ctx.mission_req_table[mission].number > 0: + if self.ctx.mission_req_table[mission].number: tooltip += f"{self.ctx.mission_req_table[mission].number} missions completed" + remaining_location_names: typing.List[str] = [ + self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) + if loc in self.ctx.missing_locations] + if remaining_location_names: + if tooltip: + tooltip += "\n" + tooltip += f"Uncollected locations:\n" + tooltip += "\n".join(remaining_location_names) mission_button = MissionButton(text=text, size_hint_y=None, height=50) mission_button.tooltip_text = tooltip @@ -325,13 +340,16 @@ class SC2Context(CommonContext): self.refresh_from_launching = False self.mission_panel.clear_widgets() - self.mission_panel.add_widget(Label(text="Launching Mission")) + self.mission_panel.add_widget(Label(text="Launching Mission: " + + lookup_id_to_mission[self.launching])) + if self.ctx.ui: + self.ctx.ui.clear_tooltip() def mission_callback(self, button): if not self.launching: - self.ctx.play_mission(list(self.mission_id_to_button.keys()) - [list(self.mission_id_to_button.values()).index(button)]) - self.launching = True + mission_id: int = next(k for k, v in self.mission_id_to_button.items() if v == button) + self.ctx.play_mission(mission_id) + self.launching = mission_id Clock.schedule_once(self.finish_launching, 10) def finish_launching(self, dt): @@ -344,12 +362,14 @@ class SC2Context(CommonContext): async def shutdown(self): await super(SC2Context, self).shutdown() + if self.last_bot: + self.last_bot.want_close = True if self.sc2_run_task: self.sc2_run_task.cancel() - def play_mission(self, mission_id): + def play_mission(self, mission_id: int): if self.missions_unlocked or \ - is_mission_available(mission_id, self.checked_locations, self.mission_req_table): + is_mission_available(self, mission_id): if self.sc2_run_task: if not self.sc2_run_task.done(): sc2_logger.warning("Starcraft 2 Client is still running!") @@ -358,12 +378,29 @@ class SC2Context(CommonContext): sc2_logger.warning("Launching Mission without Archipelago authentication, " "checks will not be registered to server.") self.sc2_run_task = asyncio.create_task(starcraft_launch(self, mission_id), - name="Starcraft 2 Launch") + name="Starcraft 2 Launch") else: sc2_logger.info( f"{lookup_id_to_mission[mission_id]} is not currently unlocked. " f"Use /unfinished or /available to see what is available.") + def build_location_to_mission_mapping(self): + mission_id_to_location_ids: typing.Dict[int, typing.Set[int]] = { + mission_info.id: set() for mission_info in self.mission_req_table.values() + } + + for loc in self.server_locations: + mission_id, objective = divmod(loc - SC2WOL_LOC_ID_OFFSET, victory_modulo) + mission_id_to_location_ids[mission_id].add(objective) + self.mission_id_to_location_ids = {mission_id: sorted(objectives) for mission_id, objectives in + mission_id_to_location_ids.items()} + + def locations_for_mission(self, mission: str): + mission_id: int = self.mission_req_table[mission].id + objectives = self.mission_id_to_location_ids[self.mission_req_table[mission].id] + for objective in objectives: + yield SC2WOL_LOC_ID_OFFSET + mission_id * 100 + objective + async def main(): multiprocessing.freeze_support() @@ -403,47 +440,27 @@ wol_default_categories = [ ] -def calculate_items(items): - unit_unlocks = 0 - armory1_unlocks = 0 - armory2_unlocks = 0 - upgrade_unlocks = 0 - building_unlocks = 0 - merc_unlocks = 0 - lab_unlocks = 0 - protoss_unlock = 0 - minerals = 0 - vespene = 0 - supply = 0 +def calculate_items(items: typing.List[NetUtils.NetworkItem]) -> typing.List[int]: + network_item: NetUtils.NetworkItem + accumulators: typing.List[int] = [0 for _ in type_flaggroups] - for item in items: - data = lookup_id_to_name[item.item] + for network_item in items: + name: str = lookup_id_to_name[network_item.item] + item_data: ItemData = item_table[name] - if item_table[data].type == "Unit": - unit_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Upgrade": - upgrade_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Armory 1": - armory1_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Armory 2": - armory2_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Building": - building_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Mercenary": - merc_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Laboratory": - lab_unlocks += (1 << item_table[data].number) - elif item_table[data].type == "Protoss": - protoss_unlock += (1 << item_table[data].number) - elif item_table[data].type == "Minerals": - minerals += item_table[data].number - elif item_table[data].type == "Vespene": - vespene += item_table[data].number - elif item_table[data].type == "Supply": - supply += item_table[data].number + # exists exactly once + if item_data.quantity == 1: + accumulators[type_flaggroups[item_data.type]] |= 1 << item_data.number - return [unit_unlocks, upgrade_unlocks, armory1_unlocks, armory2_unlocks, building_unlocks, merc_unlocks, - lab_unlocks, protoss_unlock, minerals, vespene, supply] + # exists multiple times + elif item_data.type == "Upgrade": + accumulators[type_flaggroups[item_data.type]] += 1 << item_data.number + + # sum + else: + accumulators[type_flaggroups[item_data.type]] += item_data.number + + return accumulators def calc_difficulty(difficulty): @@ -459,11 +476,7 @@ def calc_difficulty(difficulty): return 'X' -async def starcraft_launch(ctx: SC2Context, mission_id): - ctx.rec_announce_pos = len(ctx.items_rec_to_announce) - ctx.sent_announce_pos = len(ctx.items_sent_to_announce) - ctx.announcements_pos = len(ctx.announcements) - +async def starcraft_launch(ctx: SC2Context, mission_id: int): sc2_logger.info(f"Launching {lookup_id_to_mission[mission_id]}. If game does not launch check log file for errors.") with DllDirectory(None): @@ -472,32 +485,34 @@ async def starcraft_launch(ctx: SC2Context, mission_id): class ArchipelagoBot(sc2.bot_ai.BotAI): - game_running = False - mission_completed = False - first_bonus = False - second_bonus = False - third_bonus = False - fourth_bonus = False - fifth_bonus = False - sixth_bonus = False - seventh_bonus = False - eight_bonus = False - ctx: SC2Context = None - mission_id = 0 - + game_running: bool = False + mission_completed: bool = False + boni: typing.List[bool] + setup_done: bool + ctx: SC2Context + mission_id: int + want_close: bool = False can_read_game = False - last_received_update = 0 + last_received_update: int = 0 def __init__(self, ctx: SC2Context, mission_id): + self.setup_done = False self.ctx = ctx + self.ctx.last_bot = self self.mission_id = mission_id + self.boni = [False for _ in range(max_bonus)] super(ArchipelagoBot, self).__init__() async def on_step(self, iteration: int): + if self.want_close: + self.want_close = False + await self._client.leave() + return game_state = 0 - if iteration == 0: + if not self.setup_done: + self.setup_done = True start_items = calculate_items(self.ctx.items_received) if self.ctx.difficulty_override >= 0: difficulty = calc_difficulty(self.ctx.difficulty_override) @@ -511,36 +526,10 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): self.last_received_update = len(self.ctx.items_received) else: - if self.ctx.announcement_pos < len(self.ctx.announcements): - index = 0 - message = "" - while index < len(self.ctx.announcements[self.ctx.announcement_pos]): - message += self.ctx.announcements[self.ctx.announcement_pos][index]["text"] - index += 1 - - index = 0 - start_rem_pos = -1 - # Remove unneeded [Color] tags - while index < len(message): - if message[index] == '[': - start_rem_pos = index - index += 1 - elif message[index] == ']' and start_rem_pos > -1: - temp_msg = "" - - if start_rem_pos > 0: - temp_msg = message[:start_rem_pos] - if index < len(message) - 1: - temp_msg += message[index + 1:] - - message = temp_msg - index += start_rem_pos - index - start_rem_pos = -1 - else: - index += 1 - + if not self.ctx.announcements.empty(): + message = self.ctx.announcements.get(timeout=1) await self.chat_send("SendMessage " + message) - self.ctx.announcement_pos += 1 + self.ctx.announcements.task_done() # Archipelago reads the health for unit in self.all_own_units(): @@ -568,169 +557,97 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): if game_state & (1 << 1) and not self.mission_completed: if self.mission_id != 29: print("Mission Completed") - await self.ctx.send_msgs([ - {"cmd": 'LocationChecks', "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id]}]) + await self.ctx.send_msgs( + [{"cmd": 'LocationChecks', + "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id]}]) self.mission_completed = True else: print("Game Complete") await self.ctx.send_msgs([{"cmd": 'StatusUpdate', "status": ClientStatus.CLIENT_GOAL}]) self.mission_completed = True - if game_state & (1 << 2) and not self.first_bonus: - print("1st Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 1]}]) - self.first_bonus = True - - if not self.second_bonus and game_state & (1 << 3): - print("2nd Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 2]}]) - self.second_bonus = True - - if not self.third_bonus and game_state & (1 << 4): - print("3rd Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 3]}]) - self.third_bonus = True - - if not self.fourth_bonus and game_state & (1 << 5): - print("4th Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 4]}]) - self.fourth_bonus = True - - if not self.fifth_bonus and game_state & (1 << 6): - print("5th Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 5]}]) - self.fifth_bonus = True - - if not self.sixth_bonus and game_state & (1 << 7): - print("6th Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 6]}]) - self.sixth_bonus = True - - if not self.seventh_bonus and game_state & (1 << 8): - print("6th Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 7]}]) - self.seventh_bonus = True - - if not self.eight_bonus and game_state & (1 << 9): - print("6th Bonus Collected") - await self.ctx.send_msgs( - [{"cmd": 'LocationChecks', - "locations": [SC2WOL_LOC_ID_OFFSET + 100 * self.mission_id + 8]}]) - self.eight_bonus = True + for x, completed in enumerate(self.boni): + if not completed and game_state & (1 << (x + 2)): + await self.ctx.send_msgs( + [{"cmd": 'LocationChecks', + "locations": [SC2WOL_LOC_ID_OFFSET + victory_modulo * self.mission_id + x + 1]}]) + self.boni[x] = True else: await self.chat_send("LostConnection - Lost connection to game.") -def calc_objectives_completed(mission, missions_info, locations_done, unfinished_locations, ctx): - objectives_complete = 0 - - if missions_info[mission].extra_locations > 0: - for i in range(missions_info[mission].extra_locations): - if (missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i) in locations_done: - objectives_complete += 1 - else: - unfinished_locations[mission].append(ctx.location_names[ - missions_info[mission].id * 100 + SC2WOL_LOC_ID_OFFSET + i]) - - return objectives_complete - - else: - return -1 - - -def request_unfinished_missions(locations_done, location_table, ui, ctx): - if location_table: +def request_unfinished_missions(ctx: SC2Context): + if ctx.mission_req_table: message = "Unfinished Missions: " - unlocks = initialize_blank_mission_dict(location_table) - unfinished_locations = initialize_blank_mission_dict(location_table) + unlocks = initialize_blank_mission_dict(ctx.mission_req_table) + unfinished_locations = initialize_blank_mission_dict(ctx.mission_req_table) - unfinished_missions = calc_unfinished_missions(locations_done, location_table, ctx, unlocks=unlocks, - unfinished_locations=unfinished_locations) + _, unfinished_missions = calc_unfinished_missions(ctx, unlocks=unlocks) - message += ", ".join(f"{mark_up_mission_name(mission, location_table, ui,unlocks)}[{location_table[mission].id}] " + + message += ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}[{ctx.mission_req_table[mission].id}] " + mark_up_objectives( - f"[{unfinished_missions[mission]}/{location_table[mission].extra_locations}]", + f"[{len(unfinished_missions[mission])}/" + f"{sum(1 for _ in ctx.locations_for_mission(mission))}]", ctx, unfinished_locations, mission) for mission in unfinished_missions) - if ui: - ui.log_panels['All'].on_message_markup(message) - ui.log_panels['Starcraft2'].on_message_markup(message) + if ctx.ui: + ctx.ui.log_panels['All'].on_message_markup(message) + ctx.ui.log_panels['Starcraft2'].on_message_markup(message) else: sc2_logger.info(message) else: sc2_logger.warning("No mission table found, you are likely not connected to a server.") -def calc_unfinished_missions(locations_done, locations, ctx, unlocks=None, unfinished_locations=None, - available_missions=[]): +def calc_unfinished_missions(ctx: SC2Context, unlocks=None): unfinished_missions = [] locations_completed = [] if not unlocks: - unlocks = initialize_blank_mission_dict(locations) + unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - if not unfinished_locations: - unfinished_locations = initialize_blank_mission_dict(locations) - - if len(available_missions) > 0: - available_missions = [] - - available_missions.extend(calc_available_missions(locations_done, locations, unlocks)) + available_missions = calc_available_missions(ctx, unlocks) for name in available_missions: - if not locations[name].extra_locations == -1: - objectives_completed = calc_objectives_completed(name, locations, locations_done, unfinished_locations, ctx) - - if objectives_completed < locations[name].extra_locations: + objectives = set(ctx.locations_for_mission(name)) + if objectives: + objectives_completed = ctx.checked_locations & objectives + if len(objectives_completed) < len(objectives): unfinished_missions.append(name) locations_completed.append(objectives_completed) - else: + else: # infer that this is the final mission as it has no objectives unfinished_missions.append(name) locations_completed.append(-1) - return {unfinished_missions[i]: locations_completed[i] for i in range(len(unfinished_missions))} + return available_missions, dict(zip(unfinished_missions, locations_completed)) -def is_mission_available(mission_id_to_check, locations_done, locations): - unfinished_missions = calc_available_missions(locations_done, locations) +def is_mission_available(ctx: SC2Context, mission_id_to_check): + unfinished_missions = calc_available_missions(ctx) - return any(mission_id_to_check == locations[mission].id for mission in unfinished_missions) + return any(mission_id_to_check == ctx.mission_req_table[mission].id for mission in unfinished_missions) -def mark_up_mission_name(mission, location_table, ui, unlock_table): +def mark_up_mission_name(ctx: SC2Context, mission, unlock_table): """Checks if the mission is required for game completion and adds '*' to the name to mark that.""" - if location_table[mission].completion_critical: - if ui: + if ctx.mission_req_table[mission].completion_critical: + if ctx.ui: message = "[color=AF99EF]" + mission + "[/color]" else: message = "*" + mission + "*" else: message = mission - if ui: + if ctx.ui: unlocks = unlock_table[mission] if len(unlocks) > 0: - pre_message = f"[ref={list(location_table).index(mission)}|Unlocks: " - pre_message += ", ".join(f"{unlock}({location_table[unlock].id})" for unlock in unlocks) + pre_message = f"[ref={list(ctx.mission_req_table).index(mission)}|Unlocks: " + pre_message += ", ".join(f"{unlock}({ctx.mission_req_table[unlock].id})" for unlock in unlocks) pre_message += f"]" message = pre_message + message + "[/ref]" @@ -743,7 +660,7 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission): if ctx.ui: locations = unfinished_locations[mission] - pre_message = f"[ref={list(ctx.mission_req_table).index(mission)+30}|" + pre_message = f"[ref={list(ctx.mission_req_table).index(mission) + 30}|" pre_message += "".join(location for location in locations) pre_message += f"]" formatted_message = pre_message + message + "[/ref]" @@ -751,90 +668,91 @@ def mark_up_objectives(message, ctx, unfinished_locations, mission): return formatted_message -def request_available_missions(locations_done, location_table, ui): - if location_table: +def request_available_missions(ctx: SC2Context): + if ctx.mission_req_table: message = "Available Missions: " # Initialize mission unlock table - unlocks = initialize_blank_mission_dict(location_table) + unlocks = initialize_blank_mission_dict(ctx.mission_req_table) - missions = calc_available_missions(locations_done, location_table, unlocks) + missions = calc_available_missions(ctx, unlocks) message += \ - ", ".join(f"{mark_up_mission_name(mission, location_table, ui, unlocks)}[{location_table[mission].id}]" + ", ".join(f"{mark_up_mission_name(ctx, mission, unlocks)}" + f"[{ctx.mission_req_table[mission].id}]" for mission in missions) - if ui: - ui.log_panels['All'].on_message_markup(message) - ui.log_panels['Starcraft2'].on_message_markup(message) + if ctx.ui: + ctx.ui.log_panels['All'].on_message_markup(message) + ctx.ui.log_panels['Starcraft2'].on_message_markup(message) else: sc2_logger.info(message) else: sc2_logger.warning("No mission table found, you are likely not connected to a server.") -def calc_available_missions(locations_done, locations, unlocks=None): +def calc_available_missions(ctx: SC2Context, unlocks=None): available_missions = [] missions_complete = 0 # Get number of missions completed - for loc in locations_done: - if loc % 100 == 0: + for loc in ctx.checked_locations: + if loc % victory_modulo == 0: missions_complete += 1 - for name in locations: + for name in ctx.mission_req_table: # Go through the required missions for each mission and fill up unlock table used later for hover-over tooltips if unlocks: - for unlock in locations[name].required_world: - unlocks[list(locations)[unlock-1]].append(name) + for unlock in ctx.mission_req_table[name].required_world: + unlocks[list(ctx.mission_req_table)[unlock - 1]].append(name) - if mission_reqs_completed(name, missions_complete, locations_done, locations): + if mission_reqs_completed(ctx, name, missions_complete): available_missions.append(name) return available_missions -def mission_reqs_completed(location_to_check, missions_complete, locations_done, locations): +def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete): """Returns a bool signifying if the mission has all requirements complete and can be done - Keyword arguments: + Arguments: + ctx -- instance of SC2Context locations_to_check -- the mission string name to check missions_complete -- an int of how many missions have been completed - locations_done -- a list of the location ids that have been complete - locations -- a dict of MissionInfo for mission requirements for this world""" - if len(locations[location_to_check].required_world) >= 1: +""" + if len(ctx.mission_req_table[mission_name].required_world) >= 1: # A check for when the requirements are being or'd or_success = False # Loop through required missions - for req_mission in locations[location_to_check].required_world: + for req_mission in ctx.mission_req_table[mission_name].required_world: req_success = True # Check if required mission has been completed - if not (locations[list(locations)[req_mission-1]].id * 100 + SC2WOL_LOC_ID_OFFSET) in locations_done: - if not locations[location_to_check].or_requirements: + if not (ctx.mission_req_table[list(ctx.mission_req_table)[req_mission - 1]].id * + victory_modulo + SC2WOL_LOC_ID_OFFSET) in ctx.checked_locations: + if not ctx.mission_req_table[mission_name].or_requirements: return False else: req_success = False # Recursively check required mission to see if it's requirements are met, in case !collect has been done - if not mission_reqs_completed(list(locations)[req_mission-1], missions_complete, locations_done, - locations): - if not locations[location_to_check].or_requirements: + if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): + if not ctx.mission_req_table[mission_name].or_requirements: return False else: req_success = False # If requirement check succeeded mark or as satisfied - if locations[location_to_check].or_requirements and req_success: + if ctx.mission_req_table[mission_name].or_requirements and req_success: or_success = True - if locations[location_to_check].or_requirements: + if ctx.mission_req_table[mission_name].or_requirements: # Return false if or requirements not met if not or_success: return False # Check number of missions - if missions_complete >= locations[location_to_check].number: + if missions_complete >= ctx.mission_req_table[mission_name].number: return True else: return False @@ -875,7 +793,12 @@ def check_game_install_path() -> bool: with open(einfo) as f: content = f.read() if content: - base = re.search(r" = (.*)Versions", content).group(1) + try: + base = re.search(r" = (.*)Versions", content).group(1) + except AttributeError: + sc2_logger.warning(f"Found {einfo}, but it was empty. Run SC2 through the Blizzard launcher, then " + f"try again.") + return False if os.path.exists(base): executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions") @@ -892,7 +815,8 @@ def check_game_install_path() -> bool: else: sc2_logger.warning(f"{einfo} pointed to {base}, but we could not find an SC2 install there.") else: - sc2_logger.warning(f"Couldn't find {einfo}. Please run /set_path with your SC2 install directory.") + sc2_logger.warning(f"Couldn't find {einfo}. Run SC2 through the Blizzard launcher, then try again. " + f"If that fails, please run /set_path with your SC2 install directory.") return False @@ -929,7 +853,7 @@ class DllDirectory: self.set(self._old) @staticmethod - def get() -> str: + def get() -> typing.Optional[str]: if sys.platform == "win32": n = ctypes.windll.kernel32.GetDllDirectoryW(0, None) buf = ctypes.create_unicode_buffer(n) diff --git a/Utils.py b/Utils.py index c621e31c9a..c362131d75 100644 --- a/Utils.py +++ b/Utils.py @@ -35,7 +35,7 @@ class Version(typing.NamedTuple): build: int -__version__ = "0.3.4" +__version__ = "0.3.5" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -619,7 +619,7 @@ def title_sorted(data: typing.Sequence, key=None, ignore: typing.Set = frozenset def sorter(element: str) -> str: parts = element.split(maxsplit=1) if parts[0].lower() in ignore: - return parts[1] + return parts[1].lower() else: - return element + return element.lower() return sorted(data, key=lambda i: sorter(key(i)) if key else sorter(i)) diff --git a/WebHost.py b/WebHost.py index db802193a6..4c07e8b185 100644 --- a/WebHost.py +++ b/WebHost.py @@ -12,7 +12,7 @@ ModuleUpdate.update() # in case app gets imported by something like gunicorn import Utils -Utils.local_path.cached_path = os.path.dirname(__file__) +Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 from WebHostLib import register, app as raw_app from waitress import serve @@ -104,7 +104,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] for games in data: if 'Archipelago' in games['gameTitle']: generic_data = data.pop(data.index(games)) - sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"].lower()) + sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"]) json.dump(sorted_data, json_target, indent=2, ensure_ascii=False) return sorted_data diff --git a/WebHostLib/README.md b/WebHostLib/README.md new file mode 100644 index 0000000000..52d4963aee --- /dev/null +++ b/WebHostLib/README.md @@ -0,0 +1,46 @@ +# WebHost + +## Contribution Guidelines +**Thank you for your interest in contributing to the Archipelago website!** +Much of the content on the website is generated automatically, but there are some things +that need a personal touch. For those things, we rely on contributions from both the core +team and the community. The current primary maintainer of the website is Farrak Kilhn. +He may be found on Discord as `Farrak Kilhn#0418`, or on GitHub as `LegendaryLinux`. + +### Small Changes +Little changes like adding a button or a couple new select elements are perfectly fine. +Tweaks to style specific to a PR's content are also probably not a problem. For example, if +you build a new page which needs two side by side tables, and you need to write a CSS file +specific to your page, that is perfectly reasonable. + +### Content Additions +Once you develop a new feature or add new content the website, make a pull request. It will +be reviewed by the community and there will probably be some discussion around it. Depending +on the size of the feature, and if new styles are required, there may be an additional step +before the PR is accepted wherein Farrak works with the designer to implement styles. + +### Restrictions on Style Changes +A professional designer is paid to develop the styles and assets for the Archipelago website. +In an effort to maintain a consistent look and feel, pull requests which *exclusively* +change site styles are rejected. Please note this applies to code which changes the overall +look and feel of the site, not to small tweaks to CSS for your custom page. The intention +behind these restrictions is to maintain a curated feel for the design of the site. If +any PR affects the overall feel of the site but includes additive changes, there will +likely be a conversation about how to implement those changes without compromising the +curated site style. It is therefore worth noting there are a couple files which, if +changed in your pull request, will cause it to draw additional scrutiny. + +These closely guarded files are: +- `globalStyles.css` +- `islandFooter.css` +- `landing.css` +- `markdown.css` +- `tooltip.css` + +### Site Themes +There are several themes available for game pages. It is possible to request a new theme in +the `#art-and-design` channel on Discord. Because themes are created by the designer, they +are not free, and take some time to create. Farrak works closely with the designer to implement +these themes, and pays for the assets out of pocket. Therefore, only a couple themes per year +are added. If a proposed theme seems like a cool idea and the community likes it, there is a +good chance it will become a reality. diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 01f1fd25e5..6272633f4e 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -1,15 +1,16 @@ from __future__ import annotations -import functools -import websockets import asyncio +import collections +import datetime +import functools +import logging +import pickle +import random import socket import threading import time -import random -import pickle -import logging -import datetime +import websockets import Utils from .models import db_session, Room, select, commit, Command, db @@ -49,6 +50,8 @@ class DBCommandProcessor(ServerCommandProcessor): class WebHostContext(Context): + room_id: int + def __init__(self, static_server_data: dict): # static server data is used during _load_game_data to load required data, # without needing to import worlds system, which takes quite a bit of memory @@ -62,6 +65,8 @@ class WebHostContext(Context): def _load_game_data(self): for key, value in self.static_server_data.items(): setattr(self, key, value) + self.forced_auto_forfeits = collections.defaultdict(lambda: False, self.forced_auto_forfeits) + self.non_hintable_names = collections.defaultdict(frozenset, self.non_hintable_names) def listen_to_db_commands(self): cmdprocessor = DBCommandProcessor(self) @@ -103,7 +108,7 @@ class WebHostContext(Context): room.multisave = pickle.dumps(self.get_save()) # saving only occurs on activity, so we can "abuse" this information to mark this as last_activity if not exit_save: # we don't want to count a shutdown as activity, which would restart the server again - room.last_activity = datetime.utcnow() + room.last_activity = datetime.datetime.utcnow() return True def get_save(self) -> dict: diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index 528cbe5ec0..c3a373c2e9 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -32,9 +32,12 @@ def download_patch(room_id, patch_id): new_zip.writestr("archipelago.json", json.dumps(manifest)) else: new_zip.writestr(file.filename, zf.read(file), file.compress_type, 9) - + if "patch_file_ending" in manifest: + patch_file_ending = manifest["patch_file_ending"] + else: + patch_file_ending = AutoPatchRegister.patch_types[patch.game].patch_file_ending fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}" \ - f"{AutoPatchRegister.patch_types[patch.game].patch_file_ending}" + f"{patch_file_ending}" new_file.seek(0) return send_file(new_file, as_attachment=True, download_name=fname) else: diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 3c481be62b..daa742d90e 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -1,6 +1,6 @@ import logging import os -from Utils import __version__ +from Utils import __version__, local_path from jinja2 import Template import yaml import json @@ -9,14 +9,13 @@ import typing from worlds.AutoWorld import AutoWorldRegister import Options -target_folder = os.path.join("WebHostLib", "static", "generated") - handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", "exclude_locations"} def create(): - os.makedirs(os.path.join(target_folder, 'configs'), exist_ok=True) + target_folder = local_path("WebHostLib", "static", "generated") + os.makedirs(os.path.join(target_folder, "configs"), exist_ok=True) def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): data = {} @@ -49,6 +48,11 @@ def create(): return list(default_value) return default_value + def get_html_doc(option_type: type(Options.Option)) -> str: + if not option_type.__doc__: + return "Please document me!" + return "\n".join(line.strip() for line in option_type.__doc__.split("\n")).strip() + weighted_settings = { "baseOptions": { "description": "Generated by https://archipelago.gg/", @@ -61,12 +65,16 @@ def create(): for game_name, world in AutoWorldRegister.world_types.items(): all_options = {**Options.per_game_common_options, **world.option_definitions} - res = Template(open(os.path.join("WebHostLib", "templates", "options.yaml")).read()).render( + with open(local_path("WebHostLib", "templates", "options.yaml")) as f: + file_data = f.read() + res = Template(file_data).render( options=all_options, __version__=__version__, game=game_name, yaml_dump=yaml.dump, dictify_range=dictify_range, default_converter=default_converter, ) + del file_data + with open(os.path.join(target_folder, 'configs', game_name + ".yaml"), "w") as f: f.write(res) @@ -88,7 +96,7 @@ def create(): game_options[option_name] = this_option = { "type": "select", "displayName": option.display_name if hasattr(option, "display_name") else option_name, - "description": option.__doc__ if option.__doc__ else "Please document me!", + "description": get_html_doc(option), "defaultValue": None, "options": [] } @@ -114,7 +122,7 @@ def create(): game_options[option_name] = { "type": "range", "displayName": option.display_name if hasattr(option, "display_name") else option_name, - "description": option.__doc__ if option.__doc__ else "Please document me!", + "description": get_html_doc(option), "defaultValue": option.default if hasattr( option, "default") and option.default != "random" else option.range_start, "min": option.range_start, @@ -131,14 +139,14 @@ def create(): game_options[option_name] = { "type": "items-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, - "description": option.__doc__ if option.__doc__ else "Please document me!", + "description": get_html_doc(option), } elif getattr(option, "verify_location_name", False): game_options[option_name] = { "type": "locations-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, - "description": option.__doc__ if option.__doc__ else "Please document me!", + "description": get_html_doc(option), } elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet): @@ -146,7 +154,7 @@ def create(): game_options[option_name] = { "type": "custom-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, - "description": option.__doc__ if option.__doc__ else "Please document me!", + "description": get_html_doc(option), "options": list(option.valid_keys), } diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index 52d0316b2a..a4dd710e83 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,7 +1,7 @@ -flask>=2.1.3 +flask>=2.2.2 pony>=0.7.16 -waitress>=2.1.1 +waitress>=2.1.2 Flask-Caching>=2.0.1 Flask-Compress>=1.12 -Flask-Limiter>=2.5.0 +Flask-Limiter>=2.6.2 bokeh>=2.4.3 diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js index 21c6414df7..b77d4e877b 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-settings.js @@ -102,9 +102,15 @@ const buildOptionsTable = (settings, romOpts = false) => { // td Left const tdl = document.createElement('td'); const label = document.createElement('label'); + label.textContent = `${settings[setting].displayName}: `; label.setAttribute('for', setting); - label.setAttribute('data-tooltip', settings[setting].description); - label.innerText = `${settings[setting].displayName}:`; + + const questionSpan = document.createElement('span'); + questionSpan.classList.add('interactive'); + questionSpan.setAttribute('data-tooltip', settings[setting].description); + questionSpan.innerText = '(?)'; + + label.appendChild(questionSpan); tdl.appendChild(label); tr.appendChild(tdl); diff --git a/WebHostLib/static/styles/generate.css b/WebHostLib/static/styles/generate.css index 066fb8a7c5..478d444d40 100644 --- a/WebHostLib/static/styles/generate.css +++ b/WebHostLib/static/styles/generate.css @@ -56,7 +56,3 @@ #file-input{ display: none; } - -.interactive{ - color: #ffef00; -} diff --git a/WebHostLib/static/styles/globalStyles.css b/WebHostLib/static/styles/globalStyles.css index c20bab6b14..d8b10d1c50 100644 --- a/WebHostLib/static/styles/globalStyles.css +++ b/WebHostLib/static/styles/globalStyles.css @@ -105,3 +105,7 @@ h5, h6{ margin-bottom: 20px; background-color: #ffff00; } + +.interactive{ + color: #ffef00; +} \ No newline at end of file diff --git a/WebHostLib/static/styles/tooltip.css b/WebHostLib/static/styles/tooltip.css index 0c5c0c6969..7cd8463f64 100644 --- a/WebHostLib/static/styles/tooltip.css +++ b/WebHostLib/static/styles/tooltip.css @@ -14,7 +14,6 @@ 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 { position: relative; - cursor: pointer; } /* Base styles for the entire tooltip */ @@ -55,14 +54,15 @@ give it one of the following classes: tooltip-left, tooltip-right, tooltip-top, /** Content styles */ .tooltip:after, [data-tooltip]:after { + width: 260px; z-index: 10000; padding: 8px; - width: 160px; border-radius: 4px; background-color: #000; 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; } diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index 916ed72b8d..aa16a47d35 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -41,12 +41,11 @@