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 6a0231c22b..7a014e51bb 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,7 @@ README.html .vs/ EnemizerCLI/ /Players/ +/SNI/ /options.yaml /config.yaml /logs/ diff --git a/CommonClient.py b/CommonClient.py index f830035425..574da16f2a 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -152,8 +152,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 +185,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() @@ -345,6 +347,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 +636,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/Generate.py b/Generate.py index 1cad836345..d13a78b375 100644 --- a/Generate.py +++ b/Generate.py @@ -63,7 +63,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 +84,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 +178,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()} 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..acff74595a 100644 --- a/Main.py +++ b/Main.py @@ -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. diff --git a/MultiServer.py b/MultiServer.py index 8a1844bf92..fc6e17dd20 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 @@ -291,20 +292,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 +593,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,19 +730,33 @@ 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} diff --git a/Options.py b/Options.py index a4f559a532..7eb108c99d 100644 --- a/Options.py +++ b/Options.py @@ -298,7 +298,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): 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..ccac3998a1 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -149,8 +149,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 +158,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 +262,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 +293,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 +1086,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 +1165,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 +1178,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 +1205,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 +1222,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 +1233,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 +1271,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..ce4d9b046c 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -1,31 +1,31 @@ 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 MultiServer import mark_raw +from Utils import init_logging, is_windows +from worlds.sc2wol import SC2WoLWorld 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 -from MultiServer import mark_raw -import ctypes -import sys - -from Utils import init_logging, is_windows +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 +35,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 +100,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 +127,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]] = {} + raw_text_parser: RawJSONtoTextParser + + 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 +152,32 @@ 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): + if "receiving" in args and self.slot_concerns_self(args["receiving"]): + relevant = True + elif "item" in args and self.slot_concerns_self(args["item"].player): + relevant = True + 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 +195,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 +216,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 +238,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 +266,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 +277,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,7 +288,8 @@ 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]: @@ -295,7 +301,9 @@ class SC2Context(CommonContext): text = f"[color=6495ED]{text}[/color]" tooltip = f"Uncollected locations:\n" - tooltip += "\n".join(location for location in unfinished_locations[mission]) + tooltip += "\n".join([self.ctx.location_names[loc] for loc in + self.ctx.locations_for_mission(mission) + if loc in self.ctx.missing_locations]) elif mission in available_missions: text = f"[color=FFFFFF]{text}[/color]" # Map requirements not met @@ -303,7 +311,7 @@ class SC2Context(CommonContext): 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 + tooltip += ", ".join(list(self.ctx.mission_req_table)[req_mission - 1] for req_mission in self.ctx.mission_req_table[mission].required_world) @@ -325,13 +333,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): @@ -347,9 +358,9 @@ class SC2Context(CommonContext): 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 +369,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() @@ -459,11 +487,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 +496,29 @@ 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 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.mission_id = mission_id + self.boni = [False for _ in range(max_bonus)] super(ArchipelagoBot, self).__init__() async def on_step(self, iteration: int): 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 +532,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 +563,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 +666,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 +674,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 @@ -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..da7b54ba6d 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -103,7 +103,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 @@