diff --git a/.github/workflows/analyze-modified-files.yml b/.github/workflows/analyze-modified-files.yml new file mode 100644 index 000000000000..ba2660809aaa --- /dev/null +++ b/.github/workflows/analyze-modified-files.yml @@ -0,0 +1,80 @@ +name: Analyze modified files + +on: + pull_request: + paths: + - "**.py" + push: + paths: + - "**.py" + +env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + BEFORE: ${{ github.event.before }} + AFTER: ${{ github.event.after }} + +jobs: + flake8-or-mypy: + strategy: + fail-fast: false + matrix: + task: [flake8, mypy] + + name: ${{ matrix.task }} + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: "Determine modified files (pull_request)" + if: github.event_name == 'pull_request' + run: | + git fetch origin $BASE $HEAD + DIFF=$(git diff --diff-filter=d --name-only $BASE...$HEAD -- "*.py") + echo "modified files:" + echo "$DIFF" + echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV + + - name: "Determine modified files (push)" + if: github.event_name == 'push' && github.event.before != '0000000000000000000000000000000000000000' + run: | + git fetch origin $BEFORE $AFTER + DIFF=$(git diff --diff-filter=d --name-only $BEFORE..$AFTER -- "*.py") + echo "modified files:" + echo "$DIFF" + echo "diff=${DIFF//$'\n'/$' '}" >> $GITHUB_ENV + + - name: "Treat all files as modified (new branch)" + if: github.event_name == 'push' && github.event.before == '0000000000000000000000000000000000000000' + run: | + echo "diff=." >> $GITHUB_ENV + + - uses: actions/setup-python@v4 + if: env.diff != '' + with: + python-version: 3.8 + + - name: "Install dependencies" + if: env.diff != '' + run: | + python -m pip install --upgrade pip ${{ matrix.task }} + python ModuleUpdate.py --append "WebHostLib/requirements.txt" --force --yes + + - name: "flake8: Stop the build if there are Python syntax errors or undefined names" + continue-on-error: false + if: env.diff != '' && matrix.task == 'flake8' + run: | + flake8 --count --select=E9,F63,F7,F82 --show-source --statistics ${{ env.diff }} + + - name: "flake8: Lint modified files" + continue-on-error: true + if: env.diff != '' && matrix.task == 'flake8' + run: | + flake8 --count --max-complexity=10 --max-doc-length=120 --max-line-length=120 --statistics ${{ env.diff }} + + - name: "mypy: Type check modified files" + continue-on-error: true + if: env.diff != '' && matrix.task == 'mypy' + run: | + mypy --follow-imports=silent --install-types --non-interactive --strict ${{ env.diff }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d4e1efd466aa..a40084b9ab72 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -36,15 +36,15 @@ jobs: Expand-Archive -Path enemizer.zip -DestinationPath EnemizerCLI -Force - name: Build run: | - python -m pip install --upgrade pip setuptools - pip install -r requirements.txt + python -m pip install --upgrade pip python setup.py build_exe --yes - $NAME="$(ls build)".Split('.',2)[1] + $NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1] $ZIP_NAME="Archipelago_$NAME.7z" + echo "$NAME -> $ZIP_NAME" echo "ZIP_NAME=$ZIP_NAME" >> $Env:GITHUB_ENV New-Item -Path dist -ItemType Directory -Force cd build - Rename-Item exe.$NAME Archipelago + Rename-Item "exe.$NAME" Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago - name: Store 7z uses: actions/upload-artifact@v3 @@ -53,8 +53,8 @@ jobs: path: dist/${{ env.ZIP_NAME }} retention-days: 7 # keep for 7 days, should be enough - build-ubuntu1804: - runs-on: ubuntu-18.04 + build-ubuntu2004: + runs-on: ubuntu-20.04 steps: # - copy code below to release.yml - - uses: actions/checkout@v3 @@ -66,10 +66,10 @@ jobs: - name: Get a recent python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.11' - name: Install build-time dependencies run: | - echo "PYTHON=python3.9" >> $GITHUB_ENV + echo "PYTHON=python3.11" >> $GITHUB_ENV 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 @@ -85,8 +85,7 @@ jobs: # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate - "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer - pip install -r requirements.txt + "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" @@ -96,6 +95,10 @@ jobs: echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV # - copy code above to release.yml - + - name: Build Again + run: | + source venv/bin/activate + python setup.py build_exe --yes - name: Store AppImage uses: actions/upload-artifact@v3 with: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml deleted file mode 100644 index 7ecda45edad4..000000000000 --- a/.github/workflows/lint.yml +++ /dev/null @@ -1,35 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions - -name: lint - -on: - push: - paths: - - '**.py' - pull_request: - paths: - - '**.py' - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Set up Python 3.9 - uses: actions/setup-python@v4 - with: - python-version: 3.9 - - name: Install dependencies - run: | - 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: | - # stop the build if there are Python syntax errors or undefined names - flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics - # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide - flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fa3dd3210014..cc68a88b7651 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,8 +29,8 @@ jobs: # build-release-windows: # this is done by hand because of signing # build-release-macos: # LF volunteer - build-release-ubuntu1804: - runs-on: ubuntu-18.04 + build-release-ubuntu2004: + runs-on: ubuntu-20.04 steps: - name: Set env run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV @@ -44,10 +44,10 @@ jobs: - name: Get a recent python uses: actions/setup-python@v4 with: - python-version: '3.9' + python-version: '3.11' - name: Install build-time dependencies run: | - echo "PYTHON=python3.9" >> $GITHUB_ENV + echo "PYTHON=python3.11" >> $GITHUB_ENV 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 @@ -63,9 +63,8 @@ jobs: # charset-normalizer was somehow incomplete in the github runner "${{ env.PYTHON }}" -m venv venv source venv/bin/activate - "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject setuptools charset-normalizer - pip install -r requirements.txt - python setup.py build --yes bdist_appimage --yes + "${{ env.PYTHON }}" -m pip install --upgrade pip PyGObject charset-normalizer + python setup.py build_exe --yes bdist_appimage --yes echo -e "setup.py build output:\n `ls build`" echo -e "setup.py dist output:\n `ls dist`" cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd .. diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 93be745a8c29..d24c55b49ac2 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -36,12 +36,13 @@ jobs: - {version: '3.8'} - {version: '3.9'} - {version: '3.10'} + - {version: '3.11'} include: - python: {version: '3.8'} # win7 compat os: windows-latest - - python: {version: '3.10'} # current + - python: {version: '3.11'} # current os: windows-latest - - python: {version: '3.10'} # current + - python: {version: '3.11'} # current os: macos-latest steps: @@ -52,9 +53,10 @@ jobs: python-version: ${{ matrix.python.version }} - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install flake8 pytest pytest-subtests + python -m pip install --upgrade pip + pip install pytest pytest-subtests python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + python Launcher.py --update_settings # make sure host.yaml exists for tests - name: Unittests run: | pytest diff --git a/.gitignore b/.gitignore index 4a9f3402a964..8e4cc86657a5 100644 --- a/.gitignore +++ b/.gitignore @@ -26,7 +26,9 @@ *multisave *.archipelago *.apsave +*.BIN +setups build bundle/components.wxs dist @@ -35,6 +37,7 @@ README.html EnemizerCLI/ /Players/ /SNI/ +/host.yaml /options.yaml /config.yaml /logs/ @@ -52,6 +55,7 @@ Output Logs/ /setup.ini /installdelete.iss /data/user.kv +/datapackage # Byte-compiled / optimized / DLL files __pycache__/ @@ -165,6 +169,10 @@ dmypy.json # Cython debug symbols cython_debug/ +# Cython intermediates +_speedups.cpp +_speedups.html + # minecraft server stuff jdk*/ minecraft*/ @@ -174,6 +182,9 @@ minecraft_versions.json # pyenv .python-version +#undertale stuff +/Undertale/ + # OS General Files .DS_Store .AppleDouble diff --git a/AdventureClient.py b/AdventureClient.py new file mode 100644 index 000000000000..d2f4e734ac2c --- /dev/null +++ b/AdventureClient.py @@ -0,0 +1,516 @@ +import asyncio +import hashlib +import json +import time +import os +import bsdiff4 +import subprocess +import zipfile +from asyncio import StreamReader, StreamWriter, CancelledError +from typing import List + + +import Utils +from NetUtils import ClientStatus +from Utils import async_start +from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandProcessor, logger, \ + get_base_parser +from worlds.adventure import AdventureDeltaPatch + +from worlds.adventure.Locations import base_location_id +from worlds.adventure.Rom import AdventureForeignItemInfo, AdventureAutoCollectLocation, BatNoTouchLocation +from worlds.adventure.Items import base_adventure_item_id, standard_item_max, item_table +from worlds.adventure.Offsets import static_item_element_size, connector_port_offset + +SYSTEM_MESSAGE_ID = 0 + +CONNECTION_TIMING_OUT_STATUS = \ + "Connection timing out. Please restart your emulator, then restart connector_adventure.lua" +CONNECTION_REFUSED_STATUS = \ + "Connection Refused. Please start your emulator and make sure connector_adventure.lua is running" +CONNECTION_RESET_STATUS = \ + "Connection was reset. Please restart your emulator, then restart connector_adventure.lua" +CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" +CONNECTION_CONNECTED_STATUS = "Connected" +CONNECTION_INITIAL_STATUS = "Connection has not been initiated" + +SCRIPT_VERSION = 1 + + +class AdventureCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx: CommonContext): + super().__init__(ctx) + + def _cmd_2600(self): + """Check 2600 Connection State""" + if isinstance(self.ctx, AdventureContext): + logger.info(f"2600 Status: {self.ctx.atari_status}") + + def _cmd_aconnect(self): + """Discard current atari 2600 connection state""" + if isinstance(self.ctx, AdventureContext): + self.ctx.atari_sync_task.cancel() + + +class AdventureContext(CommonContext): + command_processor = AdventureCommandProcessor + game = 'Adventure' + lua_connector_port: int = 17242 + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.freeincarnates_used: int = -1 + self.freeincarnate_pending: int = 0 + self.foreign_items: [AdventureForeignItemInfo] = [] + self.autocollect_items: [AdventureAutoCollectLocation] = [] + self.atari_streams: (StreamReader, StreamWriter) = None + self.atari_sync_task = None + self.messages = {} + self.locations_array = None + self.atari_status = CONNECTION_INITIAL_STATUS + self.awaiting_rom = False + self.display_msgs = True + self.deathlink_pending = False + self.set_deathlink = False + self.client_compatibility_mode = 0 + self.items_handling = 0b111 + self.checked_locations_sent: bool = False + self.port_offset = 0 + self.bat_no_touch_locations: [BatNoTouchLocation] = [] + self.local_item_locations = {} + self.dragon_speed_info = {} + + options = Utils.get_options() + self.display_msgs = options["adventure_options"]["display_msgs"] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(AdventureContext, self).server_auth(password_requested) + if not self.auth: + self.auth = self.player_name + if not self.auth: + self.awaiting_rom = True + logger.info('Awaiting connection to adventure_connector to get Player information') + return + + await self.send_connect() + + def _set_message(self, msg: str, msg_id: int): + if self.display_msgs: + self.messages[(time.time(), msg_id)] = msg + + def on_package(self, cmd: str, args: dict): + if cmd == 'Connected': + self.locations_array = None + if Utils.get_options()["adventure_options"].get("death_link", False): + self.set_deathlink = True + async_start(self.get_freeincarnates_used()) + elif cmd == "RoomInfo": + self.seed_name = args['seed_name'] + elif cmd == 'Print': + 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 == "Retrieved": + self.freeincarnates_used = args["keys"][f"adventure_{self.auth}_freeincarnates_used"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() + elif cmd == "SetReply": + if args["key"] == f"adventure_{self.auth}_freeincarnates_used": + self.freeincarnates_used = args["value"] + if self.freeincarnates_used is None: + self.freeincarnates_used = 0 + self.freeincarnates_used += self.freeincarnate_pending + self.send_pending_freeincarnates() + + def on_deathlink(self, data: dict): + self.deathlink_pending = True + super().on_deathlink(data) + + def run_gui(self): + from kvui import GameManager + + class AdventureManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago Adventure Client" + + self.ui = AdventureManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + async def get_freeincarnates_used(self): + if self.server and not self.server.socket.closed: + await self.send_msgs([{"cmd": "SetNotify", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}]) + await self.send_msgs([{"cmd": "Get", "keys": [f"adventure_{self.auth}_freeincarnates_used"]}]) + + def send_pending_freeincarnates(self): + if self.freeincarnate_pending > 0: + async_start(self.send_pending_freeincarnates_impl(self.freeincarnate_pending)) + self.freeincarnate_pending = 0 + + async def send_pending_freeincarnates_impl(self, send_val: int) -> None: + await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used", + "default": 0, "want_reply": False, + "operations": [{"operation": "add", "value": send_val}]}]) + + async def used_freeincarnate(self) -> None: + if self.server and not self.server.socket.closed: + await self.send_msgs([{"cmd": "Set", "key": f"adventure_{self.auth}_freeincarnates_used", + "default": 0, "want_reply": True, + "operations": [{"operation": "add", "value": 1}]}]) + else: + self.freeincarnate_pending = self.freeincarnate_pending + 1 + + +def convert_item_id(ap_item_id: int): + static_item_index = ap_item_id - base_adventure_item_id + return static_item_index * static_item_element_size + + +def get_payload(ctx: AdventureContext): + current_time = time.time() + items = [] + dragon_speed_update = {} + diff_a_locked = ctx.diff_a_mode > 0 + diff_b_locked = ctx.diff_b_mode > 0 + freeincarnate_count = 0 + for item in ctx.items_received: + item_id_str = str(item.item) + if base_adventure_item_id < item.item <= standard_item_max: + items.append(convert_item_id(item.item)) + elif item_id_str in ctx.dragon_speed_info: + if item.item in dragon_speed_update: + last_index = len(ctx.dragon_speed_info[item_id_str]) - 1 + dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][last_index] + else: + dragon_speed_update[item.item] = ctx.dragon_speed_info[item_id_str][0] + elif item.item == item_table["Left Difficulty Switch"].id: + diff_a_locked = False + elif item.item == item_table["Right Difficulty Switch"].id: + diff_b_locked = False + elif item.item == item_table["Freeincarnate"].id: + freeincarnate_count = freeincarnate_count + 1 + freeincarnates_available = 0 + + if ctx.freeincarnates_used >= 0: + freeincarnates_available = freeincarnate_count - (ctx.freeincarnates_used + ctx.freeincarnate_pending) + ret = json.dumps( + { + "items": items, + "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() + if key[0] > current_time - 10}, + "deathlink": ctx.deathlink_pending, + "dragon_speeds": dragon_speed_update, + "difficulty_a_locked": diff_a_locked, + "difficulty_b_locked": diff_b_locked, + "freeincarnates_available": freeincarnates_available, + "bat_logic": ctx.bat_logic + } + ) + ctx.deathlink_pending = False + return ret + + +async def parse_locations(data: List, ctx: AdventureContext): + locations = data + + # for loc_name, loc_data in location_table.items(): + + # if flags["EventFlag"][280] & 1 and not ctx.finished_game: + # await ctx.send_msgs([ + # {"cmd": "StatusUpdate", + # "status": 30} + # ]) + # ctx.finished_game = True + if locations == ctx.locations_array: + return + ctx.locations_array = locations + if locations is not None: + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": locations}]) + + +def send_ap_foreign_items(adventure_context): + foreign_item_json_list = [] + autocollect_item_json_list = [] + bat_no_touch_locations_json_list = [] + for fi in adventure_context.foreign_items: + foreign_item_json_list.append(fi.get_dict()) + for fi in adventure_context.autocollect_items: + autocollect_item_json_list.append(fi.get_dict()) + for ntl in adventure_context.bat_no_touch_locations: + bat_no_touch_locations_json_list.append(ntl.get_dict()) + payload = json.dumps( + { + "foreign_items": foreign_item_json_list, + "autocollect_items": autocollect_item_json_list, + "local_item_locations": adventure_context.local_item_locations, + "bat_no_touch_locations": bat_no_touch_locations_json_list + } + ) + print("sending foreign items") + msg = payload.encode() + (reader, writer) = adventure_context.atari_streams + writer.write(msg) + writer.write(b'\n') + + +def send_checked_locations_if_needed(adventure_context): + if not adventure_context.checked_locations_sent and adventure_context.checked_locations is not None: + if len(adventure_context.checked_locations) == 0: + return + checked_short_ids = [] + for location in adventure_context.checked_locations: + checked_short_ids.append(location - base_location_id) + print("Sending checked locations") + payload = json.dumps( + { + "checked_locations": checked_short_ids, + } + ) + msg = payload.encode() + (reader, writer) = adventure_context.atari_streams + writer.write(msg) + writer.write(b'\n') + adventure_context.checked_locations_sent = True + + +async def atari_sync_task(ctx: AdventureContext): + logger.info("Starting Atari 2600 connector. Use /2600 for status information") + while not ctx.exit_event.is_set(): + try: + error_status = None + if ctx.atari_streams: + (reader, writer) = ctx.atari_streams + msg = get_payload(ctx).encode() + writer.write(msg) + writer.write(b'\n') + try: + await asyncio.wait_for(writer.drain(), timeout=1.5) + try: + # Data will return a dict with 1+ fields + # 1. A keepalive response of the Players Name (always) + # 2. romhash field with sha256 hash of the ROM memory region + # 3. locations, messages, and deathLink + # 4. freeincarnate, to indicate a freeincarnate was used + data = await asyncio.wait_for(reader.readline(), timeout=5) + data_decoded = json.loads(data.decode()) + if 'scriptVersion' not in data_decoded or data_decoded['scriptVersion'] != SCRIPT_VERSION: + msg = "You are connecting with an incompatible Lua script version. Ensure your connector " \ + "Lua and AdventureClient are from the same Archipelago installation." + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + error_status = CONNECTION_RESET_STATUS + if ctx.seed_name and bytes(ctx.seed_name, encoding='ASCII') != ctx.seed_name_from_data: + msg = "The server is running a different multiworld than your client is. " \ + "(invalid seed_name)" + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + error_status = CONNECTION_RESET_STATUS + if 'romhash' in data_decoded: + if ctx.rom_hash.upper() != data_decoded['romhash'].upper(): + msg = "The rom hash does not match the client rom hash data" + print("got " + data_decoded['romhash']) + print("expected " + str(ctx.rom_hash)) + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + error_status = CONNECTION_RESET_STATUS + if ctx.auth is None: + ctx.auth = ctx.player_name + if ctx.awaiting_rom: + await ctx.server_auth(False) + if 'locations' in data_decoded and ctx.game and ctx.atari_status == CONNECTION_CONNECTED_STATUS \ + and not error_status and ctx.auth: + # Not just a keep alive ping, parse + async_start(parse_locations(data_decoded['locations'], ctx)) + if 'deathLink' in data_decoded and data_decoded['deathLink'] > 0 and 'DeathLink' in ctx.tags: + dragon_name = "a dragon" + if data_decoded['deathLink'] == 1: + dragon_name = "Rhindle" + elif data_decoded['deathLink'] == 2: + dragon_name = "Yorgle" + elif data_decoded['deathLink'] == 3: + dragon_name = "Grundle" + print (ctx.auth + " has been eaten by " + dragon_name ) + await ctx.send_death(ctx.auth + " has been eaten by " + dragon_name) + # TODO - also if player reincarnates with a dragon onscreen ' dies to avoid being eaten by ' + if 'victory' in data_decoded and not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + if 'freeincarnate' in data_decoded: + await ctx.used_freeincarnate() + if ctx.set_deathlink: + await ctx.update_death_link(True) + send_checked_locations_if_needed(ctx) + except asyncio.TimeoutError: + logger.debug("Read Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.atari_streams = None + except ConnectionResetError as e: + logger.debug("Read failed due to Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.atari_streams = None + except TimeoutError: + logger.debug("Connection Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.atari_streams = None + except ConnectionResetError: + logger.debug("Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.atari_streams = None + except CancelledError: + logger.debug("Connection Cancelled, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.atari_streams = None + pass + except Exception as e: + print("unknown exception " + e) + raise + if ctx.atari_status == CONNECTION_TENTATIVE_STATUS: + if not error_status: + logger.info("Successfully Connected to 2600") + ctx.atari_status = CONNECTION_CONNECTED_STATUS + ctx.checked_locations_sent = False + send_ap_foreign_items(ctx) + send_checked_locations_if_needed(ctx) + else: + ctx.atari_status = f"Was tentatively connected but error occurred: {error_status}" + elif error_status: + ctx.atari_status = error_status + logger.info("Lost connection to 2600 and attempting to reconnect. Use /2600 for status updates") + else: + try: + port = ctx.lua_connector_port + ctx.port_offset + logger.debug(f"Attempting to connect to 2600 on port {port}") + print(f"Attempting to connect to 2600 on port {port}") + ctx.atari_streams = await asyncio.wait_for( + asyncio.open_connection("localhost", + port), + timeout=10) + ctx.atari_status = CONNECTION_TENTATIVE_STATUS + except TimeoutError: + logger.debug("Connection Timed Out, Trying Again") + ctx.atari_status = CONNECTION_TIMING_OUT_STATUS + continue + except ConnectionRefusedError: + logger.debug("Connection Refused, Trying Again") + ctx.atari_status = CONNECTION_REFUSED_STATUS + continue + except CancelledError: + pass + except CancelledError: + pass + print("exiting atari sync task") + + +async def run_game(romfile): + auto_start = Utils.get_options()["adventure_options"].get("rom_start", True) + rom_args = Utils.get_options()["adventure_options"].get("rom_args") + if auto_start is True: + import webbrowser + webbrowser.open(romfile) + elif os.path.isfile(auto_start): + open_args = [auto_start, romfile] + if rom_args is not None: + open_args.insert(1, rom_args) + subprocess.Popen(open_args, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +async def patch_and_run_game(patch_file, ctx): + base_name = os.path.splitext(patch_file)[0] + comp_path = base_name + '.a26' + try: + base_rom = AdventureDeltaPatch.get_source_data() + except Exception as msg: + logger.info(msg, extra={'compact_gui': True}) + ctx.gui_error('Error', msg) + + with open(Utils.local_path("data", "adventure_basepatch.bsdiff4"), "rb") as file: + basepatch = bytes(file.read()) + + base_patched_rom_data = bsdiff4.patch(base_rom, basepatch) + + with zipfile.ZipFile(patch_file, 'r') as patch_archive: + if not AdventureDeltaPatch.check_version(patch_archive): + logger.error("apadvn version doesn't match this client. Make sure your generator and client are the same") + raise Exception("apadvn version doesn't match this client.") + + ctx.foreign_items = AdventureDeltaPatch.read_foreign_items(patch_archive) + ctx.autocollect_items = AdventureDeltaPatch.read_autocollect_items(patch_archive) + ctx.local_item_locations = AdventureDeltaPatch.read_local_item_locations(patch_archive) + ctx.dragon_speed_info = AdventureDeltaPatch.read_dragon_speed_info(patch_archive) + ctx.seed_name_from_data, ctx.player_name = AdventureDeltaPatch.read_rom_info(patch_archive) + ctx.diff_a_mode, ctx.diff_b_mode = AdventureDeltaPatch.read_difficulty_switch_info(patch_archive) + ctx.bat_logic = AdventureDeltaPatch.read_bat_logic(patch_archive) + ctx.bat_no_touch_locations = AdventureDeltaPatch.read_bat_no_touch(patch_archive) + ctx.rom_deltas = AdventureDeltaPatch.read_rom_deltas(patch_archive) + ctx.auth = ctx.player_name + + patched_rom_data = AdventureDeltaPatch.apply_rom_deltas(base_patched_rom_data, ctx.rom_deltas) + rom_hash = hashlib.sha256() + rom_hash.update(patched_rom_data) + ctx.rom_hash = rom_hash.hexdigest() + ctx.port_offset = patched_rom_data[connector_port_offset] + + with open(comp_path, "wb") as patched_rom_file: + patched_rom_file.write(patched_rom_data) + + async_start(run_game(comp_path)) + + +if __name__ == '__main__': + + Utils.init_logging("AdventureClient") + + async def main(): + parser = get_base_parser() + parser.add_argument('patch_file', default="", type=str, nargs="?", + help='Path to an ADVNTURE.BIN rom file') + parser.add_argument('port', default=17242, type=int, nargs="?", + help='port for adventure_connector connection') + args = parser.parse_args() + + ctx = AdventureContext(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + ctx.atari_sync_task = asyncio.create_task(atari_sync_task(ctx), name="Adventure Sync") + + if args.patch_file: + ext = args.patch_file.split(".")[len(args.patch_file.split(".")) - 1].lower() + if ext == "apadvn": + logger.info("apadvn file supplied, beginning patching process...") + async_start(patch_and_run_game(args.patch_file, ctx)) + else: + logger.warning(f"Unknown patch file extension {ext}") + if args.port is int: + ctx.lua_connector_port = args.port + + await ctx.exit_event.wait() + ctx.server_address = None + + await ctx.shutdown() + + if ctx.atari_sync_task: + await ctx.atari_sync_task + print("finished atari_sync_task (main)") + + + import colorama + + colorama.init() + + asyncio.run(main()) + colorama.deinit() diff --git a/BaseClasses.py b/BaseClasses.py index 221675bfd43c..26cdfb528569 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -7,9 +7,10 @@ import secrets import typing # this can go away when Python 3.8 support is dropped from argparse import Namespace -from collections import OrderedDict, Counter, deque, ChainMap +from collections import ChainMap, Counter, deque from enum import IntEnum, IntFlag -from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple +from typing import Any, Callable, Dict, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, TypedDict, Union, \ + Type, ClassVar import NetUtils import Options @@ -28,15 +29,15 @@ class Group(TypedDict, total=False): link_replacement: bool -class ThreadBarrierProxy(): +class ThreadBarrierProxy: """Passes through getattr while passthrough is True""" - def __init__(self, obj: Any): + def __init__(self, obj: object) -> None: self.passthrough = True self.obj = obj - def __getattr__(self, item): + def __getattr__(self, name: str) -> Any: if self.passthrough: - return getattr(self.obj, item) + return getattr(self.obj, name) else: raise RuntimeError("You are in a threaded context and global random state was removed for your safety. " "Please use multiworld.per_slot_randoms[player] or randomize ahead of output.") @@ -81,6 +82,7 @@ class MultiWorld(): random: random.Random per_slot_randoms: Dict[int, random.Random] + """Deprecated. Please use `self.random` instead.""" class AttributeProxy(): def __init__(self, rule): @@ -96,7 +98,6 @@ def __init__(self, players: int): self.player_types = {player: NetUtils.SlotType.player for player in self.player_ids} self.glitch_triforce = False self.algorithm = 'balanced' - self.dungeons: Dict[Tuple[str, int], Dungeon] = {} self.groups = {} self.regions = [] self.shops = [] @@ -113,7 +114,6 @@ def __init__(self, players: int): self.dark_world_light_cone = False self.rupoor_cost = 10 self.aga_randomness = True - self.lock_aga_door_in_escape = False self.save_and_quit_from_boss = True self.custom = False self.customitemarray = [] @@ -122,6 +122,7 @@ def __init__(self, players: int): self.early_items = {player: {} for player in self.player_ids} self.local_early_items = {player: {} for player in self.player_ids} self.indirect_connections = {} + self.start_inventory_from_pool: Dict[int, Options.StartInventoryPool] = {} self.fix_trock_doors = self.AttributeProxy( lambda player: self.shuffle[player] != 'vanilla' or self.mode[player] == 'inverted') self.fix_skullwoods_exit = self.AttributeProxy( @@ -135,7 +136,6 @@ def __init__(self, players: int): def set_player_attr(attr, val): self.__dict__.setdefault(attr, {})[player] = val - set_player_attr('tech_tree_layout_prerequisites', {}) set_player_attr('_region_cache', {}) set_player_attr('shuffle', "vanilla") set_player_attr('logic', "noglitches") @@ -244,6 +244,7 @@ def set_options(self, args: Namespace) -> None: setattr(self, option_key, getattr(args, option_key, {})) self.worlds[player] = world_type(self, player) + self.worlds[player].random = self.per_slot_randoms[player] def set_item_links(self): item_links = {} @@ -336,7 +337,7 @@ def get_player_name(self, player: int) -> str: return self.player_name[player] def get_file_safe_player_name(self, player: int) -> str: - return ''.join(c for c in self.get_player_name(player) if c not in '<>:"/\\|?*') + return Utils.get_file_safe_name(self.get_player_name(player)) def get_out_file_name_base(self, player: int) -> str: """ the base name (without file extension) for each player's output file for a seed """ @@ -387,12 +388,6 @@ def get_location(self, location: str, player: int) -> Location: self._recache() return self._location_cache[location, player] - def get_dungeon(self, dungeonname: str, player: int) -> Dungeon: - try: - return self.dungeons[dungeonname, player] - except KeyError as e: - raise KeyError('No such dungeon %s for player %d' % (dungeonname, player)) from e - def get_all_state(self, use_cache: bool) -> CollectionState: cached = getattr(self, "_all_state", None) if use_cache and cached: @@ -445,7 +440,6 @@ def push_precollected(self, item: Item): self.state.collect(item, True) def push_item(self, location: Location, item: Item, collect: bool = True): - assert location.can_fill(self.state, item, False), f"Cannot place {item} into {location}." location.item = item item.location = location if collect: @@ -493,8 +487,10 @@ def get_placeable_locations(self, state=None, player=None) -> List[Location]: def get_unfilled_locations_for_players(self, location_names: List[str], players: Iterable[int]): for player in players: if not location_names: - location_names = [location.name for location in self.get_unfilled_locations(player)] - for location_name in location_names: + valid_locations = [location.name for location in self.get_unfilled_locations(player)] + else: + valid_locations = location_names + for location_name in valid_locations: location = self._location_cache.get((location_name, player), None) if location is not None and location.item is None: yield location @@ -742,9 +738,11 @@ def has(self, item: str, player: int, count: int = 1) -> bool: return self.prog_items[item, player] >= count def has_all(self, items: Set[str], player: int) -> bool: + """Returns True if each item name of items is in state at least once.""" return all(self.prog_items[item, player] for item in items) def has_any(self, items: Set[str], player: int) -> bool: + """Returns True if at least one item name of items is in state at least once.""" return any(self.prog_items[item, player] for item in items) def count(self, item: str, player: int) -> int: @@ -793,56 +791,6 @@ def remove(self, item: Item): self.stale[item.player] = True -class Region: - name: str - _hint_text: str - player: int - multiworld: Optional[MultiWorld] - entrances: List[Entrance] - exits: List[Entrance] - locations: List[Location] - dungeon: Optional[Dungeon] = None - - def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): - self.name = name - self.entrances = [] - self.exits = [] - self.locations = [] - self.multiworld = multiworld - self._hint_text = hint - self.player = player - - def can_reach(self, state: CollectionState) -> bool: - if state.stale[self.player]: - state.update_reachable_regions(self.player) - return self in state.reachable_regions[self.player] - - def can_reach_private(self, state: CollectionState) -> bool: - for entrance in self.entrances: - if entrance.can_reach(state): - if not self in state.path: - state.path[self] = (self.name, state.path.get(entrance, None)) - return True - return False - - @property - def hint_text(self) -> str: - return self._hint_text if self._hint_text else self.name - - 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__() - - def __str__(self): - return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' - - class Entrance: access_rule: Callable[[CollectionState], bool] = staticmethod(lambda state: True) hide_path: bool = False @@ -881,41 +829,92 @@ def __str__(self): return world.get_name_string_for_object(self) if world else f'{self.name} (Player {self.player})' -class Dungeon(object): - def __init__(self, name: str, regions: List[Region], big_key: Item, small_keys: List[Item], - dungeon_items: List[Item], player: int): +class Region: + name: str + _hint_text: str + player: int + multiworld: Optional[MultiWorld] + entrances: List[Entrance] + exits: List[Entrance] + locations: List[Location] + entrance_type: ClassVar[Type[Entrance]] = Entrance + + def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name - self.regions = regions - self.big_key = big_key - self.small_keys = small_keys - self.dungeon_items = dungeon_items - self.bosses = dict() + self.entrances = [] + self.exits = [] + self.locations = [] + self.multiworld = multiworld + self._hint_text = hint self.player = player - self.multiworld = None - - @property - def boss(self) -> Optional[Boss]: - return self.bosses.get(None, None) - @boss.setter - def boss(self, value: Optional[Boss]): - self.bosses[None] = value - - @property - def keys(self) -> List[Item]: - return self.small_keys + ([self.big_key] if self.big_key else []) + def can_reach(self, state: CollectionState) -> bool: + if state.stale[self.player]: + state.update_reachable_regions(self.player) + return self in state.reachable_regions[self.player] @property - def all_items(self) -> List[Item]: - return self.dungeon_items + self.keys + def hint_text(self) -> str: + return self._hint_text if self._hint_text else self.name - def is_dungeon_item(self, item: Item) -> bool: - return item.player == self.player and item.name in (dungeon_item.name for dungeon_item in self.all_items) + def get_connecting_entrance(self, is_main_entrance: 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 __eq__(self, other: Dungeon) -> bool: - if not other: - return False - return self.name == other.name and self.player == other.player + def add_locations(self, locations: Dict[str, Optional[int]], + location_type: Optional[Type[Location]] = None) -> None: + """ + Adds locations to the Region object, where location_type is your Location class and locations is a dict of + location names to address. + + :param locations: dictionary of locations to be created and added to this Region `{name: ID}` + :param location_type: Location class to be used to create the locations with""" + if location_type is None: + location_type = Location + for location, address in locations.items(): + self.locations.append(location_type(self.player, location, address, self)) + + def connect(self, connecting_region: Region, name: Optional[str] = None, + rule: Optional[Callable[[CollectionState], bool]] = None) -> None: + """ + Connects this Region to another Region, placing the provided rule on the connection. + + :param connecting_region: Region object to connect to path is `self -> exiting_region` + :param name: name of the connection being created + :param rule: callable to determine access of this connection to go from self to the exiting_region""" + exit_ = self.create_exit(name if name else f"{self.name} -> {connecting_region.name}") + if rule: + exit_.access_rule = rule + exit_.connect(connecting_region) + + def create_exit(self, name: str) -> Entrance: + """ + Creates and returns an Entrance object as an exit of this region. + + :param name: name of the Entrance being created + """ + exit_ = self.entrance_type(self.player, name, self) + self.exits.append(exit_) + return exit_ + + def add_exits(self, exits: Union[Iterable[str], Dict[str, Optional[str]]], + rules: Dict[str, Callable[[CollectionState], bool]] = None) -> None: + """ + Connects current region to regions in exit dictionary. Passed region names must exist first. + + :param exits: exits from the region. format is {"connecting_region": "exit_name"}. if a non dict is provided, + created entrances will be named "self.name -> connecting_region" + :param rules: rules for the exits from this region. format is {"connecting_region", rule} + """ + if not isinstance(exits, Dict): + exits = dict.fromkeys(exits) + for connecting_region, name in exits.items(): + self.connect(self.multiworld.get_region(connecting_region, self.player), + name, + rules[connecting_region] if rules and connecting_region in rules else None) def __repr__(self): return self.__str__() @@ -924,20 +923,6 @@ def __str__(self): return self.multiworld.get_name_string_for_object(self) if self.multiworld else f'{self.name} (Player {self.player})' -class Boss(): - def __init__(self, name: str, enemizer_name: str, defeat_rule: Callable, player: int): - self.name = name - self.enemizer_name = enemizer_name - self.defeat_rule = defeat_rule - self.player = player - - def can_defeat(self, state) -> bool: - return self.defeat_rule(state, self.player) - - def __repr__(self): - return f"Boss({self.name})" - - class LocationProgressType(IntEnum): DEFAULT = 1 PRIORITY = 2 @@ -1070,15 +1055,19 @@ def trap(self) -> bool: def flags(self) -> int: return self.classification.as_flag() - def __eq__(self, other): + def __eq__(self, other: object) -> bool: + if not isinstance(other, Item): + return NotImplemented return self.name == other.name and self.player == other.player - def __lt__(self, other: Item) -> bool: + def __lt__(self, other: object) -> bool: + if not isinstance(other, Item): + return NotImplemented if other.player != self.player: return other.player < self.player return self.name < other.name - def __hash__(self): + def __hash__(self) -> int: return hash((self.name, self.player)) def __repr__(self) -> str: @@ -1090,33 +1079,44 @@ def __str__(self) -> str: return f"{self.name} (Player {self.player})" -class Spoiler(): +class EntranceInfo(TypedDict, total=False): + player: int + entrance: str + exit: str + direction: str + + +class Spoiler: multiworld: MultiWorld + hashes: Dict[int, str] + entrances: Dict[Tuple[str, str, int], EntranceInfo] + playthrough: Dict[str, Union[List[str], Dict[str, str]]] # sphere "0" is list, others are dict unreachables: Set[Location] + paths: Dict[str, List[Union[Tuple[str, str], Tuple[str, None]]]] # last step takes no further exits - def __init__(self, world): - self.multiworld = world + def __init__(self, multiworld: MultiWorld) -> None: + self.multiworld = multiworld self.hashes = {} - self.entrances = OrderedDict() + self.entrances = {} self.playthrough = {} self.unreachables = set() self.paths = {} - def set_entrance(self, entrance: str, exit_: str, direction: str, player: int): + def set_entrance(self, entrance: str, exit_: str, direction: str, player: int) -> None: if self.multiworld.players == 1: - self.entrances[(entrance, direction, player)] = OrderedDict( - [('entrance', entrance), ('exit', exit_), ('direction', direction)]) + self.entrances[(entrance, direction, player)] = \ + {"entrance": entrance, "exit": exit_, "direction": direction} else: - self.entrances[(entrance, direction, player)] = OrderedDict( - [('player', player), ('entrance', entrance), ('exit', exit_), ('direction', direction)]) + self.entrances[(entrance, direction, player)] = \ + {"player": player, "entrance": entrance, "exit": exit_, "direction": direction} - def create_playthrough(self, create_paths: bool = True): + def create_playthrough(self, create_paths: bool = True) -> None: """Destructive to the world while it is run, damage gets repaired afterwards.""" from itertools import chain # get locations containing progress items multiworld = self.multiworld prog_locations = {location for location in multiworld.get_filled_locations() if location.item.advancement} - state_cache = [None] + state_cache: List[Optional[CollectionState]] = [None] collection_spheres: List[Set[Location]] = [] state = CollectionState(multiworld) sphere_candidates = set(prog_locations) @@ -1208,7 +1208,7 @@ def create_playthrough(self, create_paths: bool = True): raise RuntimeError(f'Not all required items reachable. Unreachable locations: {required_locations}') # we can finally output our playthrough - self.playthrough = {"0": sorted([str(item) for item in + self.playthrough = {"0": sorted([self.multiworld.get_name_string_for_object(item) for item in chain.from_iterable(multiworld.precollected_items.values()) if item.advancement])} @@ -1225,17 +1225,17 @@ def create_playthrough(self, create_paths: bool = True): for item in removed_precollected: multiworld.push_precollected(item) - def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]): + def create_paths(self, state: CollectionState, collection_spheres: List[Set[Location]]) -> None: from itertools import zip_longest multiworld = self.multiworld - def flist_to_iter(node): - while node: - value, node = node - yield value + def flist_to_iter(path_value: Optional[PathValue]) -> Iterator[str]: + while path_value: + region_or_entrance, path_value = path_value + yield region_or_entrance - def get_path(state, region): - reversed_path_as_flist = state.path.get(region, (region, None)) + def get_path(state: CollectionState, region: Region) -> List[Union[Tuple[str, str], Tuple[str, None]]]: + reversed_path_as_flist: PathValue = state.path.get(region, (str(region), None)) string_path_flat = reversed(list(map(str, flist_to_iter(reversed_path_as_flist)))) # Now we combine the flat string list into (region, exit) pairs pathsiter = iter(string_path_flat) @@ -1261,14 +1261,11 @@ def get_path(state, region): self.paths[str(multiworld.get_region('Inverted Big Bomb Shop', player))] = \ get_path(state, multiworld.get_region('Inverted Big Bomb Shop', player)) - def to_file(self, filename: str): - def write_option(option_key: str, option_obj: type(Options.Option)): + def to_file(self, filename: str) -> None: + def write_option(option_key: str, option_obj: Options.AssembleOptions) -> None: res = getattr(self.multiworld, option_key)[player] display_name = getattr(option_obj, "display_name", option_key) - try: - outfile.write(f'{display_name + ":":33}{res.current_option_name}\n') - except: - raise Exception + outfile.write(f"{display_name + ':':33}{res.current_option_name}\n") with open(filename, 'w', encoding="utf-8-sig") as outfile: outfile.write( @@ -1301,15 +1298,15 @@ def write_option(option_key: str, option_obj: type(Options.Option)): AutoWorld.call_all(self.multiworld, "write_spoiler", outfile) locations = [(str(location), str(location.item) if location.item is not None else "Nothing") - for location in self.multiworld.get_locations() if location.show_in_spoiler] + for location in self.multiworld.get_locations() if location.show_in_spoiler] outfile.write('\n\nLocations:\n\n') outfile.write('\n'.join( ['%s: %s' % (location, item) for location, item in locations])) outfile.write('\n\nPlaythrough:\n\n') outfile.write('\n'.join(['%s: {\n%s\n}' % (sphere_nr, '\n'.join( - [' %s: %s' % (location, item) for (location, item) in sphere.items()] if sphere_nr != '0' else [ - f' {item}' for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()])) + [f" {location}: {item}" for (location, item) in sphere.items()] if isinstance(sphere, dict) else + [f" {item}" for item in sphere])) for (sphere_nr, sphere) in self.playthrough.items()])) if self.unreachables: outfile.write('\n\nUnreachable Items:\n\n') outfile.write( @@ -1370,23 +1367,21 @@ def from_set(cls, option_set: Set[str]) -> PlandoOptions: @classmethod def _handle_part(cls, part: str, base: PlandoOptions) -> PlandoOptions: try: - part = cls[part] + return base | cls[part] except Exception as e: raise KeyError(f"{part} is not a recognized name for a plando module. " - f"Known options: {', '.join(flag.name for flag in cls)}") from e - else: - return base | part + f"Known options: {', '.join(str(flag.name) for flag in cls)}") from e def __str__(self) -> str: if self.value: - return ", ".join(flag.name for flag in PlandoOptions if self.value & flag.value) + return ", ".join(str(flag.name) for flag in PlandoOptions if self.value & flag.value) return "None" seeddigits = 20 -def get_seed(seed=None) -> int: +def get_seed(seed: Optional[int] = None) -> int: if seed is None: random.seed(None) return random.randint(0, pow(10, seeddigits) - 1) diff --git a/CommonClient.py b/CommonClient.py index 02dd55da9859..61fad6589793 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -23,6 +23,7 @@ from Utils import Version, stream_input, async_start from worlds import network_data_package, AutoWorldRegister import os +import ssl if typing.TYPE_CHECKING: import kvui @@ -33,6 +34,12 @@ gui_enabled = not sys.stdout or "--nogui" not in sys.argv +@Utils.cache_argsless +def get_ssl_context(): + import certifi + return ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=certifi.where()) + + class ClientCommandProcessor(CommandProcessor): def __init__(self, ctx: CommonContext): self.ctx = ctx @@ -68,14 +75,17 @@ def _cmd_received(self) -> bool: self.output(f"{self.ctx.item_names[item.item]} from {self.ctx.player_names[item.player]}") return True - def _cmd_missing(self) -> bool: - """List all missing location checks, from your local game state""" + def _cmd_missing(self, filter_text = "") -> bool: + """List all missing location checks, from your local game state. + Can be given text, which will be used as filter.""" if not self.ctx.game: self.output("No game set, cannot determine missing checks.") return False count = 0 checked_count = 0 for location, location_id in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id.items(): + if filter_text and filter_text not in location: + continue if location_id < 0: continue if location_id not in self.ctx.locations_checked: @@ -136,7 +146,7 @@ class CommonContext: items_handling: typing.Optional[int] = None want_slot_data: bool = True # should slot_data be retrieved via Connect - # datapackage + # data package # Contents in flux until connection to server is made, to download correct data for this multiworld. item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') @@ -154,6 +164,7 @@ class CommonContext: disconnected_intentionally: bool = False server: typing.Optional[Endpoint] = None server_version: Version = Version(0, 0, 0) + generator_version: Version = Version(0, 0, 0) current_energy_link_value: typing.Optional[int] = None # to display in UI, gets set by server last_death_link: float = time.time() # last send/received death link on AP layer @@ -163,6 +174,7 @@ class CommonContext: server_address: typing.Optional[str] password: typing.Optional[str] hint_cost: typing.Optional[int] + hint_points: typing.Optional[int] player_names: typing.Dict[int, str] finished_game: bool @@ -179,6 +191,10 @@ class CommonContext: server_locations: typing.Set[int] # all locations the server knows of, missing_location | checked_locations locations_info: typing.Dict[int, NetworkItem] + # data storage + stored_data: typing.Dict[str, typing.Any] + stored_data_notification_keys: typing.Set[str] + # internals # current message box through kvui _messagebox: typing.Optional["kvui.MessageBox"] = None @@ -214,6 +230,9 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option self.server_locations = set() # all locations the server knows of, missing_location | checked_locations self.locations_info = {} + self.stored_data = {} + self.stored_data_notification_keys = set() + self.input_queue = asyncio.Queue() self.input_requests = 0 @@ -223,7 +242,7 @@ def __init__(self, server_address: typing.Optional[str], password: typing.Option self.watcher_event = asyncio.Event() self.jsontotextparser = JSONtoTextParser(self) - self.update_datapackage(network_data_package) + self.update_data_package(network_data_package) # execution self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") @@ -256,6 +275,7 @@ def reset_server_state(self): self.items_received = [] self.locations_info = {} self.server_version = Version(0, 0, 0) + self.generator_version = Version(0, 0, 0) self.server = None self.server_task = None self.hint_cost = None @@ -399,32 +419,40 @@ async def shutdown(self): self.input_task.cancel() # DataPackage - async def prepare_datapackage(self, relevant_games: typing.Set[str], - remote_datepackage_versions: typing.Dict[str, int]): + async def prepare_data_package(self, relevant_games: typing.Set[str], + remote_date_package_versions: typing.Dict[str, int], + remote_data_package_checksums: typing.Dict[str, str]): """Validate that all data is present for the current multiworld. Download, assimilate and cache missing data from the server.""" # by documentation any game can use Archipelago locations/items -> always relevant relevant_games.add("Archipelago") - 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: + if game not in remote_date_package_versions and game not in remote_data_package_checksums: continue - remote_version: int = remote_datepackage_versions[game] - if remote_version == 0: # custom datapackage for this game + remote_version: int = remote_date_package_versions.get(game, 0) + remote_checksum: typing.Optional[str] = remote_data_package_checksums.get(game) + + if remote_version == 0 and not remote_checksum: # custom data package and no checksum for this game needed_updates.add(game) continue + local_version: int = network_data_package["games"].get(game, {}).get("version", 0) + local_checksum: typing.Optional[str] = network_data_package["games"].get(game, {}).get("checksum") # no action required if local version is new enough - if remote_version > local_version: - cache_version: int = cache_package.get(game, {}).get("version", 0) + if (not remote_checksum and (remote_version > local_version or remote_version == 0)) \ + or remote_checksum != local_checksum: + cached_game = Utils.load_data_package_for_checksum(game, remote_checksum) + cache_version: int = cached_game.get("version", 0) + cache_checksum: typing.Optional[str] = cached_game.get("checksum") # download remote version if cache is not new enough - if remote_version > cache_version: + if (not remote_checksum and (remote_version > cache_version or remote_version == 0)) \ + or remote_checksum != cache_checksum: needed_updates.add(game) else: - self.update_game(cache_package[game]) + self.update_game(cached_game) if needed_updates: await self.send_msgs([{"cmd": "GetDataPackage", "games": list(needed_updates)}]) @@ -434,15 +462,32 @@ def update_game(self, game_package: dict): for location_name, location_id in game_package["location_name_to_id"].items(): self.location_names[location_id] = location_name - def update_datapackage(self, data_package: dict): - for game, gamedata in data_package["games"].items(): - self.update_game(gamedata) + def update_data_package(self, data_package: dict): + for game, game_data in data_package["games"].items(): + self.update_game(game_data) - def consume_network_datapackage(self, data_package: dict): - self.update_datapackage(data_package) + def consume_network_data_package(self, data_package: dict): + self.update_data_package(data_package) current_cache = Utils.persistent_load().get("datapackage", {}).get("games", {}) current_cache.update(data_package["games"]) Utils.persistent_store("datapackage", "games", current_cache) + for game, game_data in data_package["games"].items(): + Utils.store_data_package_for_checksum(game, game_data) + + # data storage + + def set_notify(self, *keys: str) -> None: + """Subscribe to be notified of changes to selected data storage keys. + + The values can be accessed via the "stored_data" attribute of this context, which is a dictionary mapping the + names of the data storage keys to the latest values received from the server. + """ + if new_keys := (set(keys) - self.stored_data_notification_keys): + self.stored_data_notification_keys.update(new_keys) + async_start(self.send_msgs([{"cmd": "Get", + "keys": list(new_keys)}, + {"cmd": "SetNotify", + "keys": list(new_keys)}])) # DeathLink hooks @@ -573,7 +618,8 @@ def reconnect_hint() -> str: logger.info(f'Connecting to Archipelago server at {address}') try: - socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) + socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None, + ssl=get_ssl_context() if address.startswith("wss://") else None) if ctx.ui is not None: ctx.ui.update_address_bar(server_url.netloc) ctx.server = Endpoint(socket) @@ -588,6 +634,7 @@ def reconnect_hint() -> str: except websockets.InvalidMessage: # probably encrypted if address.startswith("ws://"): + # try wss await server_loop(ctx, "ws" + address[1:]) else: ctx.handle_connection_loss(f"Lost connection to the multiworld server due to InvalidMessage" @@ -632,11 +679,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict): logger.info('Room Information:') logger.info('--------------------------------') version = args["version"] - ctx.server_version = tuple(version) - version = ".".join(str(item) for item in version) + ctx.server_version = Version(*version) - logger.info(f'Server protocol version: {version}') - logger.info("Server protocol tags: " + ", ".join(args["tags"])) + if "generator_version" in args: + ctx.generator_version = Version(*args["generator_version"]) + logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, ' + f'generator version: {ctx.generator_version.as_simple_string()}, ' + f'tags: {", ".join(args["tags"])}') + else: + logger.info(f'Server protocol version: {ctx.server_version.as_simple_string()}, ' + f'tags: {", ".join(args["tags"])}') if args['password']: logger.info('Password required') ctx.update_permissions(args.get("permissions", {})) @@ -661,14 +713,16 @@ async def process_server_cmd(ctx: CommonContext, args: dict): current_team = network_player.team logger.info(' %s (Player %d)' % (network_player.alias, network_player.slot)) - # update datapackage - await ctx.prepare_datapackage(set(args["games"]), args["datapackage_versions"]) + # update data package + data_package_versions = args.get("datapackage_versions", {}) + data_package_checksums = args.get("datapackage_checksums", {}) + await ctx.prepare_data_package(set(args["games"]), data_package_versions, data_package_checksums) await ctx.server_auth(args['password']) elif cmd == 'DataPackage': logger.info("Got new ID/Name DataPackage") - ctx.consume_network_datapackage(args['data']) + ctx.consume_network_data_package(args['data']) elif cmd == 'ConnectionRefused': errors = args["errors"] @@ -696,6 +750,7 @@ async def process_server_cmd(ctx: CommonContext, args: dict): ctx.slot = args["slot"] # int keys get lost in JSON transfer ctx.slot_info = {int(pid): data for pid, data in args["slot_info"].items()} + ctx.hint_points = args.get("hint_points", 0) ctx.consume_players_package(args["players"]) msgs = [] if ctx.locations_checked: @@ -704,6 +759,11 @@ async def process_server_cmd(ctx: CommonContext, args: dict): if ctx.locations_scouted: msgs.append({"cmd": "LocationScouts", "locations": list(ctx.locations_scouted)}) + if ctx.stored_data_notification_keys: + msgs.append({"cmd": "Get", + "keys": list(ctx.stored_data_notification_keys)}) + msgs.append({"cmd": "SetNotify", + "keys": list(ctx.stored_data_notification_keys)}) if msgs: await ctx.send_msgs(msgs) if ctx.finished_game: @@ -767,8 +827,13 @@ async def process_server_cmd(ctx: CommonContext, args: dict): # we can skip checking "DeathLink" in ctx.tags, as otherwise we wouldn't have been send this if "DeathLink" in tags and ctx.last_death_link != args["data"]["time"]: ctx.on_deathlink(args["data"]) + + elif cmd == "Retrieved": + ctx.stored_data.update(args["keys"]) + elif cmd == "SetReply": - if args["key"] == "EnergyLink": + ctx.stored_data[args["key"]] = args["value"] + if args["key"].startswith("EnergyLink"): ctx.current_energy_link_value = args["value"] if ctx.ui: ctx.ui.set_new_energy_link_value() @@ -808,10 +873,9 @@ def get_base_parser(description: typing.Optional[str] = None): return parser -if __name__ == '__main__': - # Text Mode to use !hint and such with games that have no text entry - +def run_as_textclient(): class TextContext(CommonContext): + # Text Mode to use !hint and such with games that have no text entry tags = {"AP", "TextOnly"} game = "" # empty matches any game since 0.3.2 items_handling = 0b111 # receive all items for /received @@ -826,12 +890,11 @@ async def server_auth(self, password_requested: bool = False): def on_package(self, cmd: str, args: dict): if cmd == "Connected": self.game = self.slot_info[self.slot].game - + async def disconnect(self, allow_autoreconnect: bool = False): self.game = "" await super().disconnect(allow_autoreconnect) - async def main(args): ctx = TextContext(args.connect, args.password) ctx.auth = args.name @@ -844,7 +907,6 @@ async def main(args): await ctx.exit_event.wait() await ctx.shutdown() - import colorama parser = get_base_parser(description="Gameless Archipelago Client, for text interfacing.") @@ -864,3 +926,7 @@ async def main(args): asyncio.run(main(args)) colorama.deinit() + + +if __name__ == '__main__': + run_as_textclient() diff --git a/FF1Client.py b/FF1Client.py index 83c2484682fc..b7c58e206123 100644 --- a/FF1Client.py +++ b/FF1Client.py @@ -13,9 +13,9 @@ SYSTEM_MESSAGE_ID = 0 -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart ff1_connector.lua" -CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure ff1_connector.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart ff1_connector.lua" +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_ff1.lua" +CONNECTION_REFUSED_STATUS = "Connection Refused. Please start your emulator and make sure connector_ff1.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_ff1.lua" CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" CONNECTION_CONNECTED_STATUS = "Connected" CONNECTION_INITIAL_STATUS = "Connection has not been initiated" @@ -33,7 +33,7 @@ def _cmd_nes(self): logger.info(f"NES Status: {self.ctx.nes_status}") def _cmd_toggle_msgs(self): - """Toggle displaying messages in bizhawk""" + """Toggle displaying messages in EmuHawk""" global DISPLAY_MSGS DISPLAY_MSGS = not DISPLAY_MSGS logger.info(f"Messages are now {'enabled' if DISPLAY_MSGS else 'disabled'}") diff --git a/FactorioClient.py b/FactorioClient.py index 9c294c101603..070ca503269f 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -1,553 +1,12 @@ from __future__ import annotations -import os -import logging -import json -import string -import copy -import re -import subprocess -import sys -import time -import random -import typing import ModuleUpdate ModuleUpdate.update() -import factorio_rcon -import colorama -import asyncio -from queue import Queue +from worlds.factorio.Client import check_stdin, launch import Utils -def check_stdin() -> None: - if Utils.is_windows and sys.stdin: - print("WARNING: Console input is not routed reliably on Windows, use the GUI instead.") - if __name__ == "__main__": Utils.init_logging("FactorioClient", exception_logger="Client") check_stdin() - -from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger, gui_enabled, get_base_parser -from MultiServer import mark_raw -from NetUtils import NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart -from Utils import async_start - -from worlds.factorio import Factorio - - -class FactorioCommandProcessor(ClientCommandProcessor): - ctx: FactorioContext - - def _cmd_energy_link(self): - """Print the status of the energy link.""" - self.output(f"Energy Link: {self.ctx.energy_link_status}") - - @mark_raw - def _cmd_factorio(self, text: str) -> bool: - """Send the following command to the bound Factorio Server.""" - if self.ctx.rcon_client: - # TODO: Print the command non-silently only for race seeds, or otherwise block anything but /factorio /save in race seeds. - self.ctx.print_to_game(f"/factorio {text}") - result = self.ctx.rcon_client.send_command(text) - if result: - self.output(result) - return True - return False - - def _cmd_resync(self): - """Manually trigger a resync.""" - self.ctx.awaiting_bridge = True - - def _cmd_toggle_send_filter(self): - """Toggle filtering of item sends that get displayed in-game to only those that involve you.""" - self.ctx.toggle_filter_item_sends() - - def _cmd_toggle_chat(self): - """Toggle sending of chat messages from players on the Factorio server to Archipelago.""" - self.ctx.toggle_bridge_chat_out() - -class FactorioContext(CommonContext): - command_processor = FactorioCommandProcessor - game = "Factorio" - items_handling = 0b111 # full remote - - # updated by spinup server - mod_version: Utils.Version = Utils.Version(0, 0, 0) - - def __init__(self, server_address, password): - super(FactorioContext, self).__init__(server_address, password) - self.send_index: int = 0 - self.rcon_client = None - self.awaiting_bridge = False - self.write_data_path = None - self.death_link_tick: int = 0 # last send death link on Factorio layer - self.factorio_json_text_parser = FactorioJSONtoTextParser(self) - self.energy_link_increment = 0 - self.last_deplete = 0 - self.filter_item_sends: bool = False - self.multiplayer: bool = False # whether multiple different players have connected - self.bridge_chat_out: bool = True - - async def server_auth(self, password_requested: bool = False): - if password_requested and not self.password: - await super(FactorioContext, self).server_auth(password_requested) - - if self.rcon_client: - await get_info(self, self.rcon_client) # retrieve current auth code - else: - raise Exception("Cannot connect to a server with unknown own identity, " - "bridge to Factorio first.") - - await self.send_connect() - - def on_print(self, args: dict): - super(FactorioContext, self).on_print(args) - if self.rcon_client: - if not args['text'].startswith(self.player_names[self.slot] + ":"): - self.print_to_game(args['text']) - - def on_print_json(self, args: dict): - if self.rcon_client: - if (not self.filter_item_sends or not self.is_uninteresting_item_send(args)) \ - and not self.is_echoed_chat(args): - text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - if not text.startswith(self.player_names[self.slot] + ":"): # TODO: Remove string heuristic in the future. - self.print_to_game(text) - super(FactorioContext, self).on_print_json(args) - - @property - def savegame_name(self) -> str: - return f"AP_{self.seed_name}_{self.auth}_Save.zip" - - def print_to_game(self, text): - self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " - f"{text}") - - @property - def energy_link_status(self) -> str: - if not self.energy_link_increment: - return "Disabled" - elif self.current_energy_link_value is None: - return "Standby" - else: - return f"{Utils.format_SI_prefix(self.current_energy_link_value)}J" - - def on_deathlink(self, data: dict): - if self.rcon_client: - self.rcon_client.send_command(f"/ap-deathlink {data['source']}") - super(FactorioContext, self).on_deathlink(data) - - def on_package(self, cmd: str, args: dict): - if cmd in {"Connected", "RoomUpdate"}: - # catch up sync anything that is already cleared. - if "checked_locations" in args and args["checked_locations"]: - self.rcon_client.send_commands({item_name: f'/ap-get-technology ap-{item_name}-\t-1' for - item_name in args["checked_locations"]}) - if cmd == "Connected" and self.energy_link_increment: - async_start(self.send_msgs([{ - "cmd": "SetNotify", "keys": ["EnergyLink"] - }])) - elif cmd == "SetReply": - if args["key"] == "EnergyLink": - if self.energy_link_increment and args.get("last_deplete", -1) == self.last_deplete: - # it's our deplete request - gained = int(args["original_value"] - args["value"]) - gained_text = Utils.format_SI_prefix(gained) + "J" - if gained: - logger.debug(f"EnergyLink: Received {gained_text}. " - f"{Utils.format_SI_prefix(args['value'])}J remaining.") - self.rcon_client.send_command(f"/ap-energylink {gained}") - - def on_user_say(self, text: str) -> typing.Optional[str]: - # Mirror chat sent from the UI to the Factorio server. - self.print_to_game(f"{self.player_names[self.slot]}: {text}") - return text - - async def chat_from_factorio(self, user: str, message: str) -> None: - if not self.bridge_chat_out: - return - - # Pass through commands - if message.startswith("!"): - await self.send_msgs([{"cmd": "Say", "text": message}]) - return - - # Omit messages that contain local coordinates - if "[gps=" in message: - return - - prefix = f"({user}) " if self.multiplayer else "" - await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}]) - - def toggle_filter_item_sends(self) -> None: - self.filter_item_sends = not self.filter_item_sends - if self.filter_item_sends: - announcement = "Item sends are now filtered." - else: - announcement = "Item sends are no longer filtered." - logger.info(announcement) - self.print_to_game(announcement) - - def toggle_bridge_chat_out(self) -> None: - self.bridge_chat_out = not self.bridge_chat_out - if self.bridge_chat_out: - announcement = "Chat is now bridged to Archipelago." - else: - announcement = "Chat is no longer bridged to Archipelago." - logger.info(announcement) - self.print_to_game(announcement) - - def run_gui(self): - from kvui import GameManager - - class FactorioManager(GameManager): - logging_pairs = [ - ("Client", "Archipelago"), - ("FactorioServer", "Factorio Server Log"), - ("FactorioWatcher", "Bridge Data Log"), - ] - base_title = "Archipelago Factorio Client" - - self.ui = FactorioManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") - - -async def game_watcher(ctx: FactorioContext): - bridge_logger = logging.getLogger("FactorioWatcher") - next_bridge = time.perf_counter() + 1 - try: - while not ctx.exit_event.is_set(): - # TODO: restore on-demand refresh - if ctx.rcon_client and time.perf_counter() > next_bridge: - next_bridge = time.perf_counter() + 1 - ctx.awaiting_bridge = False - data = json.loads(ctx.rcon_client.send_command("/ap-sync")) - if not ctx.auth: - pass # auth failed, wait for new attempt - elif data["slot_name"] != ctx.auth: - bridge_logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}") - elif data["seed_name"] != ctx.seed_name: - bridge_logger.warning( - f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}") - else: - data = data["info"] - research_data = data["research_done"] - research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} - victory = data["victory"] - await ctx.update_death_link(data["death_link"]) - ctx.multiplayer = data.get("multiplayer", False) - - if not ctx.finished_game and victory: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - - if ctx.locations_checked != research_data: - bridge_logger.debug( - f"New researches done: " - f"{[ctx.location_names[rid] for rid in research_data - ctx.locations_checked]}") - ctx.locations_checked = research_data - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) - death_link_tick = data.get("death_link_tick", 0) - if death_link_tick != ctx.death_link_tick: - ctx.death_link_tick = death_link_tick - if "DeathLink" in ctx.tags: - async_start(ctx.send_death()) - if ctx.energy_link_increment: - in_world_bridges = data["energy_bridges"] - if in_world_bridges: - in_world_energy = data["energy"] - if in_world_energy < (ctx.energy_link_increment * in_world_bridges): - # attempt to refill - ctx.last_deplete = time.time() - async_start(ctx.send_msgs([{ - "cmd": "Set", "key": "EnergyLink", "operations": - [{"operation": "add", "value": -ctx.energy_link_increment * in_world_bridges}, - {"operation": "max", "value": 0}], - "last_deplete": ctx.last_deplete - }])) - # Above Capacity - (len(Bridges) * ENERGY_INCREMENT) - elif in_world_energy > (in_world_bridges * ctx.energy_link_increment * 5) - \ - ctx.energy_link_increment*in_world_bridges: - value = ctx.energy_link_increment * in_world_bridges - async_start(ctx.send_msgs([{ - "cmd": "Set", "key": "EnergyLink", "operations": - [{"operation": "add", "value": value}] - }])) - ctx.rcon_client.send_command( - f"/ap-energylink -{value}") - logger.debug(f"EnergyLink: Sent {Utils.format_SI_prefix(value)}J") - - await asyncio.sleep(0.1) - - except Exception as e: - logging.exception(e) - logging.error("Aborted Factorio Server Bridge") - - -def stream_factorio_output(pipe, queue, process): - pipe.reconfigure(errors="replace") - - def queuer(): - while process.poll() is None: - text = pipe.readline().strip() - if text: - queue.put_nowait(text) - - from threading import Thread - - thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True) - thread.start() - return thread - - -async def factorio_server_watcher(ctx: FactorioContext): - savegame_name = os.path.abspath(ctx.savegame_name) - if not os.path.exists(savegame_name): - logger.info(f"Creating savegame {savegame_name}") - subprocess.run(( - executable, "--create", savegame_name, "--preset", "archipelago" - )) - factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name, - *(str(elem) for elem in server_args)), - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - stdin=subprocess.DEVNULL, - encoding="utf-8") - factorio_server_logger.info("Started Factorio Server") - factorio_queue = Queue() - stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process) - stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) - try: - while not ctx.exit_event.is_set(): - if factorio_process.poll() is not None: - factorio_server_logger.info("Factorio server has exited.") - ctx.exit_event.set() - - while not factorio_queue.empty(): - msg = factorio_queue.get() - factorio_queue.task_done() - - if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg: - ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) - if not ctx.server: - logger.info("Established bridge to Factorio Server. " - "Ready to connect to Archipelago via /connect") - check_stdin() - - if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: - ctx.awaiting_bridge = True - factorio_server_logger.debug(msg) - elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command energy-link$", msg): - factorio_server_logger.debug(msg) - ctx.print_to_game(f"Energy Link: {ctx.energy_link_status}") - elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter$", msg): - factorio_server_logger.debug(msg) - ctx.toggle_filter_item_sends() - elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg): - factorio_server_logger.debug(msg) - ctx.toggle_bridge_chat_out() - else: - factorio_server_logger.info(msg) - match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg) - if match: - await ctx.chat_from_factorio(match.group(1), match.group(2)) - if ctx.rcon_client: - commands = {} - while ctx.send_index < len(ctx.items_received): - transfer_item: NetworkItem = ctx.items_received[ctx.send_index] - item_id = transfer_item.item - player_name = ctx.player_names[transfer_item.player] - if item_id not in Factorio.item_id_to_name: - factorio_server_logger.error(f"Cannot send unknown item ID: {item_id}") - else: - item_name = Factorio.item_id_to_name[item_id] - factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.") - commands[ctx.send_index] = f'/ap-get-technology {item_name}\t{ctx.send_index}\t{player_name}' - ctx.send_index += 1 - if commands: - ctx.rcon_client.send_commands(commands) - await asyncio.sleep(0.1) - - except Exception as e: - logging.exception(e) - logging.error("Aborted Factorio Server Bridge") - ctx.exit_event.set() - - finally: - if factorio_process.poll() is not None: - if ctx.rcon_client: - ctx.rcon_client.close() - ctx.rcon_client = None - return - - sent_quit = False - if ctx.rcon_client: - # Attempt clean quit through RCON. - try: - ctx.rcon_client.send_command("/quit") - except factorio_rcon.RCONNetworkError: - pass - else: - sent_quit = True - ctx.rcon_client.close() - ctx.rcon_client = None - if not sent_quit: - # Attempt clean quit using SIGTERM. (Note that on Windows this kills the process instead.) - factorio_process.terminate() - - try: - factorio_process.wait(10) - except subprocess.TimeoutExpired: - factorio_process.kill() - - -async def get_info(ctx: FactorioContext, rcon_client: factorio_rcon.RCONClient): - info = json.loads(rcon_client.send_command("/ap-rcon-info")) - ctx.auth = info["slot_name"] - ctx.seed_name = info["seed_name"] - # 0.2.0 addition, not present earlier - death_link = bool(info.get("death_link", False)) - ctx.energy_link_increment = info.get("energy_link", 0) - logger.debug(f"Energy Link Increment: {ctx.energy_link_increment}") - if ctx.energy_link_increment and ctx.ui: - ctx.ui.enable_energy_link() - await ctx.update_death_link(death_link) - - -async def factorio_spinup_server(ctx: FactorioContext) -> bool: - savegame_name = os.path.abspath("Archipelago.zip") - if not os.path.exists(savegame_name): - logger.info(f"Creating savegame {savegame_name}") - subprocess.run(( - executable, "--create", savegame_name - )) - factorio_process = subprocess.Popen( - (executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)), - stderr=subprocess.PIPE, - stdout=subprocess.PIPE, - stdin=subprocess.DEVNULL, - encoding="utf-8") - factorio_server_logger.info("Started Information Exchange Factorio Server") - factorio_queue = Queue() - stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process) - stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) - rcon_client = None - try: - while not ctx.auth: - while not factorio_queue.empty(): - msg = factorio_queue.get() - factorio_server_logger.info(msg) - if "Loading mod AP-" in msg and msg.endswith("(data.lua)"): - parts = msg.split() - ctx.mod_version = Utils.Version(*(int(number) for number in parts[-2].split("."))) - elif "Write data path: " in msg: - ctx.write_data_path = Utils.get_text_between(msg, "Write data path: ", " [") - if "AppData" in ctx.write_data_path: - logger.warning("It appears your mods are loaded from Appdata, " - "this can lead to problems with multiple Factorio instances. " - "If this is the case, you will get a file locked error running Factorio.") - if not rcon_client and "Starting RCON interface at IP ADDR:" in msg: - rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) - if ctx.mod_version == ctx.__class__.mod_version: - raise Exception("No Archipelago mod was loaded. Aborting.") - await get_info(ctx, rcon_client) - await asyncio.sleep(0.01) - - except Exception as e: - logger.exception(e, extra={"compact_gui": True}) - msg = "Aborted Factorio Server Bridge" - logger.error(msg) - ctx.gui_error(msg, e) - ctx.exit_event.set() - - else: - logger.info( - f"Got World Information from AP Mod {tuple(ctx.mod_version)} for seed {ctx.seed_name} in slot {ctx.auth}") - return True - finally: - factorio_process.terminate() - factorio_process.wait(5) - return False - - -async def main(args): - ctx = FactorioContext(args.connect, args.password) - ctx.filter_item_sends = initial_filter_item_sends - ctx.bridge_chat_out = initial_bridge_chat_out - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") - - if gui_enabled: - ctx.run_gui() - ctx.run_cli() - - factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer") - successful_launch = await factorio_server_task - if successful_launch: - factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer") - progression_watcher = asyncio.create_task( - game_watcher(ctx), name="FactorioProgressionWatcher") - - await ctx.exit_event.wait() - ctx.server_address = None - - await progression_watcher - await factorio_server_task - - await ctx.shutdown() - - -class FactorioJSONtoTextParser(JSONtoTextParser): - def _handle_color(self, node: JSONMessagePart): - colors = node["color"].split(";") - for color in colors: - if color in self.color_codes: - node["text"] = f"[color=#{self.color_codes[color]}]{node['text']}[/color]" - return self._handle_text(node) - return self._handle_text(node) - - -if __name__ == '__main__': - parser = get_base_parser(description="Optional arguments to FactorioClient follow. " - "Remaining arguments get passed into bound Factorio instance." - "Refer to Factorio --help for those.") - parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') - parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') - parser.add_argument('--server-settings', help='Factorio server settings configuration file.') - - args, rest = parser.parse_known_args() - colorama.init() - rcon_port = args.rcon_port - rcon_password = args.rcon_password if args.rcon_password else ''.join( - random.choice(string.ascii_letters) for x in range(32)) - - factorio_server_logger = logging.getLogger("FactorioServer") - options = Utils.get_options() - executable = options["factorio_options"]["executable"] - server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) - if server_settings: - server_settings = os.path.abspath(server_settings) - if not isinstance(options["factorio_options"]["filter_item_sends"], bool): - logging.warning(f"Warning: Option filter_item_sends should be a bool.") - initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"]) - if not isinstance(options["factorio_options"]["bridge_chat_out"], bool): - logging.warning(f"Warning: Option bridge_chat_out should be a bool.") - initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"]) - - if not os.path.exists(os.path.dirname(executable)): - raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") - if os.path.isdir(executable): # user entered a path to a directory, let's find the executable therein - executable = os.path.join(executable, "factorio") - if not os.path.isfile(executable): - if os.path.isfile(executable + ".exe"): - executable = executable + ".exe" - else: - raise FileNotFoundError(f"Path {executable} is not an executable file.") - - if server_settings and os.path.isfile(server_settings): - server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, "--server-settings", server_settings, *rest) - else: - server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest) - - asyncio.run(main(args)) - colorama.deinit() + launch() diff --git a/Fill.py b/Fill.py index 92b57af58b07..3e0342f42cd3 100644 --- a/Fill.py +++ b/Fill.py @@ -1,11 +1,10 @@ -import logging -import typing import collections import itertools +import logging +import typing from collections import Counter, deque -from BaseClasses import CollectionState, Location, LocationProgressType, MultiWorld, Item, ItemClassification - +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld from worlds.AutoWorld import call_all from worlds.generic.Rules import add_item_rule @@ -23,15 +22,28 @@ def sweep_from_pool(base_state: CollectionState, itempool: typing.Sequence[Item] def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: typing.List[Location], - itempool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, + item_pool: typing.List[Item], single_player_placement: bool = False, lock: bool = False, swap: bool = True, on_place: typing.Optional[typing.Callable[[Location], None]] = None, - allow_partial: bool = False) -> None: + allow_partial: bool = False, allow_excluded: bool = False) -> None: + """ + :param world: Multiworld to be filled. + :param base_state: State assumed before fill. + :param locations: Locations to be filled with item_pool + :param item_pool: Items to fill into the locations + :param single_player_placement: if true, can speed up placement if everything belongs to a single player + :param lock: locations are set to locked as they are filled + :param swap: if true, swaps of already place items are done in the event of a dead end + :param on_place: callback that is called when a placement happens + :param allow_partial: only place what is possible. Remaining items will be in the item_pool list. + :param allow_excluded: if true and placement fails, it is re-attempted while ignoring excluded on Locations + """ unplaced_items: typing.List[Item] = [] placements: typing.List[Location] = [] + cleanup_required = False - swapped_items: typing.Counter[typing.Tuple[int, str]] = Counter() + swapped_items: typing.Counter[typing.Tuple[int, str, bool]] = Counter() reachable_items: typing.Dict[int, typing.Deque[Item]] = {} - for item in itempool: + for item in item_pool: reachable_items.setdefault(item.player, deque()).append(item) while any(reachable_items.values()) and locations: @@ -39,9 +51,12 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: items_to_place = [items.pop() for items in reachable_items.values() if items] for item in items_to_place: - itempool.remove(item) + for p, pool_item in enumerate(item_pool): + if pool_item is item: + item_pool.pop(p) + break maximum_exploration_state = sweep_from_pool( - base_state, itempool + unplaced_items) + base_state, item_pool + unplaced_items) has_beaten_game = world.has_beaten_game(maximum_exploration_state) @@ -73,25 +88,28 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: else: # we filled all reachable spots. if swap: - # try swapping this item with previously placed items - for (i, location) in enumerate(placements): + # try swapping this item with previously placed items in a safe way then in an unsafe way + swap_attempts = ((i, location, unsafe) + for unsafe in (False, True) + for i, location in enumerate(placements)) + for (i, location, unsafe) in swap_attempts: 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 - swap_count = swapped_items[placed_item.player, - placed_item.name] + swap_count = swapped_items[placed_item.player, placed_item.name, unsafe] if swap_count > 1: continue location.item = None placed_item.location = None - swap_state = sweep_from_pool(base_state, [placed_item]) - # swap_state assumes we can collect placed item before item_to_place + swap_state = sweep_from_pool(base_state, [placed_item] if unsafe else []) + # unsafe means swap_state assumes we can somehow collect placed_item before item_to_place + # by continuing to swap, which is not guaranteed. This is unsafe because there is no mechanic + # to clean that up later, so there is a chance generation fails. if (not single_player_placement or location.player == item_to_place.player) \ and location.can_fill(swap_state, item_to_place, perform_access_check): - # Verify that placing this item won't reduce available locations, which could happen with rules - # that want to not have both items. Left in until removal is proven useful. + # Verify placing this item won't reduce available locations, which would be a useless swap. prev_state = swap_state.copy() prev_loc_count = len( world.get_reachable_locations(prev_state)) @@ -106,12 +124,14 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: spot_to_fill = placements.pop(i) swap_count += 1 - swapped_items[placed_item.player, - placed_item.name] = swap_count + swapped_items[placed_item.player, placed_item.name, unsafe] = swap_count reachable_items[placed_item.player].appendleft( placed_item) - itempool.append(placed_item) + item_pool.append(placed_item) + + # cleanup at the end to hopefully get better errors + cleanup_required = True break @@ -133,6 +153,31 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if on_place: on_place(spot_to_fill) + if cleanup_required: + # validate all placements and remove invalid ones + state = sweep_from_pool(base_state, []) + for placement in placements: + if world.accessibility[placement.item.player] != "minimal" and not placement.can_reach(state): + placement.item.location = None + unplaced_items.append(placement.item) + placement.item = None + locations.append(placement) + + if allow_excluded: + # check if partial fill is the result of excluded locations, in which case retry + excluded_locations = [ + location for location in locations + if location.progress_type == location.progress_type.EXCLUDED and not location.item + ] + if excluded_locations: + for location in excluded_locations: + location.progress_type = location.progress_type.DEFAULT + fill_restrictive(world, base_state, excluded_locations, unplaced_items, single_player_placement, lock, + swap, on_place, allow_partial, False) + for location in excluded_locations: + if not location.item: + location.progress_type = location.progress_type.EXCLUDED + if not allow_partial and len(unplaced_items) > 0 and len(locations) > 0: # There are leftover unplaceable items and locations that won't accept them if world.can_beat_game(): @@ -142,7 +187,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: 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) + item_pool.extend(unplaced_items) def remaining_fill(world: MultiWorld, @@ -499,16 +544,16 @@ def balance_multiworld_progression(world: MultiWorld) -> None: checked_locations: typing.Set[Location] = set() unchecked_locations: typing.Set[Location] = set(world.get_locations()) - reachable_locations_count: typing.Dict[int, int] = { - player: 0 - for player in world.player_ids - if len(world.get_filled_locations(player)) != 0 - } total_locations_count: typing.Counter[int] = Counter( location.player for location in world.get_locations() if not location.locked ) + reachable_locations_count: typing.Dict[int, int] = { + player: 0 + for player in world.player_ids + if total_locations_count[player] and len(world.get_filled_locations(player)) != 0 + } balanceable_players = { player: balanceable_players[player] for player in balanceable_players @@ -525,6 +570,10 @@ def get_sphere_locations(sphere_state: CollectionState, def item_percentage(player: int, num: int) -> float: return num / total_locations_count[player] + # If there are no locations that aren't locked, there's no point in attempting to balance progression. + if len(total_locations_count) == 0: + return + while True: # Gather non-locked locations. # This ensures that only shuffled locations get counted for progression balancing, @@ -798,7 +847,6 @@ def failed(warning: str, force: typing.Union[bool, str]) -> None: for player in worlds: locations += non_early_locations[player] - block['locations'] = locations if not block['count']: diff --git a/Generate.py b/Generate.py index afb34f11c6be..5d44a1db4550 100644 --- a/Generate.py +++ b/Generate.py @@ -7,55 +7,52 @@ import string import urllib.parse import urllib.request -from collections import Counter, ChainMap -from typing import Dict, Tuple, Callable, Any, Union +from collections import ChainMap, Counter +from typing import Any, Callable, Dict, Tuple, Union import ModuleUpdate ModuleUpdate.update() +import copy import Utils +import Options +from BaseClasses import seeddigits, get_seed, PlandoOptions +from Main import main as ERmain +from settings import get_settings +from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path from worlds.alttp import Options as LttPOptions -from worlds.generic import PlandoConnection -from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path from worlds.alttp.EntranceRandomizer import parse_arguments -from Main import main as ERmain -from BaseClasses import seeddigits, get_seed, PlandoOptions -import Options from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister -import copy - - - +from worlds.generic import PlandoConnection def mystery_argparse(): - options = get_options() - defaults = options["generator"] - - def resolve_path(path: str, resolver: Callable[[str], str]) -> str: - return path if os.path.isabs(path) else resolver(path) + options = get_settings() + defaults = options.generator parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") - parser.add_argument('--weights_file_path', default=defaults["weights_file_path"], + parser.add_argument('--weights_file_path', default=defaults.weights_file_path, help='Path to the weights file to use for rolling game settings, urls are also valid') parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player', action='store_true') - parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path), + parser.add_argument('--player_files_path', default=defaults.player_files_path, help="Input directory for player files.") parser.add_argument('--seed', help='Define seed number to generate.', type=int) - parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1)) - parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) - parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path), + parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) + parser.add_argument('--spoiler', type=int, default=defaults.spoiler) + parser.add_argument('--outputpath', default=options.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd - parser.add_argument('--race', action='store_true', default=defaults["race"]) - parser.add_argument('--meta_file_path', default=defaults["meta_file_path"]) + parser.add_argument('--race', action='store_true', default=defaults.race) + parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--log_level', default='info', help='Sets log level') parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0), help='Output rolled mystery results to yaml up to specified number (made for async multiworld)') - parser.add_argument('--plando', default=defaults["plando_options"], + parser.add_argument('--plando', default=defaults.plando_options, help='List of options that can be set manually. Can be combined, for example "bosses, items"') + parser.add_argument("--skip_prog_balancing", action="store_true", + help="Skip progression balancing step during generation.") args = parser.parse_args() if not os.path.isabs(args.weights_file_path): args.weights_file_path = os.path.join(args.player_files_path, args.weights_file_path) @@ -72,12 +69,16 @@ def get_seed_name(random_source) -> str: def main(args=None, callback=ERmain): if not args: args, options = mystery_argparse() + else: + options = get_settings() seed = get_seed(args.seed) + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) random.seed(seed) seed_name = get_seed_name(random) if args.race: + logging.info("Race mode enabled. Using non-deterministic random source.") random.seed() # reset to time-based random source weights_cache: Dict[str, Tuple[Any, ...]] = {} @@ -85,16 +86,16 @@ def main(args=None, callback=ERmain): try: weights_cache[args.weights_file_path] = read_weights_yamls(args.weights_file_path) except Exception as e: - raise ValueError(f"File {args.weights_file_path} is destroyed. Please fix your yaml.") from e - print(f"Weights: {args.weights_file_path} >> " - f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}") + raise ValueError(f"File {args.weights_file_path} is invalid. Please fix your yaml.") from e + logging.info(f"Weights: {args.weights_file_path} >> " + f"{get_choice('description', weights_cache[args.weights_file_path][-1], 'No description specified')}") if args.meta_file_path and os.path.exists(args.meta_file_path): try: meta_weights = read_weights_yamls(args.meta_file_path)[-1] except Exception as e: - raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e - print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") + raise ValueError(f"File {args.meta_file_path} is invalid. Please fix your yaml.") from e + logging.info(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") try: # meta description allows us to verify that the file named meta.yaml is intentionally a meta file del(meta_weights["meta_description"]) except Exception as e: @@ -113,35 +114,35 @@ def main(args=None, callback=ERmain): try: weights_cache[fname] = read_weights_yamls(path) except Exception as e: - raise ValueError(f"File {fname} is destroyed. Please fix your yaml.") from e + raise ValueError(f"File {fname} is invalid. Please fix your yaml.") from e # sort dict for consistent results across platforms: weights_cache = {key: value for key, value in sorted(weights_cache.items())} for filename, yaml_data in weights_cache.items(): if filename not in {args.meta_file_path, args.weights_file_path}: for yaml in yaml_data: - print(f"P{player_id} Weights: {filename} >> " - f"{get_choice('description', yaml, 'No description specified')}") + logging.info(f"P{player_id} Weights: {filename} >> " + f"{get_choice('description', yaml, 'No description specified')}") player_files[player_id] = filename player_id += 1 args.multi = max(player_id - 1, args.multi) - print(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, {seed_name} Seed {seed} with plando: " - f"{args.plando}") + logging.info(f"Generating for {args.multi} player{'s' if args.multi > 1 else ''}, " + f"{seed_name} Seed {seed} with plando: {args.plando}") if not weights_cache: - raise Exception(f"No weights found. Provide a general weights file ({args.weights_file_path}) or individual player files. " + raise Exception(f"No weights found. " + f"Provide a general weights file ({args.weights_file_path}) or individual player files. " f"A mix is also permitted.") erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.plando_options = args.plando - erargs.glitch_triforce = options["generator"]["glitch_triforce_room"] + erargs.glitch_triforce = options.generator.glitch_triforce_room erargs.spoiler = args.spoiler erargs.race = args.race erargs.outputname = seed_name erargs.outputpath = args.outputpath - - Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) + erargs.skip_prog_balancing = args.skip_prog_balancing settings_cache: Dict[str, Tuple[argparse.Namespace, ...]] = \ {fname: (tuple(roll_settings(yaml, args.plando) for yaml in yamls) if args.samesettings else None) @@ -194,7 +195,7 @@ def main(args=None, callback=ERmain): player += 1 except Exception as e: - raise ValueError(f"File {path} is destroyed. Please fix your yaml.") from e + raise ValueError(f"File {path} is invalid. Please fix your yaml.") from e else: raise RuntimeError(f'No weights specified for player {player}') @@ -373,7 +374,7 @@ def roll_linked_options(weights: dict) -> dict: else: logging.debug(f"linked option {option_set['name']} skipped.") except Exception as e: - raise ValueError(f"Linked option {option_set['name']} is destroyed. " + raise ValueError(f"Linked option {option_set['name']} is invalid. " f"Please fix your linked option.") from e return weights @@ -403,7 +404,7 @@ def roll_triggers(weights: dict, triggers: list) -> dict: update_weights(currently_targeted_weights, category_options, "Triggered", option_set["option_name"]) except Exception as e: - raise ValueError(f"Your trigger number {i + 1} is destroyed. " + raise ValueError(f"Your trigger number {i + 1} is invalid. " f"Please fix your triggers.") from e return weights @@ -449,6 +450,11 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b raise Exception(f"Option {option_key} has to be in a game's section, not on its own.") ret.game = get_choice("game", weights) + if ret.game not in AutoWorldRegister.world_types: + picks = Utils.get_fuzzy_results(ret.game, AutoWorldRegister.world_types, limit=1)[0] + raise Exception(f"No world found to handle game {ret.game}. Did you mean '{picks[0]}' ({picks[1]}% sure)? " + f"Check your spelling or installation of that world.") + if ret.game not in weights: raise Exception(f"No game options for selected game \"{ret.game}\" found.") @@ -463,32 +469,29 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b for option_key, option in Options.common_options.items(): setattr(ret, option_key, option.from_any(get_choice(option_key, weights, option.default))) - if ret.game in AutoWorldRegister.world_types: - for option_key, option in world_type.option_definitions.items(): + for option_key, option in world_type.option_definitions.items(): + 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 option_key not in world_type.option_definitions and \ + (option_key not in Options.common_options or option_key in game_weights): 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 option_key not in world_type.option_definitions and \ - (option_key not in Options.common_options or option_key in game_weights): - handle_option(ret, game_weights, option_key, option, plando_options) - if PlandoOptions.items in plando_options: - ret.plando_items = game_weights.get("plando_items", []) - if ret.game == "Minecraft" or ret.game == "Ocarina of Time": - # bad hardcoded behavior to make this work for now - ret.plando_connections = [] - if PlandoOptions.connections in plando_options: - options = game_weights.get("plando_connections", []) - for placement in options: - if roll_percentage(get_choice("percentage", placement, 100)): - ret.plando_connections.append(PlandoConnection( - get_choice("entrance", placement), - get_choice("exit", placement), - get_choice("direction", placement) - )) - elif ret.game == "A Link to the Past": - roll_alttp_settings(ret, game_weights, plando_options) - else: - raise Exception(f"Unsupported game {ret.game}") + if PlandoOptions.items in plando_options: + ret.plando_items = game_weights.get("plando_items", []) + if ret.game == "Minecraft" or ret.game == "Ocarina of Time": + # bad hardcoded behavior to make this work for now + ret.plando_connections = [] + if PlandoOptions.connections in plando_options: + options = game_weights.get("plando_connections", []) + for placement in options: + if roll_percentage(get_choice("percentage", placement, 100)): + ret.plando_connections.append(PlandoConnection( + get_choice("entrance", placement), + get_choice("exit", placement), + get_choice("direction", placement) + )) + elif ret.game == "A Link to the Past": + roll_alttp_settings(ret, game_weights, plando_options) return ret diff --git a/KH2Client.py b/KH2Client.py new file mode 100644 index 000000000000..1134932dc26c --- /dev/null +++ b/KH2Client.py @@ -0,0 +1,894 @@ +import os +import asyncio +import ModuleUpdate +import json +import Utils +from pymem import pymem +from worlds.kh2.Items import exclusionItem_table, CheckDupingItems +from worlds.kh2 import all_locations, item_dictionary_table, exclusion_table + +from worlds.kh2.WorldLocations import * + +from worlds import network_data_package + +if __name__ == "__main__": + Utils.init_logging("KH2Client", exception_logger="Client") + +from NetUtils import ClientStatus +from CommonClient import gui_enabled, logger, get_base_parser, ClientCommandProcessor, \ + CommonContext, server_loop + +ModuleUpdate.update() + +kh2_loc_name_to_id = network_data_package["games"]["Kingdom Hearts 2"]["location_name_to_id"] + + +# class KH2CommandProcessor(ClientCommandProcessor): + + +class KH2Context(CommonContext): + # command_processor: int = KH2CommandProcessor + game = "Kingdom Hearts 2" + items_handling = 0b101 # Indicates you get items sent from other worlds. + + def __init__(self, server_address, password): + super(KH2Context, self).__init__(server_address, password) + self.kh2LocalItems = None + self.ability = None + self.growthlevel = None + self.KH2_sync_task = None + self.syncing = False + self.kh2connected = False + self.serverconneced = False + self.item_name_to_data = {name: data for name, data, in item_dictionary_table.items()} + self.location_name_to_data = {name: data for name, data, in all_locations.items()} + self.lookup_id_to_item: typing.Dict[int, str] = {data.code: item_name for item_name, data in + item_dictionary_table.items() if data.code} + self.lookup_id_to_Location: typing.Dict[int, str] = {data.code: item_name for item_name, data in + all_locations.items() if data.code} + self.location_name_to_worlddata = {name: data for name, data, in all_world_locations.items()} + + self.location_table = {} + self.collectible_table = {} + self.collectible_override_flags_address = 0 + self.collectible_offsets = {} + self.sending = [] + # list used to keep track of locations+items player has. Used for disoneccting + self.kh2seedsave = None + self.slotDataProgressionNames = {} + self.kh2seedname = None + self.kh2slotdata = None + self.itemamount = {} + # sora equipped, valor equipped, master equipped, final equipped + self.keybladeAnchorList = (0x24F0, 0x32F4, 0x339C, 0x33D4) + if "localappdata" in os.environ: + self.game_communication_path = os.path.expandvars(r"%localappdata%\KH2AP") + self.amountOfPieces = 0 + # hooked object + self.kh2 = None + self.ItemIsSafe = False + self.game_connected = False + self.finalxemnas = False + self.worldid = { + # 1: {}, # world of darkness (story cutscenes) + 2: TT_Checks, + # 3: {}, # destiny island doesn't have checks to ima put tt checks here + 4: HB_Checks, + 5: BC_Checks, + 6: Oc_Checks, + 7: AG_Checks, + 8: LoD_Checks, + 9: HundredAcreChecks, + 10: PL_Checks, + 11: DC_Checks, # atlantica isn't a supported world. if you go in atlantica it will check dc + 12: DC_Checks, + 13: TR_Checks, + 14: HT_Checks, + 15: HB_Checks, # world map, but you only go to the world map while on the way to goa so checking hb + 16: PR_Checks, + 17: SP_Checks, + 18: TWTNW_Checks, + # 255: {}, # starting screen + } + # 0x2A09C00+0x40 is the sve anchor. +1 is the last saved room + self.sveroom = 0x2A09C00 + 0x41 + # 0 not in battle 1 in yellow battle 2 red battle #short + self.inBattle = 0x2A0EAC4 + 0x40 + self.onDeath = 0xAB9078 + # PC Address anchors + self.Now = 0x0714DB8 + self.Save = 0x09A70B0 + self.Sys3 = 0x2A59DF0 + self.Bt10 = 0x2A74880 + self.BtlEnd = 0x2A0D3E0 + self.Slot1 = 0x2A20C98 + + self.chest_set = set(exclusion_table["Chests"]) + + self.keyblade_set = set(CheckDupingItems["Weapons"]["Keyblades"]) + self.staff_set = set(CheckDupingItems["Weapons"]["Staffs"]) + self.shield_set = set(CheckDupingItems["Weapons"]["Shields"]) + + self.all_weapons = self.keyblade_set.union(self.staff_set).union(self.shield_set) + + self.equipment_categories = CheckDupingItems["Equipment"] + self.armor_set = set(self.equipment_categories["Armor"]) + self.accessories_set = set(self.equipment_categories["Accessories"]) + self.all_equipment = self.armor_set.union(self.accessories_set) + + self.Equipment_Anchor_Dict = { + "Armor": [0x2504, 0x2506, 0x2508, 0x250A], + "Accessories": [0x2514, 0x2516, 0x2518, 0x251A]} + + self.AbilityQuantityDict = {} + self.ability_categories = CheckDupingItems["Abilities"] + + self.sora_ability_set = set(self.ability_categories["Sora"]) + self.donald_ability_set = set(self.ability_categories["Donald"]) + self.goofy_ability_set = set(self.ability_categories["Goofy"]) + + self.all_abilities = self.sora_ability_set.union(self.donald_ability_set).union(self.goofy_ability_set) + + self.boost_set = set(CheckDupingItems["Boosts"]) + self.stat_increase_set = set(CheckDupingItems["Stat Increases"]) + self.AbilityQuantityDict = {item: self.item_name_to_data[item].quantity for item in self.all_abilities} + # Growth:[level 1,level 4,slot] + self.growth_values_dict = {"High Jump": [0x05E, 0x061, 0x25DA], + "Quick Run": [0x62, 0x65, 0x25DC], + "Dodge Roll": [0x234, 0x237, 0x25DE], + "Aerial Dodge": [0x066, 0x069, 0x25E0], + "Glide": [0x6A, 0x6D, 0x25E2]} + self.boost_to_anchor_dict = { + "Power Boost": 0x24F9, + "Magic Boost": 0x24FA, + "Defense Boost": 0x24FB, + "AP Boost": 0x24F8} + + self.AbilityCodeList = [self.item_name_to_data[item].code for item in exclusionItem_table["Ability"]] + self.master_growth = {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"} + + self.bitmask_item_code = [ + 0x130000, 0x130001, 0x130002, 0x130003, 0x130004, 0x130005, 0x130006, 0x130007 + , 0x130008, 0x130009, 0x13000A, 0x13000B, 0x13000C + , 0x13001F, 0x130020, 0x130021, 0x130022, 0x130023 + , 0x13002A, 0x13002B, 0x13002C, 0x13002D] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(KH2Context, self).server_auth(password_requested) + await self.get_username() + await self.send_connect() + + async def connection_closed(self): + self.kh2connected = False + self.serverconneced = False + if self.kh2seedname is not None and self.auth is not None: + with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), + 'w') as f: + f.write(json.dumps(self.kh2seedsave, indent=4)) + await super(KH2Context, self).connection_closed() + + async def disconnect(self, allow_autoreconnect: bool = False): + self.kh2connected = False + self.serverconneced = False + if self.kh2seedname not in {None} and self.auth not in {None}: + with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), + 'w') as f: + f.write(json.dumps(self.kh2seedsave, indent=4)) + await super(KH2Context, self).disconnect() + + @property + def endpoints(self): + if self.server: + return [self.server] + else: + return [] + + async def shutdown(self): + if self.kh2seedname not in {None} and self.auth not in {None}: + with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), + 'w') as f: + f.write(json.dumps(self.kh2seedsave, indent=4)) + await super(KH2Context, self).shutdown() + + def on_package(self, cmd: str, args: dict): + if cmd in {"RoomInfo"}: + self.kh2seedname = args['seed_name'] + if not os.path.exists(self.game_communication_path): + os.makedirs(self.game_communication_path) + if not os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"): + self.kh2seedsave = {"itemIndex": -1, + # back of soras invo is 0x25E2. Growth should be moved there + # Character: [back of invo, front of invo] + "SoraInvo": [0x25D8, 0x2546], + "DonaldInvo": [0x26F4, 0x2658], + "GoofyInvo": [0x280A, 0x276C], + "AmountInvo": { + "ServerItems": { + "Ability": {}, + "Amount": {}, + "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, + "Aerial Dodge": 0, + "Glide": 0}, + "Bitmask": [], + "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, + "Equipment": [], + "Magic": {}, + "StatIncrease": {}, + "Boost": {}, + }, + "LocalItems": { + "Ability": {}, + "Amount": {}, + "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, + "Aerial Dodge": 0, "Glide": 0}, + "Bitmask": [], + "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, + "Equipment": [], + "Magic": {}, + "StatIncrease": {}, + "Boost": {}, + }}, + # 1,3,255 are in this list in case the player gets locations in those "worlds" and I need to still have them checked + "LocationsChecked": [], + "Levels": { + "SoraLevel": 0, + "ValorLevel": 0, + "WisdomLevel": 0, + "LimitLevel": 0, + "MasterLevel": 0, + "FinalLevel": 0, + }, + "SoldEquipment": [], + "SoldBoosts": {"Power Boost": 0, + "Magic Boost": 0, + "Defense Boost": 0, + "AP Boost": 0} + } + with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), + 'wt') as f: + pass + self.locations_checked = set() + elif os.path.exists(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json"): + with open(self.game_communication_path + f"\kh2save{self.kh2seedname}{self.auth}.json", 'r') as f: + self.kh2seedsave = json.load(f) + self.locations_checked = set(self.kh2seedsave["LocationsChecked"]) + self.serverconneced = True + + if cmd in {"Connected"}: + self.kh2slotdata = args['slot_data'] + self.kh2LocalItems = {int(location): item for location, item in self.kh2slotdata["LocalItems"].items()} + try: + self.kh2 = pymem.Pymem(process_name="KINGDOM HEARTS II FINAL MIX") + logger.info("You are now auto-tracking") + self.kh2connected = True + except Exception as e: + logger.info("Line 247") + if self.kh2connected: + logger.info("Connection Lost") + self.kh2connected = False + logger.info(e) + + if cmd in {"ReceivedItems"}: + start_index = args["index"] + if start_index == 0: + # resetting everything that were sent from the server + self.kh2seedsave["SoraInvo"][0] = 0x25D8 + self.kh2seedsave["DonaldInvo"][0] = 0x26F4 + self.kh2seedsave["GoofyInvo"][0] = 0x280A + self.kh2seedsave["itemIndex"] = - 1 + self.kh2seedsave["AmountInvo"]["ServerItems"] = { + "Ability": {}, + "Amount": {}, + "Growth": {"High Jump": 0, "Quick Run": 0, "Dodge Roll": 0, + "Aerial Dodge": 0, + "Glide": 0}, + "Bitmask": [], + "Weapon": {"Sora": [], "Donald": [], "Goofy": []}, + "Equipment": [], + "Magic": {}, + "StatIncrease": {}, + "Boost": {}, + } + if start_index > self.kh2seedsave["itemIndex"]: + self.kh2seedsave["itemIndex"] = start_index + for item in args['items']: + asyncio.create_task(self.give_item(item.item)) + + if cmd in {"RoomUpdate"}: + if "checked_locations" in args: + new_locations = set(args["checked_locations"]) + # TODO: make this take locations from other players on the same slot so proper coop happens + # items_to_give = [self.kh2slotdata["LocalItems"][str(location_id)] for location_id in new_locations if + # location_id in self.kh2LocalItems.keys()] + self.checked_locations |= new_locations + + async def checkWorldLocations(self): + try: + currentworldint = int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big") + if currentworldint in self.worldid: + curworldid = self.worldid[currentworldint] + for location, data in curworldid.items(): + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked \ + and (int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), + "big") & 0x1 << data.bitIndex) > 0: + self.sending = self.sending + [(int(locationId))] + except Exception as e: + logger.info("Line 285") + if self.kh2connected: + logger.info("Connection Lost.") + self.kh2connected = False + logger.info(e) + + async def checkLevels(self): + try: + for location, data in SoraLevels.items(): + currentLevel = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), "big") + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked \ + and currentLevel >= data.bitIndex: + if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel: + self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel + self.sending = self.sending + [(int(locationId))] + formDict = { + 0: ["ValorLevel", ValorLevels], 1: ["WisdomLevel", WisdomLevels], 2: ["LimitLevel", LimitLevels], + 3: ["MasterLevel", MasterLevels], 4: ["FinalLevel", FinalLevels]} + for i in range(5): + for location, data in formDict[i][1].items(): + formlevel = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), "big") + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked \ + and formlevel >= data.bitIndex: + if formlevel > self.kh2seedsave["Levels"][formDict[i][0]]: + self.kh2seedsave["Levels"][formDict[i][0]] = formlevel + self.sending = self.sending + [(int(locationId))] + except Exception as e: + logger.info("Line 312") + if self.kh2connected: + logger.info("Connection Lost.") + self.kh2connected = False + logger.info(e) + + async def checkSlots(self): + try: + for location, data in weaponSlots.items(): + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked: + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), + "big") > 0: + self.sending = self.sending + [(int(locationId))] + + for location, data in formSlots.items(): + locationId = kh2_loc_name_to_id[location] + if locationId not in self.locations_checked: + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), + "big") & 0x1 << data.bitIndex > 0: + # self.locations_checked + self.sending = self.sending + [(int(locationId))] + + except Exception as e: + if self.kh2connected: + logger.info("Line 333") + logger.info("Connection Lost.") + self.kh2connected = False + logger.info(e) + + async def verifyChests(self): + try: + for location in self.locations_checked: + locationName = self.lookup_id_to_Location[location] + if locationName in self.chest_set: + if locationName in self.location_name_to_worlddata.keys(): + locationData = self.location_name_to_worlddata[locationName] + if int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1), + "big") & 0x1 << locationData.bitIndex == 0: + roomData = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, + 1), "big") + self.kh2.write_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, + (roomData | 0x01 << locationData.bitIndex).to_bytes(1, 'big'), 1) + + except Exception as e: + if self.kh2connected: + logger.info("Line 350") + logger.info("Connection Lost.") + self.kh2connected = False + logger.info(e) + + async def verifyLevel(self): + for leveltype, anchor in {"SoraLevel": 0x24FF, + "ValorLevel": 0x32F6, + "WisdomLevel": 0x332E, + "LimitLevel": 0x3366, + "MasterLevel": 0x339E, + "FinalLevel": 0x33D6}.items(): + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + anchor, 1), "big") < \ + self.kh2seedsave["Levels"][leveltype]: + self.kh2.write_bytes(self.kh2.base_address + self.Save + anchor, + (self.kh2seedsave["Levels"][leveltype]).to_bytes(1, 'big'), 1) + + async def give_item(self, item, ItemType="ServerItems"): + try: + itemname = self.lookup_id_to_item[item] + itemcode = self.item_name_to_data[itemname] + if itemcode.ability: + abilityInvoType = 0 + TwilightZone = 2 + if ItemType == "LocalItems": + abilityInvoType = 1 + TwilightZone = -2 + if itemname in {"High Jump", "Quick Run", "Dodge Roll", "Aerial Dodge", "Glide"}: + self.kh2seedsave["AmountInvo"][ItemType]["Growth"][itemname] += 1 + return + + if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Ability"]: + self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname] = [] + # appending the slot that the ability should be in + + if len(self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname]) < \ + self.AbilityQuantityDict[itemname]: + if itemname in self.sora_ability_set: + self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append( + self.kh2seedsave["SoraInvo"][abilityInvoType]) + self.kh2seedsave["SoraInvo"][abilityInvoType] -= TwilightZone + elif itemname in self.donald_ability_set: + self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append( + self.kh2seedsave["DonaldInvo"][abilityInvoType]) + self.kh2seedsave["DonaldInvo"][abilityInvoType] -= TwilightZone + else: + self.kh2seedsave["AmountInvo"][ItemType]["Ability"][itemname].append( + self.kh2seedsave["GoofyInvo"][abilityInvoType]) + self.kh2seedsave["GoofyInvo"][abilityInvoType] -= TwilightZone + + elif itemcode.code in self.bitmask_item_code: + + if itemname not in self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"]: + self.kh2seedsave["AmountInvo"][ItemType]["Bitmask"].append(itemname) + + elif itemcode.memaddr in {0x3594, 0x3595, 0x3596, 0x3597, 0x35CF, 0x35D0}: + + if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Magic"]: + self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] += 1 + else: + self.kh2seedsave["AmountInvo"][ItemType]["Magic"][itemname] = 1 + elif itemname in self.all_equipment: + + self.kh2seedsave["AmountInvo"][ItemType]["Equipment"].append(itemname) + + elif itemname in self.all_weapons: + if itemname in self.keyblade_set: + self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Sora"].append(itemname) + elif itemname in self.staff_set: + self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Donald"].append(itemname) + else: + self.kh2seedsave["AmountInvo"][ItemType]["Weapon"]["Goofy"].append(itemname) + + elif itemname in self.boost_set: + if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Boost"]: + self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] += 1 + else: + self.kh2seedsave["AmountInvo"][ItemType]["Boost"][itemname] = 1 + + elif itemname in self.stat_increase_set: + + if itemname in self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"]: + self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] += 1 + else: + self.kh2seedsave["AmountInvo"][ItemType]["StatIncrease"][itemname] = 1 + + else: + if itemname in self.kh2seedsave["AmountInvo"][ItemType]["Amount"]: + self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] += 1 + else: + self.kh2seedsave["AmountInvo"][ItemType]["Amount"][itemname] = 1 + + except Exception as e: + if self.kh2connected: + logger.info("Line 398") + logger.info("Connection Lost.") + self.kh2connected = False + logger.info(e) + + def run_gui(self): + """Import kivy UI system and start running it as self.ui_task.""" + from kvui import GameManager + + class KH2Manager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago KH2 Client" + + self.ui = KH2Manager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + async def IsInShop(self, sellable, master_boost): + # journal = 0x741230 shop = 0x741320 + # if journal=-1 and shop = 5 then in shop + # if journam !=-1 and shop = 10 then journal + journal = self.kh2.read_short(self.kh2.base_address + 0x741230) + shop = self.kh2.read_short(self.kh2.base_address + 0x741320) + if (journal == -1 and shop == 5) or (journal != -1 and shop == 10): + # print("your in the shop") + sellable_dict = {} + for itemName in sellable: + itemdata = self.item_name_to_data[itemName] + amount = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big") + sellable_dict[itemName] = amount + while (journal == -1 and shop == 5) or (journal != -1 and shop == 10): + journal = self.kh2.read_short(self.kh2.base_address + 0x741230) + shop = self.kh2.read_short(self.kh2.base_address + 0x741320) + await asyncio.sleep(0.5) + for item, amount in sellable_dict.items(): + itemdata = self.item_name_to_data[item] + afterShop = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + itemdata.memaddr, 1), "big") + if afterShop < amount: + if item in master_boost: + self.kh2seedsave["SoldBoosts"][item] += (amount - afterShop) + else: + self.kh2seedsave["SoldEquipment"].append(item) + + async def verifyItems(self): + try: + local_amount = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"].keys()) + server_amount = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"].keys()) + master_amount = local_amount | server_amount + + local_ability = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"].keys()) + server_ability = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"].keys()) + master_ability = local_ability | server_ability + + local_bitmask = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Bitmask"]) + server_bitmask = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Bitmask"]) + master_bitmask = local_bitmask | server_bitmask + + local_keyblade = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Sora"]) + local_staff = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Donald"]) + local_shield = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Weapon"]["Goofy"]) + + server_keyblade = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Sora"]) + server_staff = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Donald"]) + server_shield = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Weapon"]["Goofy"]) + + master_keyblade = local_keyblade | server_keyblade + master_staff = local_staff | server_staff + master_shield = local_shield | server_shield + + local_equipment = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Equipment"]) + server_equipment = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Equipment"]) + master_equipment = local_equipment | server_equipment + + local_magic = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"].keys()) + server_magic = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"].keys()) + master_magic = local_magic | server_magic + + local_stat = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"].keys()) + server_stat = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"].keys()) + master_stat = local_stat | server_stat + + local_boost = set(self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"].keys()) + server_boost = set(self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"].keys()) + master_boost = local_boost | server_boost + + master_sell = master_equipment | master_staff | master_shield | master_boost + await asyncio.create_task(self.IsInShop(master_sell, master_boost)) + for itemName in master_amount: + itemData = self.item_name_to_data[itemName] + amountOfItems = 0 + if itemName in local_amount: + amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Amount"][itemName] + if itemName in server_amount: + amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Amount"][itemName] + + if itemName == "Torn Page": + # Torn Pages are handled differently because they can be consumed. + # Will check the progression in 100 acre and - the amount of visits + # amountofitems-amount of visits done + for location, data in tornPageLocks.items(): + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + data.addrObtained, 1), + "big") & 0x1 << data.bitIndex > 0: + amountOfItems -= 1 + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != amountOfItems and amountOfItems >= 0: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + amountOfItems.to_bytes(1, 'big'), 1) + + for itemName in master_keyblade: + itemData = self.item_name_to_data[itemName] + # if the inventory slot for that keyblade is less than the amount they should have + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != 1 and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x1CFF, 1), + "big") != 13: + # Checking form anchors for the keyblade + if self.kh2.read_short(self.kh2.base_address + self.Save + 0x24F0) == itemData.kh2id \ + or self.kh2.read_short(self.kh2.base_address + self.Save + 0x32F4) == itemData.kh2id \ + or self.kh2.read_short(self.kh2.base_address + self.Save + 0x339C) == itemData.kh2id \ + or self.kh2.read_short(self.kh2.base_address + self.Save + 0x33D4) == itemData.kh2id: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (0).to_bytes(1, 'big'), 1) + else: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (1).to_bytes(1, 'big'), 1) + for itemName in master_staff: + itemData = self.item_name_to_data[itemName] + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != 1 \ + and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2604) != itemData.kh2id \ + and itemName not in self.kh2seedsave["SoldEquipment"]: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (1).to_bytes(1, 'big'), 1) + + for itemName in master_shield: + itemData = self.item_name_to_data[itemName] + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != 1 \ + and self.kh2.read_short(self.kh2.base_address + self.Save + 0x2718) != itemData.kh2id \ + and itemName not in self.kh2seedsave["SoldEquipment"]: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (1).to_bytes(1, 'big'), 1) + + for itemName in master_ability: + itemData = self.item_name_to_data[itemName] + ability_slot = [] + if itemName in local_ability: + ability_slot += self.kh2seedsave["AmountInvo"]["LocalItems"]["Ability"][itemName] + if itemName in server_ability: + ability_slot += self.kh2seedsave["AmountInvo"]["ServerItems"]["Ability"][itemName] + for slot in ability_slot: + current = self.kh2.read_short(self.kh2.base_address + self.Save + slot) + ability = current & 0x0FFF + if ability | 0x8000 != (0x8000 + itemData.memaddr): + if current - 0x8000 > 0: + self.kh2.write_short(self.kh2.base_address + self.Save + slot, (0x8000 + itemData.memaddr)) + else: + self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr) + # removes the duped ability if client gave faster than the game. + for charInvo in {"SoraInvo", "DonaldInvo", "GoofyInvo"}: + if self.kh2.read_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1]) != 0 and \ + self.kh2seedsave[charInvo][1] + 2 < self.kh2seedsave[charInvo][0]: + self.kh2.write_short(self.kh2.base_address + self.Save + self.kh2seedsave[charInvo][1], 0) + # remove the dummy level 1 growths if they are in these invo slots. + for inventorySlot in {0x25CE, 0x25D0, 0x25D2, 0x25D4, 0x25D6, 0x25D8}: + current = self.kh2.read_short(self.kh2.base_address + self.Save + inventorySlot) + ability = current & 0x0FFF + if 0x05E <= ability <= 0x06D: + self.kh2.write_short(self.kh2.base_address + self.Save + inventorySlot, 0) + + for itemName in self.master_growth: + growthLevel = self.kh2seedsave["AmountInvo"]["ServerItems"]["Growth"][itemName] \ + + self.kh2seedsave["AmountInvo"]["LocalItems"]["Growth"][itemName] + if growthLevel > 0: + slot = self.growth_values_dict[itemName][2] + min_growth = self.growth_values_dict[itemName][0] + max_growth = self.growth_values_dict[itemName][1] + if growthLevel > 4: + growthLevel = 4 + current_growth_level = self.kh2.read_short(self.kh2.base_address + self.Save + slot) + ability = current_growth_level & 0x0FFF + # if the player should be getting a growth ability + if ability | 0x8000 != 0x8000 + min_growth - 1 + growthLevel: + # if it should be level one of that growth + if 0x8000 + min_growth - 1 + growthLevel <= 0x8000 + min_growth or ability < min_growth: + self.kh2.write_short(self.kh2.base_address + self.Save + slot, min_growth) + # if it is already in the inventory + elif ability | 0x8000 < (0x8000 + max_growth): + self.kh2.write_short(self.kh2.base_address + self.Save + slot, current_growth_level + 1) + + for itemName in master_bitmask: + itemData = self.item_name_to_data[itemName] + itemMemory = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), "big") + if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") & 0x1 << itemData.bitmask) == 0: + # when getting a form anti points should be reset to 0 but bit-shift doesn't trigger the game. + if itemName in {"Valor Form", "Wisdom Form", "Limit Form", "Master Form", "Final Form"}: + self.kh2.write_bytes(self.kh2.base_address + self.Save + 0x3410, + (0).to_bytes(1, 'big'), 1) + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (itemMemory | 0x01 << itemData.bitmask).to_bytes(1, 'big'), 1) + + for itemName in master_equipment: + itemData = self.item_name_to_data[itemName] + isThere = False + if itemName in self.accessories_set: + Equipment_Anchor_List = self.Equipment_Anchor_Dict["Accessories"] + else: + Equipment_Anchor_List = self.Equipment_Anchor_Dict["Armor"] + # Checking form anchors for the equipment + for slot in Equipment_Anchor_List: + if self.kh2.read_short(self.kh2.base_address + self.Save + slot) == itemData.kh2id: + isThere = True + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != 0: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (0).to_bytes(1, 'big'), 1) + break + if not isThere and itemName not in self.kh2seedsave["SoldEquipment"]: + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != 1: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (1).to_bytes(1, 'big'), 1) + + for itemName in master_magic: + itemData = self.item_name_to_data[itemName] + amountOfItems = 0 + if itemName in local_magic: + amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Magic"][itemName] + if itemName in server_magic: + amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Magic"][itemName] + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != amountOfItems \ + and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x741320, 1), "big") in {10, 8}: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + amountOfItems.to_bytes(1, 'big'), 1) + + for itemName in master_stat: + itemData = self.item_name_to_data[itemName] + amountOfItems = 0 + if itemName in local_stat: + amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["StatIncrease"][itemName] + if itemName in server_stat: + amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["StatIncrease"][itemName] + + # 0x130293 is Crit_1's location id for touching the computer + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") != amountOfItems \ + and int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Slot1 + 0x1B2, 1), + "big") >= 5 and int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x23DF, 1), + "big") > 0: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + amountOfItems.to_bytes(1, 'big'), 1) + + for itemName in master_boost: + itemData = self.item_name_to_data[itemName] + amountOfItems = 0 + if itemName in local_boost: + amountOfItems += self.kh2seedsave["AmountInvo"]["LocalItems"]["Boost"][itemName] + if itemName in server_boost: + amountOfItems += self.kh2seedsave["AmountInvo"]["ServerItems"]["Boost"][itemName] + amountOfBoostsInInvo = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + itemData.memaddr, 1), + "big") + amountOfUsedBoosts = int.from_bytes( + self.kh2.read_bytes(self.kh2.base_address + self.Save + self.boost_to_anchor_dict[itemName], 1), + "big") + # Ap Boots start at +50 for some reason + if itemName == "AP Boost": + amountOfUsedBoosts -= 50 + totalBoosts = (amountOfBoostsInInvo + amountOfUsedBoosts) + if totalBoosts <= amountOfItems - self.kh2seedsave["SoldBoosts"][ + itemName] and amountOfBoostsInInvo < 255: + self.kh2.write_bytes(self.kh2.base_address + self.Save + itemData.memaddr, + (amountOfBoostsInInvo + 1).to_bytes(1, 'big'), 1) + + except Exception as e: + logger.info("Line 573") + if self.kh2connected: + logger.info("Connection Lost.") + self.kh2connected = False + logger.info(e) + + +def finishedGame(ctx: KH2Context, message): + if ctx.kh2slotdata['FinalXemnas'] == 1: + if 0x1301ED in message[0]["locations"]: + ctx.finalxemnas = True + # three proofs + if ctx.kh2slotdata['Goal'] == 0: + if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, 1), "big") > 0 \ + and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, 1), "big") > 0 \ + and int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, 1), "big") > 0: + if ctx.kh2slotdata['FinalXemnas'] == 1: + if ctx.finalxemnas: + return True + else: + return False + else: + return True + else: + return False + elif ctx.kh2slotdata['Goal'] == 1: + if int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + ctx.Save + 0x3641, 1), "big") >= \ + ctx.kh2slotdata['LuckyEmblemsRequired']: + ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1) + ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1) + ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1) + if ctx.kh2slotdata['FinalXemnas'] == 1: + if ctx.finalxemnas: + return True + else: + return False + else: + return True + else: + return False + elif ctx.kh2slotdata['Goal'] == 2: + for boss in ctx.kh2slotdata["hitlist"]: + if boss in message[0]["locations"]: + ctx.amountOfPieces += 1 + if ctx.amountOfPieces >= ctx.kh2slotdata["BountyRequired"]: + ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B2, (1).to_bytes(1, 'big'), 1) + ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B3, (1).to_bytes(1, 'big'), 1) + ctx.kh2.write_bytes(ctx.kh2.base_address + ctx.Save + 0x36B4, (1).to_bytes(1, 'big'), 1) + if ctx.kh2slotdata['FinalXemnas'] == 1: + if ctx.finalxemnas: + return True + else: + return False + else: + return True + else: + return False + + +async def kh2_watcher(ctx: KH2Context): + while not ctx.exit_event.is_set(): + try: + if ctx.kh2connected and ctx.serverconneced: + ctx.sending = [] + await asyncio.create_task(ctx.checkWorldLocations()) + await asyncio.create_task(ctx.checkLevels()) + await asyncio.create_task(ctx.checkSlots()) + await asyncio.create_task(ctx.verifyChests()) + await asyncio.create_task(ctx.verifyItems()) + await asyncio.create_task(ctx.verifyLevel()) + message = [{"cmd": 'LocationChecks', "locations": ctx.sending}] + if finishedGame(ctx, message): + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + location_ids = [] + location_ids = [location for location in message[0]["locations"] if location not in location_ids] + for location in location_ids: + if location not in ctx.locations_checked: + ctx.locations_checked.add(location) + ctx.kh2seedsave["LocationsChecked"].append(location) + if location in ctx.kh2LocalItems: + item = ctx.kh2slotdata["LocalItems"][str(location)] + await asyncio.create_task(ctx.give_item(item, "LocalItems")) + await ctx.send_msgs(message) + elif not ctx.kh2connected and ctx.serverconneced: + logger.info("Game is not open. Disconnecting from Server.") + await ctx.disconnect() + except Exception as e: + logger.info("Line 661") + if ctx.kh2connected: + logger.info("Connection Lost.") + ctx.kh2connected = False + logger.info(e) + await asyncio.sleep(0.5) + + +if __name__ == '__main__': + async def main(args): + ctx = KH2Context(args.connect, args.password) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + progression_watcher = asyncio.create_task( + kh2_watcher(ctx), name="KH2ProgressionWatcher") + + await ctx.exit_event.wait() + ctx.server_address = None + + await progression_watcher + + await ctx.shutdown() + + + import colorama + + parser = get_base_parser(description="KH2 Client, for text interfacing.") + + args, rest = parser.parse_known_args() + colorama.init() + asyncio.run(main(args)) + colorama.deinit() diff --git a/Launcher.py b/Launcher.py index c4d9b6fea0e3..a1548d594ce8 100644 --- a/Launcher.py +++ b/Launcher.py @@ -11,13 +11,19 @@ import argparse import itertools +import logging +import multiprocessing import shlex import subprocess import sys -from enum import Enum, auto +import webbrowser from os.path import isfile from shutil import which -from typing import Iterable, Sequence, Callable, Union, Optional +from typing import Sequence, Union, Optional + +import Utils +import settings +from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths if __name__ == "__main__": import ModuleUpdate @@ -28,7 +34,8 @@ def open_host_yaml(): - file = user_path('host.yaml') + file = settings.get_settings().filename + assert file, "host.yaml missing" if is_linux: exe = which('sensible-editor') or which('gedit') or \ which('xdg-open') or which('gnome-open') or which('kde-open') @@ -37,7 +44,6 @@ def open_host_yaml(): exe = which("open") subprocess.Popen([exe, file]) else: - import webbrowser webbrowser.open(file) @@ -52,131 +58,59 @@ def open_patch(): except Exception as e: messagebox('Error', str(e), error=True) else: - file, _, component = identify(filename) + file, component = identify(filename) if file and component: launch([*get_exe(component), file], component.cli) +def generate_yamls(): + from Options import generate_yaml_templates + + target = Utils.user_path("Players", "Templates") + generate_yaml_templates(target, False) + open_folder(target) + + def browse_files(): - file = user_path() + open_folder(user_path()) + + +def open_folder(folder_path): if is_linux: exe = which('xdg-open') or which('gnome-open') or which('kde-open') - subprocess.Popen([exe, file]) + subprocess.Popen([exe, folder_path]) elif is_macos: exe = which("open") - subprocess.Popen([exe, file]) + subprocess.Popen([exe, folder_path]) else: - import webbrowser - webbrowser.open(file) + webbrowser.open(folder_path) + +def update_settings(): + from settings import get_settings + get_settings().save() -# noinspection PyArgumentList -class Type(Enum): - TOOL = auto() - FUNC = auto() # not a real component - CLIENT = auto() - ADJUSTER = auto() - - -class SuffixIdentifier: - suffixes: Iterable[str] - - def __init__(self, *args: str): - self.suffixes = args - - def __call__(self, path: str): - if isinstance(path, str): - for suffix in self.suffixes: - if path.endswith(suffix): - return True - return False - - -class Component: - display_name: str - type: Optional[Type] - script_name: Optional[str] - frozen_name: Optional[str] - icon: str # just the name, no suffix - cli: bool - func: Optional[Callable] - file_identifier: Optional[Callable[[str], bool]] - - def __init__(self, display_name: str, script_name: Optional[str] = None, frozen_name: Optional[str] = None, - cli: bool = False, icon: str = 'icon', component_type: Type = None, func: Optional[Callable] = None, - file_identifier: Optional[Callable[[str], bool]] = None): - self.display_name = display_name - self.script_name = script_name - self.frozen_name = frozen_name or f'Archipelago{script_name}' if script_name else None - self.icon = icon - self.cli = cli - self.type = component_type or \ - None if not display_name else \ - Type.FUNC if func else \ - Type.CLIENT if 'Client' in display_name else \ - Type.ADJUSTER if 'Adjuster' in display_name else Type.TOOL - self.func = func - self.file_identifier = file_identifier - - def handles_file(self, path: str): - return self.file_identifier(path) if self.file_identifier else False - - -components: Iterable[Component] = ( - # Launcher - Component('', 'Launcher'), - # Core - Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True, - file_identifier=SuffixIdentifier('.archipelago', '.zip')), - Component('Generate', 'Generate', cli=True), - Component('Text Client', 'CommonClient', 'ArchipelagoTextClient'), - # SNI - Component('SNI Client', 'SNIClient', - file_identifier=SuffixIdentifier('.apz3', '.apm3', '.apsoe', '.aplttp', '.apsm', '.apsmz3', '.apdkc3', - '.apsmw', '.apl2ac')), - Component('LttP Adjuster', 'LttPAdjuster'), - # Factorio - Component('Factorio Client', 'FactorioClient'), - # Minecraft - Component('Minecraft Client', 'MinecraftClient', icon='mcicon', cli=True, - file_identifier=SuffixIdentifier('.apmc')), - # Ocarina of Time - Component('OoT Client', 'OoTClient', - file_identifier=SuffixIdentifier('.apz5')), - Component('OoT Adjuster', 'OoTAdjuster'), - # FF1 - Component('FF1 Client', 'FF1Client'), - # Pokémon - Component('Pokemon Client', 'PokemonClient', file_identifier=SuffixIdentifier('.apred', '.apblue')), - # TLoZ - Component('Zelda 1 Client', 'Zelda1Client'), - # ChecksFinder - Component('ChecksFinder Client', 'ChecksFinderClient'), - # Starcraft 2 - Component('Starcraft 2 Client', 'Starcraft2Client'), - # Wargroove - Component('Wargroove Client', 'WargrooveClient'), - # Zillion - Component('Zillion Client', 'ZillionClient', - file_identifier=SuffixIdentifier('.apzl')), + +components.extend([ # Functions - Component('Open host.yaml', func=open_host_yaml), - Component('Open Patch', func=open_patch), - Component('Browse Files', func=browse_files), -) -icon_paths = { - 'icon': local_path('data', 'icon.ico' if is_windows else 'icon.png'), - 'mcicon': local_path('data', 'mcicon.ico') -} + Component("Open host.yaml", func=open_host_yaml), + Component("Open Patch", func=open_patch), + Component("Generate Template Settings", func=generate_yamls), + Component("Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/8Z65BR2")), + Component("18+ Discord Server", icon="discord", func=lambda: webbrowser.open("https://discord.gg/fqvNCCRsu4")), + Component("Browse Files", func=browse_files), +]) def identify(path: Union[None, str]): if path is None: - return None, None, None + return None, None for component in components: if component.handles_file(path): - return path, component.script_name, component - return (None, None, None) if '/' in path or '\\' in path else (None, path, None) + return path, component + elif path == component.display_name or path == component.script_name: + return None, component + return None, None def get_exe(component: Union[str, Component]) -> Optional[Sequence[str]]: @@ -223,16 +157,18 @@ def launch(exe, in_terminal=False): def run_gui(): from kvui import App, ContainerLayout, GridLayout, Button, Label + from kivy.uix.image import AsyncImage + from kivy.uix.relativelayout import RelativeLayout class Launcher(App): base_title: str = "Archipelago Launcher" container: ContainerLayout grid: GridLayout - _tools = {c.display_name: c for c in components if c.type == Type.TOOL and isfile(get_exe(c)[-1])} - _clients = {c.display_name: c for c in components if c.type == Type.CLIENT and isfile(get_exe(c)[-1])} - _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER and isfile(get_exe(c)[-1])} - _funcs = {c.display_name: c for c in components if c.type == Type.FUNC} + _tools = {c.display_name: c for c in components if c.type == Type.TOOL} + _clients = {c.display_name: c for c in components if c.type == Type.CLIENT} + _adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER} + _miscs = {c.display_name: c for c in components if c.type == Type.MISC} def __init__(self, ctx=None): self.title = self.base_title @@ -244,24 +180,44 @@ def build(self): self.container = ContainerLayout() self.grid = GridLayout(cols=2) self.container.add_widget(self.grid) - + self.grid.add_widget(Label(text="General")) + self.grid.add_widget(Label(text="Clients")) button_layout = self.grid # make buttons fill the window + + def build_button(component: Component): + """ + Builds a button widget for a given component. + + Args: + component (Component): The component associated with the button. + + Returns: + None. The button is added to the parent grid layout. + + """ + button = Button(text=component.display_name) + button.component = component + button.bind(on_release=self.component_action) + if component.icon != "icon": + image = AsyncImage(source=icon_paths[component.icon], + size=(38, 38), size_hint=(None, 1), pos=(5, 0)) + box_layout = RelativeLayout() + box_layout.add_widget(button) + box_layout.add_widget(image) + button_layout.add_widget(box_layout) + else: + button_layout.add_widget(button) + for (tool, client) in itertools.zip_longest(itertools.chain( - self._tools.items(), self._funcs.items(), self._adjusters.items()), self._clients.items()): + self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()): # column 1 if tool: - button = Button(text=tool[0]) - button.component = tool[1] - button.bind(on_release=self.component_action) - button_layout.add_widget(button) + build_button(tool[1]) else: button_layout.add_widget(Label()) # column 2 if client: - button = Button(text=client[0]) - button.component = client[1] - button.bind(on_press=self.component_action) - button_layout.add_widget(button) + build_button(client[1]) else: button_layout.add_widget(Label()) @@ -269,14 +225,29 @@ def build(self): @staticmethod def component_action(button): - if button.component.type == Type.FUNC: + if button.component.func: button.component.func() else: launch(get_exe(button.component), button.component.cli) + def _stop(self, *largs): + # ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm. + # Closing the window explicitly cleans it up. + self.root_window.close() + super()._stop(*largs) + Launcher().run() +def run_component(component: Component, *args): + if component.func: + component.func(*args) + elif component.script_name: + subprocess.run([*get_exe(component.script_name), *args]) + else: + logging.warning(f"Component {component} does not appear to be executable.") + + def main(args: Optional[Union[argparse.Namespace, dict]] = None): if isinstance(args, argparse.Namespace): args = {k: v for k, v in args._get_kwargs()} @@ -284,24 +255,40 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): args = {} if "Patch|Game|Component" in args: - file, component, _ = identify(args["Patch|Game|Component"]) + file, component = identify(args["Patch|Game|Component"]) if file: args['file'] = file if component: args['component'] = component + if not component: + logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") + if args["update_settings"]: + update_settings() if 'file' in args: - subprocess.run([*get_exe(args['component']), args['file'], *args['args']]) + run_component(args["component"], args["file"], *args["args"]) elif 'component' in args: - subprocess.run([*get_exe(args['component']), *args['args']]) - else: + run_component(args["component"], *args["args"]) + elif not args["update_settings"]: run_gui() if __name__ == '__main__': init_logging('Launcher') + Utils.freeze_support() + multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work parser = argparse.ArgumentParser(description='Archipelago Launcher') - parser.add_argument('Patch|Game|Component', type=str, nargs='?', - help="Pass either a patch file, a generated game or the name of a component to run.") - parser.add_argument('args', nargs="*", help="Arguments to pass to component.") + run_group = parser.add_argument_group("Run") + run_group.add_argument("--update_settings", action="store_true", + help="Update host.yaml and exit.") + run_group.add_argument("Patch|Game|Component", type=str, nargs="?", + help="Pass either a patch file, a generated game or the name of a component to run.") + run_group.add_argument("args", nargs="*", + help="Arguments to pass to component.") main(parser.parse_args()) + + from worlds.LauncherComponents import processes + for process in processes: + # we await all child processes to close before we tear down the process host + # this makes it feel like each one is its own program, as the Launcher is closed now + process.join() diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py new file mode 100644 index 000000000000..f3fc9d2cdb72 --- /dev/null +++ b/LinksAwakeningClient.py @@ -0,0 +1,700 @@ +import ModuleUpdate +ModuleUpdate.update() + +import Utils + +if __name__ == "__main__": + Utils.init_logging("LinksAwakeningContext", exception_logger="Client") + +import asyncio +import base64 +import binascii +import colorama +import io +import os +import re +import select +import shlex +import socket +import struct +import sys +import subprocess +import time +import typing + + +from CommonClient import (CommonContext, get_base_parser, gui_enabled, logger, + server_loop) +from NetUtils import ClientStatus +from worlds.ladx.Common import BASE_ID as LABaseID +from worlds.ladx.GpsTracker import GpsTracker +from worlds.ladx.ItemTracker import ItemTracker +from worlds.ladx.LADXR.checkMetadata import checkMetadataTable +from worlds.ladx.Locations import get_locations_to_id, meta_to_name +from worlds.ladx.Tracker import LocationTracker, MagpieBridge + + +class GameboyException(Exception): + pass + + +class RetroArchDisconnectError(GameboyException): + pass + + +class InvalidEmulatorStateError(GameboyException): + pass + + +class BadRetroArchResponse(GameboyException): + pass + + +def magpie_logo(): + from kivy.uix.image import CoreImage + binary_data = """ +iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAAAXN +SR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA +7DAcdvqGQAAADGSURBVDhPhVLBEcIwDHOYhjHCBuXHj2OTbAL8+ +MEGZIxOQ1CinOOk0Op0bmo7tlXXeR9FJMYDLOD9mwcLjQK7+hSZ +wgcWMZJOAGeGKtChNHFL0j+FZD3jSCuo0w7l03wDrWdg00C4/aW +eDEYNenuzPOfPspBnxf0kssE80vN0L8361j10P03DK4x6FHabuV +ear8fHme+b17rwSjbAXeUMLb+EVTV2QHm46MWQanmnydA98KsVS +XkV+qFpGQXrLhT/fqraQeQLuplpNH5g+WkAAAAASUVORK5CYII=""" + binary_data = base64.b64decode(binary_data) + data = io.BytesIO(binary_data) + return CoreImage(data, ext="png").texture + + +class LAClientConstants: + # Connector version + VERSION = 0x01 + # + # Memory locations of LADXR + ROMGameID = 0x0051 # 4 bytes + SlotName = 0x0134 + # Unused + # ROMWorldID = 0x0055 + # ROMConnectorVersion = 0x0056 + # RO: We should only act if this is higher then 6, as it indicates that the game is running normally + wGameplayType = 0xDB95 + # RO: Starts at 0, increases every time an item is received from the server and processed + wLinkSyncSequenceNumber = 0xDDF6 + wLinkStatusBits = 0xDDF7 # RW: + # Bit0: wLinkGive* contains valid data, set from script cleared from ROM. + wLinkHealth = 0xDB5A + wLinkGiveItem = 0xDDF8 # RW + wLinkGiveItemFrom = 0xDDF9 # RW + # All of these six bytes are unused, we can repurpose + # wLinkSendItemRoomHigh = 0xDDFA # RO + # wLinkSendItemRoomLow = 0xDDFB # RO + # wLinkSendItemTarget = 0xDDFC # RO + # wLinkSendItemItem = 0xDDFD # RO + # wLinkSendShopItem = 0xDDFE # RO, which item to send (1 based, order of the shop items) + # RO, which player to send to, but it's just the X position of the NPC used, so 0x18 is player 0 + # wLinkSendShopTarget = 0xDDFF + + + wRecvIndex = 0xDDFD # Two bytes + wCheckAddress = 0xC0FF - 0x4 + WRamCheckSize = 0x4 + WRamSafetyValue = bytearray([0]*WRamCheckSize) + + MinGameplayValue = 0x06 + MaxGameplayValue = 0x1A + VictoryGameplayAndSub = 0x0102 + + +class RAGameboy(): + cache = [] + cache_start = 0 + cache_size = 0 + last_cache_read = None + socket = None + + def __init__(self, address, port) -> None: + self.address = address + self.port = port + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + assert (self.socket) + self.socket.setblocking(False) + + async def send_command(self, command, timeout=1.0): + self.send(f'{command}\n') + response_str = await self.async_recv() + self.check_command_response(command, response_str) + return response_str.rstrip() + + async def get_retroarch_version(self): + return await self.send_command("VERSION") + + async def get_retroarch_status(self): + return await self.send_command("GET_STATUS") + + def set_cache_limits(self, cache_start, cache_size): + self.cache_start = cache_start + self.cache_size = cache_size + + def send(self, b): + if type(b) is str: + b = b.encode('ascii') + self.socket.sendto(b, (self.address, self.port)) + + def recv(self): + select.select([self.socket], [], []) + response, _ = self.socket.recvfrom(4096) + return response + + async def async_recv(self, timeout=1.0): + response = await asyncio.wait_for(asyncio.get_event_loop().sock_recv(self.socket, 4096), timeout) + return response + + async def check_safe_gameplay(self, throw=True): + async def check_wram(): + check_values = await self.async_read_memory(LAClientConstants.wCheckAddress, LAClientConstants.WRamCheckSize) + + if check_values != LAClientConstants.WRamSafetyValue: + if throw: + raise InvalidEmulatorStateError() + return False + return True + + if not await check_wram(): + if throw: + raise InvalidEmulatorStateError() + return False + + gameplay_value = await self.async_read_memory(LAClientConstants.wGameplayType) + gameplay_value = gameplay_value[0] + # In gameplay or credits + if not (LAClientConstants.MinGameplayValue <= gameplay_value <= LAClientConstants.MaxGameplayValue) and gameplay_value != 0x1: + if throw: + logger.info("invalid emu state") + raise InvalidEmulatorStateError() + return False + if not await check_wram(): + if throw: + raise InvalidEmulatorStateError() + return False + return True + + # We're sadly unable to update the whole cache at once + # as RetroArch only gives back some number of bytes at a time + # So instead read as big as chunks at a time as we can manage + async def update_cache(self): + # First read the safety address - if it's invalid, bail + self.cache = [] + + if not await self.check_safe_gameplay(): + return + + cache = [] + remaining_size = self.cache_size + while remaining_size: + block = await self.async_read_memory(self.cache_start + len(cache), remaining_size) + remaining_size -= len(block) + cache += block + + if not await self.check_safe_gameplay(): + return + + self.cache = cache + self.last_cache_read = time.time() + + async def read_memory_cache(self, addresses): + # TODO: can we just update once per frame? + if not self.last_cache_read or self.last_cache_read + 0.1 < time.time(): + await self.update_cache() + if not self.cache: + return None + assert (len(self.cache) == self.cache_size) + for address in addresses: + assert self.cache_start <= address <= self.cache_start + self.cache_size + r = {address: self.cache[address - self.cache_start] + for address in addresses} + return r + + async def async_read_memory_safe(self, address, size=1): + # whenever we do a read for a check, we need to make sure that we aren't reading + # garbage memory values - we also need to protect against reading a value, then the emulator resetting + # + # ...actually, we probably _only_ need the post check + + # Check before read + if not await self.check_safe_gameplay(): + return None + + # Do read + r = await self.async_read_memory(address, size) + + # Check after read + if not await self.check_safe_gameplay(): + return None + + return r + + def check_command_response(self, command: str, response: bytes): + if command == "VERSION": + ok = re.match("\d+\.\d+\.\d+", response.decode('ascii')) is not None + else: + ok = response.startswith(command.encode()) + if not ok: + logger.warning(f"Bad response to command {command} - {response}") + raise BadRetroArchResponse() + + def read_memory(self, address, size=1): + command = "READ_CORE_MEMORY" + + self.send(f'{command} {hex(address)} {size}\n') + response = self.recv() + + self.check_command_response(command, response) + + splits = response.decode().split(" ", 2) + # Ignore the address for now + if splits[2][:2] == "-1": + raise BadRetroArchResponse() + + # TODO: check response address, check hex behavior between RA and BH + + return bytearray.fromhex(splits[2]) + + async def async_read_memory(self, address, size=1): + command = "READ_CORE_MEMORY" + + self.send(f'{command} {hex(address)} {size}\n') + response = await self.async_recv() + self.check_command_response(command, response) + response = response[:-1] + splits = response.decode().split(" ", 2) + try: + response_addr = int(splits[1], 16) + except ValueError: + raise BadRetroArchResponse() + + if response_addr != address: + raise BadRetroArchResponse() + + ret = bytearray.fromhex(splits[2]) + if len(ret) > size: + raise BadRetroArchResponse() + return ret + + def write_memory(self, address, bytes): + command = "WRITE_CORE_MEMORY" + + self.send(f'{command} {hex(address)} {" ".join(hex(b) for b in bytes)}') + select.select([self.socket], [], []) + response, _ = self.socket.recvfrom(4096) + self.check_command_response(command, response) + splits = response.decode().split(" ", 3) + + assert (splits[0] == command) + + if splits[2] == "-1": + logger.info(splits[3]) + + +class LinksAwakeningClient(): + socket = None + gameboy = None + tracker = None + auth = None + game_crc = None + pending_deathlink = False + deathlink_debounce = True + recvd_checks = {} + retroarch_address = None + retroarch_port = None + gameboy = None + + def msg(self, m): + logger.info(m) + s = f"SHOW_MSG {m}\n" + self.gameboy.send(s) + + def __init__(self, retroarch_address="127.0.0.1", retroarch_port=55355): + self.retroarch_address = retroarch_address + self.retroarch_port = retroarch_port + pass + + stop_bizhawk_spam = False + async def wait_for_retroarch_connection(self): + if not self.stop_bizhawk_spam: + logger.info("Waiting on connection to Retroarch...") + self.stop_bizhawk_spam = True + self.gameboy = RAGameboy(self.retroarch_address, self.retroarch_port) + + while True: + try: + version = await self.gameboy.get_retroarch_version() + NO_CONTENT = b"GET_STATUS CONTENTLESS" + status = NO_CONTENT + core_type = None + GAME_BOY = b"game_boy" + while status == NO_CONTENT or core_type != GAME_BOY: + status = await self.gameboy.get_retroarch_status() + if status.count(b" ") < 2: + await asyncio.sleep(1.0) + continue + GET_STATUS, PLAYING, info = status.split(b" ", 2) + if status.count(b",") < 2: + await asyncio.sleep(1.0) + continue + core_type, rom_name, self.game_crc = info.split(b",", 2) + if core_type != GAME_BOY: + logger.info( + f"Core type should be '{GAME_BOY}', found {core_type} instead - wrong type of ROM?") + await asyncio.sleep(1.0) + continue + self.stop_bizhawk_spam = False + logger.info(f"Connected to Retroarch {version.decode('ascii')} running {rom_name.decode('ascii')}") + return + except (BlockingIOError, TimeoutError, ConnectionResetError): + await asyncio.sleep(1.0) + pass + + async def reset_auth(self): + auth = binascii.hexlify(await self.gameboy.async_read_memory(0x0134, 12)).decode() + self.auth = auth + + async def wait_and_init_tracker(self): + await self.wait_for_game_ready() + self.tracker = LocationTracker(self.gameboy) + self.item_tracker = ItemTracker(self.gameboy) + self.gps_tracker = GpsTracker(self.gameboy) + + async def recved_item_from_ap(self, item_id, from_player, next_index): + # Don't allow getting an item until you've got your first check + if not self.tracker.has_start_item(): + return + + # Spin until we either: + # get an exception from a bad read (emu shut down or reset) + # beat the game + # the client handles the last pending item + status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0] + while not (await self.is_victory()) and status & 1 == 1: + time.sleep(0.1) + status = (await self.gameboy.async_read_memory_safe(LAClientConstants.wLinkStatusBits))[0] + + item_id -= LABaseID + # The player name table only goes up to 100, so don't go past that + # Even if it didn't, the remote player _index_ byte is just a byte, so 255 max + if from_player > 100: + from_player = 100 + + next_index += 1 + self.gameboy.write_memory(LAClientConstants.wLinkGiveItem, [ + item_id, from_player]) + status |= 1 + status = self.gameboy.write_memory(LAClientConstants.wLinkStatusBits, [status]) + self.gameboy.write_memory(LAClientConstants.wRecvIndex, struct.pack(">H", next_index)) + + should_reset_auth = False + async def wait_for_game_ready(self): + logger.info("Waiting on game to be in valid state...") + while not await self.gameboy.check_safe_gameplay(throw=False): + if self.should_reset_auth: + self.should_reset_auth = False + raise GameboyException("Resetting due to wrong archipelago server") + logger.info("Game connection ready!") + + async def is_victory(self): + return (await self.gameboy.read_memory_cache([LAClientConstants.wGameplayType]))[LAClientConstants.wGameplayType] == 1 + + async def main_tick(self, item_get_cb, win_cb, deathlink_cb): + await self.tracker.readChecks(item_get_cb) + await self.item_tracker.readItems() + await self.gps_tracker.read_location() + + current_health = (await self.gameboy.read_memory_cache([LAClientConstants.wLinkHealth]))[LAClientConstants.wLinkHealth] + if self.deathlink_debounce and current_health != 0: + self.deathlink_debounce = False + elif not self.deathlink_debounce and current_health == 0: + # logger.info("YOU DIED.") + await deathlink_cb() + self.deathlink_debounce = True + + if self.pending_deathlink: + logger.info("Got a deathlink") + self.gameboy.write_memory(LAClientConstants.wLinkHealth, [0]) + self.pending_deathlink = False + self.deathlink_debounce = True + + if await self.is_victory(): + await win_cb() + + recv_index = struct.unpack(">H", await self.gameboy.async_read_memory(LAClientConstants.wRecvIndex, 2))[0] + + # Play back one at a time + if recv_index in self.recvd_checks: + item = self.recvd_checks[recv_index] + await self.recved_item_from_ap(item.item, item.player, recv_index) + + +all_tasks = set() + +def create_task_log_exception(awaitable) -> asyncio.Task: + async def _log_exception(awaitable): + try: + return await awaitable + except Exception as e: + logger.exception(e) + pass + finally: + all_tasks.remove(task) + task = asyncio.create_task(_log_exception(awaitable)) + all_tasks.add(task) + + +class LinksAwakeningContext(CommonContext): + tags = {"AP"} + game = "Links Awakening DX" + items_handling = 0b101 + want_slot_data = True + la_task = None + client = None + # TODO: does this need to re-read on reset? + found_checks = [] + last_resend = time.time() + + magpie_enabled = False + magpie = None + magpie_task = None + won = False + + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str], magpie: typing.Optional[bool]) -> None: + self.client = LinksAwakeningClient() + if magpie: + self.magpie_enabled = True + self.magpie = MagpieBridge() + super().__init__(server_address, password) + + def run_gui(self) -> None: + import webbrowser + import kvui + from kvui import Button, GameManager + from kivy.uix.image import Image + + class LADXManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago"), + ("Tracker", "Tracker"), + ] + base_title = "Archipelago Links Awakening DX Client" + + def build(self): + b = super().build() + + if self.ctx.magpie_enabled: + button = Button(text="", size=(30, 30), size_hint_x=None, + on_press=lambda _: webbrowser.open('https://magpietracker.us/?enable_autotracker=1')) + image = Image(size=(16, 16), texture=magpie_logo()) + button.add_widget(image) + + def set_center(_, center): + image.center = center + button.bind(center=set_center) + + self.connect_layout.add_widget(button) + return b + + self.ui = LADXManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + async def send_checks(self): + message = [{"cmd": 'LocationChecks', "locations": self.found_checks}] + await self.send_msgs(message) + + had_invalid_slot_data = None + def event_invalid_slot(self): + # The next time we try to connect, reset the game loop for new auth + self.had_invalid_slot_data = True + self.auth = None + # Don't try to autoreconnect, it will just fail + self.disconnected_intentionally = True + CommonContext.event_invalid_slot(self) + + ENABLE_DEATHLINK = False + async def send_deathlink(self): + if self.ENABLE_DEATHLINK: + message = [{"cmd": 'Deathlink', + 'time': time.time(), + 'cause': 'Had a nightmare', + # 'source': self.slot_info[self.slot].name, + }] + await self.send_msgs(message) + + async def send_victory(self): + if not self.won: + message = [{"cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL}] + logger.info("victory!") + await self.send_msgs(message) + self.won = True + + async def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: + if self.ENABLE_DEATHLINK: + self.client.pending_deathlink = True + + def new_checks(self, item_ids, ladxr_ids): + self.found_checks += item_ids + create_task_log_exception(self.send_checks()) + if self.magpie_enabled: + create_task_log_exception(self.magpie.send_new_checks(ladxr_ids)) + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(LinksAwakeningContext, self).server_auth(password_requested) + + if self.had_invalid_slot_data: + # We are connecting when previously we had the wrong ROM or server - just in case + # re-read the ROM so that if the user had the correct address but wrong ROM, we + # allow a successful reconnect + self.client.should_reset_auth = True + self.had_invalid_slot_data = False + + while self.client.auth == None: + await asyncio.sleep(0.1) + self.auth = self.client.auth + await self.send_connect() + + def on_package(self, cmd: str, args: dict): + if cmd == "Connected": + self.game = self.slot_info[self.slot].game + # TODO - use watcher_event + if cmd == "ReceivedItems": + for index, item in enumerate(args["items"], start=args["index"]): + self.client.recvd_checks[index] = item + + async def sync(self): + sync_msg = [{'cmd': 'Sync'}] + await self.send_msgs(sync_msg) + + item_id_lookup = get_locations_to_id() + + async def run_game_loop(self): + def on_item_get(ladxr_checks): + checks = [self.item_id_lookup[meta_to_name( + checkMetadataTable[check.id])] for check in ladxr_checks] + self.new_checks(checks, [check.id for check in ladxr_checks]) + + async def victory(): + await self.send_victory() + + async def deathlink(): + await self.send_deathlink() + + if self.magpie_enabled: + self.magpie_task = asyncio.create_task(self.magpie.serve()) + + # yield to allow UI to start + await asyncio.sleep(0) + + while True: + try: + # TODO: cancel all client tasks + if not self.client.stop_bizhawk_spam: + logger.info("(Re)Starting game loop") + self.found_checks.clear() + # On restart of game loop, clear all checks, just in case we swapped ROMs + # this isn't totally neccessary, but is extra safety against cross-ROM contamination + self.client.recvd_checks.clear() + await self.client.wait_for_retroarch_connection() + await self.client.reset_auth() + # If we find ourselves with new auth after the reset, reconnect + if self.auth and self.client.auth != self.auth: + # It would be neat to reconnect here, but connection needs this loop to be running + logger.info("Detected new ROM, disconnecting...") + await self.disconnect() + continue + + if not self.client.recvd_checks: + await self.sync() + + await self.client.wait_and_init_tracker() + + while True: + await self.client.main_tick(on_item_get, victory, deathlink) + await asyncio.sleep(0.1) + now = time.time() + if self.last_resend + 5.0 < now: + self.last_resend = now + await self.send_checks() + if self.magpie_enabled: + try: + self.magpie.set_checks(self.client.tracker.all_checks) + await self.magpie.set_item_tracker(self.client.item_tracker) + await self.magpie.send_gps(self.client.gps_tracker) + except Exception: + # Don't let magpie errors take out the client + pass + if self.client.should_reset_auth: + self.client.should_reset_auth = False + raise GameboyException("Resetting due to wrong archipelago server") + except (GameboyException, asyncio.TimeoutError, TimeoutError, ConnectionResetError): + await asyncio.sleep(1.0) + +def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["ladx_options"].get("rom_start", True)) + if auto_start is True: + import webbrowser + webbrowser.open(romfile) + elif isinstance(auto_start, str): + args = shlex.split(auto_start) + # Specify full path to ROM as we are going to cd in popen + full_rom_path = os.path.realpath(romfile) + args.append(full_rom_path) + try: + # set cwd so that paths to lua scripts are always relative to our client + if getattr(sys, 'frozen', False): + # The application is frozen + script_dir = os.path.dirname(sys.executable) + else: + script_dir = os.path.dirname(os.path.realpath(__file__)) + + subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=script_dir) + except FileNotFoundError: + logger.error(f"Couldn't launch ROM, {args[0]} is missing") + +async def main(): + parser = get_base_parser(description="Link's Awakening Client.") + parser.add_argument("--url", help="Archipelago connection url") + parser.add_argument("--no-magpie", dest='magpie', default=True, action='store_false', help="Disable magpie bridge") + parser.add_argument('diff_file', default="", type=str, nargs="?", + help='Path to a .apladx Archipelago Binary Patch file') + + args = parser.parse_args() + + if args.diff_file: + import Patch + logger.info("patch file was supplied - creating rom...") + meta, rom_file = Patch.create_rom_file(args.diff_file) + if "server" in meta and not args.connect: + args.connect = meta["server"] + logger.info(f"wrote rom file to {rom_file}") + + + ctx = LinksAwakeningContext(args.connect, args.password, args.magpie) + + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + + # TODO: nothing about the lambda about has to be in a lambda + ctx.la_task = create_task_log_exception(ctx.run_game_loop()) + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + # Down below run_gui so that we get errors out of the process + if args.diff_file: + run_game(rom_file) + + await ctx.exit_event.wait() + await ctx.shutdown() + +if __name__ == '__main__': + colorama.init() + asyncio.run(main()) + colorama.deinit() diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 205a76813aec..802ec47dd1f0 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -25,7 +25,7 @@ from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ - get_adjuster_settings, tkinter_center_window, init_logging + get_adjuster_settings, get_adjuster_settings_no_defaults, tkinter_center_window, init_logging GAME_ALTTP = "A Link to the Past" @@ -43,8 +43,49 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter): def _get_help_string(self, action): return textwrap.dedent(action.help) - -def main(): +# See argparse.BooleanOptionalAction +class BooleanOptionalActionWithDisable(argparse.Action): + def __init__(self, + option_strings, + dest, + default=None, + type=None, + choices=None, + required=False, + help=None, + metavar=None): + + _option_strings = [] + for option_string in option_strings: + _option_strings.append(option_string) + + if option_string.startswith('--'): + option_string = '--disable' + option_string[2:] + _option_strings.append(option_string) + + if help is not None and default is not None: + help += " (default: %(default)s)" + + super().__init__( + option_strings=_option_strings, + dest=dest, + nargs=0, + default=default, + type=type, + choices=choices, + required=required, + help=help, + metavar=metavar) + + def __call__(self, parser, namespace, values, option_string=None): + if option_string in self.option_strings: + setattr(namespace, self.dest, not option_string.startswith('--disable')) + + def format_usage(self): + return ' | '.join(self.option_strings) + + +def get_argparser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(formatter_class=ArgumentDefaultsHelpFormatter) parser.add_argument('rom', nargs="?", default='AP_LttP.sfc', help='Path to an ALttP rom to adjust.') @@ -52,6 +93,8 @@ def main(): help='Path to an ALttP Japan(1.0) rom to use as a base.') parser.add_argument('--loglevel', default='info', const='info', nargs='?', choices=['error', 'info', 'warning', 'debug'], help='Select level of logging for output.') + parser.add_argument('--auto_apply', default='ask', + choices=['ask', 'always', 'never'], help='Whether or not to apply settings automatically in the future.') parser.add_argument('--menuspeed', default='normal', const='normal', nargs='?', choices=['normal', 'instant', 'double', 'triple', 'quadruple', 'half'], help='''\ @@ -61,7 +104,7 @@ def main(): parser.add_argument('--quickswap', help='Enable quick item swapping with L and R.', action='store_true') parser.add_argument('--deathlink', help='Enable DeathLink system.', action='store_true') parser.add_argument('--allowcollect', help='Allow collection of other player items', action='store_true') - parser.add_argument('--disablemusic', help='Disables game music.', action='store_true') + parser.add_argument('--music', default=True, help='Enables/Disables game music.', action=BooleanOptionalActionWithDisable) parser.add_argument('--triforcehud', default='hide_goal', const='hide_goal', nargs='?', choices=['normal', 'hide_goal', 'hide_required', 'hide_both'], help='''\ @@ -85,9 +128,6 @@ 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('--shield_palettes', default='default', choices=['default', 'random', 'blackout', 'puke', 'classic', 'grayscale', 'negative', 'dizzy', 'sick']) @@ -107,10 +147,23 @@ def main(): Alternatively, can be a ALttP Rom patched with a Link sprite that will be extracted. ''') - parser.add_argument('--names', default='', type=str) + parser.add_argument('--sprite_pool', nargs='+', default=[], help=''' + A list of sprites to pull from. + ''') + parser.add_argument('--oof', help='''\ + Path to a sound effect to replace Link's "oof" sound. + Needs to be in a .brr format and have a length of no + more than 2673 bytes, created from a 16-bit signed PCM + .wav at 12khz. https://github.com/boldowa/snesbrr + ''') parser.add_argument('--update_sprites', action='store_true', help='Update Sprite Database, then exit.') - args = parser.parse_args() - args.music = not args.disablemusic + return parser + + +def main(): + parser = get_argparser() + args = parser.parse_args(namespace=get_adjuster_settings_no_defaults(GAME_ALTTP)) + # set up logger loglevel = {'error': logging.ERROR, 'info': logging.INFO, 'warning': logging.WARNING, 'debug': logging.DEBUG}[ args.loglevel] @@ -126,6 +179,13 @@ def main(): if args.sprite is not None and not os.path.isfile(args.sprite) and not Sprite.get_sprite_from_name(args.sprite): input('Could not find link sprite sheet at given location. \nPress Enter to exit.') sys.exit(1) + if args.oof is not None and not os.path.isfile(args.oof): + input('Could not find oof sound effect at given location. \nPress Enter to exit.') + sys.exit(1) + if args.oof is not None and os.path.getsize(args.oof) > 2673: + input('"oof" sound effect cannot exceed 2673 bytes. \nPress Enter to exit.') + sys.exit(1) + args, path = adjust(args=args) if isinstance(args.sprite, Sprite): @@ -165,7 +225,7 @@ def adjust(args): world = getattr(args, "world") apply_rom_settings(rom, args.heartbeep, args.heartcolor, args.quickswap, args.menuspeed, args.music, - args.sprite, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world, + args.sprite, args.oof, palettes_options, reduceflashing=args.reduceflashing or racerom, world=world, deathlink=args.deathlink, allowcollect=args.allowcollect) path = output_path(f'{os.path.basename(args.rom)[:-4]}_adjusted.sfc') rom.write_to_file(path) @@ -180,7 +240,7 @@ def adjustGUI(): from tkinter import Tk, LEFT, BOTTOM, TOP, \ StringVar, Frame, Label, X, Entry, Button, filedialog, messagebox, ttk from argparse import Namespace - from Main import __version__ as MWVersion + from Utils import __version__ as MWVersion adjustWindow = Tk() adjustWindow.wm_title("Archipelago %s LttP Adjuster" % MWVersion) set_icon(adjustWindow) @@ -227,6 +287,7 @@ def adjustRom(): guiargs.sprite = rom_vars.sprite if rom_vars.sprite_pool: guiargs.world = AdjusterWorld(rom_vars.sprite_pool) + guiargs.oof = rom_vars.oof try: guiargs, path = adjust(args=guiargs) @@ -265,6 +326,7 @@ def saveGUISettings(): else: guiargs.sprite = rom_vars.sprite guiargs.sprite_pool = rom_vars.sprite_pool + guiargs.oof = rom_vars.oof persistent_store("adjuster", GAME_ALTTP, guiargs) messagebox.showinfo(title="Success", message="Settings saved to persistent storage") @@ -481,11 +543,38 @@ def close_window(self): self.stop() +class AttachTooltip(object): + + def __init__(self, parent, text): + self._parent = parent + self._text = text + self._window = None + parent.bind('', lambda event : self.show()) + parent.bind('', lambda event : self.hide()) + + def show(self): + if self._window or not self._text: + return + self._window = Toplevel(self._parent) + #remove window bar controls + self._window.wm_overrideredirect(1) + #adjust positioning + x, y, *_ = self._parent.bbox("insert") + x = x + self._parent.winfo_rootx() + 20 + y = y + self._parent.winfo_rooty() + 20 + self._window.wm_geometry("+{0}+{1}".format(x,y)) + #show text + label = Label(self._window, text=self._text, justify=LEFT) + label.pack(ipadx=1) + + def hide(self): + if self._window: + self._window.destroy() + self._window = None + + def get_rom_frame(parent=None): adjuster_settings = get_adjuster_settings(GAME_ALTTP) - if not adjuster_settings: - adjuster_settings = Namespace() - adjuster_settings.baserom = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" romFrame = Frame(parent) baseRomLabel = Label(romFrame, text='LttP Base Rom: ') @@ -513,32 +602,8 @@ def RomSelect(): return romFrame, romVar - def get_rom_options_frame(parent=None): adjuster_settings = get_adjuster_settings(GAME_ALTTP) - defaults = { - "auto_apply": 'ask', - "music": True, - "reduceflashing": True, - "deathlink": False, - "sprite": None, - "quickswap": True, - "menuspeed": 'normal', - "heartcolor": 'red', - "heartbeep": 'normal', - "ow_palettes": 'default', - "uw_palettes": 'default', - "hud_palettes": 'default', - "sword_palettes": 'default', - "shield_palettes": 'default', - "sprite_pool": [], - "allowcollect": False, - } - if not adjuster_settings: - adjuster_settings = Namespace() - for key, defaultvalue in defaults.items(): - if not hasattr(adjuster_settings, key): - setattr(adjuster_settings, key, defaultvalue) romOptionsFrame = LabelFrame(parent, text="Rom options") romOptionsFrame.columnconfigure(0, weight=1) @@ -598,12 +663,50 @@ def SpriteSelect(): spriteEntry.pack(side=LEFT) spriteSelectButton.pack(side=LEFT) + oofDialogFrame = Frame(romOptionsFrame) + oofDialogFrame.grid(row=1, column=1) + baseOofLabel = Label(oofDialogFrame, text='"OOF" Sound:') + + vars.oofNameVar = StringVar() + vars.oof = adjuster_settings.oof + + def set_oof(oof_param): + nonlocal vars + if isinstance(oof_param, str) and os.path.isfile(oof_param) and os.path.getsize(oof_param) <= 2673: + vars.oof = oof_param + vars.oofNameVar.set(oof_param.rsplit('/',1)[-1]) + else: + vars.oof = None + vars.oofNameVar.set('(unchanged)') + + set_oof(adjuster_settings.oof) + oofEntry = Label(oofDialogFrame, textvariable=vars.oofNameVar) + + def OofSelect(): + nonlocal vars + oof_file = filedialog.askopenfilename( + filetypes=[("BRR files", ".brr"), + ("All Files", "*")]) + try: + set_oof(oof_file) + except Exception: + set_oof(None) + + oofSelectButton = Button(oofDialogFrame, text='...', command=OofSelect) + AttachTooltip(oofSelectButton, + text="Select a .brr file no more than 2673 bytes.\n" + \ + "This can be created from a <=0.394s 16-bit signed PCM .wav file at 12khz using snesbrr.") + + baseOofLabel.pack(side=LEFT) + oofEntry.pack(side=LEFT) + oofSelectButton.pack(side=LEFT) + vars.quickSwapVar = IntVar(value=adjuster_settings.quickswap) quickSwapCheckbutton = Checkbutton(romOptionsFrame, text="L/R Quickswapping", variable=vars.quickSwapVar) quickSwapCheckbutton.grid(row=1, column=0, sticky=E) menuspeedFrame = Frame(romOptionsFrame) - menuspeedFrame.grid(row=1, column=1, sticky=E) + menuspeedFrame.grid(row=6, column=1, sticky=E) menuspeedLabel = Label(menuspeedFrame, text='Menu speed') menuspeedLabel.pack(side=LEFT) vars.menuspeedVar = StringVar() @@ -1056,7 +1159,6 @@ def alttpr_sprite_dir(self): def custom_sprite_dir(self): return user_path("data", "sprites", "custom") - def get_image_for_sprite(sprite, gif_only: bool = False): if not sprite.valid: return None diff --git a/MMBN3Client.py b/MMBN3Client.py new file mode 100644 index 000000000000..3f7474a6fd50 --- /dev/null +++ b/MMBN3Client.py @@ -0,0 +1,376 @@ +import asyncio +import hashlib +import json +import os +import multiprocessing +import subprocess +import zipfile + +from asyncio import StreamReader, StreamWriter + +import bsdiff4 + +from CommonClient import CommonContext, server_loop, gui_enabled, \ + ClientCommandProcessor, logger, get_base_parser +import Utils +from NetUtils import ClientStatus +from worlds.mmbn3.Items import items_by_id +from worlds.mmbn3.Rom import get_base_rom_path +from worlds.mmbn3.Locations import all_locations, scoutable_locations + +SYSTEM_MESSAGE_ID = 0 + +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_mmbn3.lua" +CONNECTION_REFUSED_STATUS = \ + "Connection refused. Please start your emulator and make sure connector_mmbn3.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_mmbn3.lua" +CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" +CONNECTION_CONNECTED_STATUS = "Connected" +CONNECTION_INITIAL_STATUS = "Connection has not been initiated" +CONNECTION_INCORRECT_ROM = "Supplied Base Rom does not match US GBA Blue Version. Please provide the correct ROM version" + +script_version: int = 2 + +debugEnabled = False +locations_checked = [] +items_sent = [] +itemIndex = 1 + +CHECKSUM_BLUE = "6fe31df0144759b34ad666badaacc442" + + +class MMBN3CommandProcessor(ClientCommandProcessor): + def __init__(self, ctx): + super().__init__(ctx) + + def _cmd_gba(self): + """Check GBA Connection State""" + if isinstance(self.ctx, MMBN3Context): + logger.info(f"GBA Status: {self.ctx.gba_status}") + + def _cmd_debug(self): + """Toggle the Debug Text overlay in ROM""" + global debugEnabled + debugEnabled = not debugEnabled + logger.info("Debug Overlay Enabled" if debugEnabled else "Debug Overlay Disabled") + + +class MMBN3Context(CommonContext): + command_processor = MMBN3CommandProcessor + game = "MegaMan Battle Network 3" + items_handling = 0b001 # full local + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.gba_streams: (StreamReader, StreamWriter) = None + self.gba_sync_task = None + self.gba_status = CONNECTION_INITIAL_STATUS + self.awaiting_rom = False + self.location_table = {} + self.version_warning = False + self.auth_name = None + self.slot_data = dict() + self.patching_error = False + self.sent_hints = [] + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super(MMBN3Context, self).server_auth(password_requested) + + if self.auth_name is None: + self.awaiting_rom = True + logger.info("No ROM detected, awaiting conection to Bizhawk to authenticate to the multiworld server") + return + + logger.info("Attempting to decode from ROM... ") + self.awaiting_rom = False + self.auth = self.auth_name.decode("utf8").replace('\x00', '') + logger.info("Connecting as "+self.auth) + await self.send_connect(name=self.auth) + + def run_gui(self): + from kvui import GameManager + + class MMBN3Manager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago MegaMan Battle Network 3 Client" + + self.ui = MMBN3Manager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def on_package(self, cmd: str, args: dict): + if cmd == 'Connected': + self.slot_data = args.get("slot_data", {}) + print(self.slot_data) + +class ItemInfo: + id = 0x00 + sender = "" + type = "" + count = 1 + itemName = "Unknown" + itemID = 0x00 # Item ID, Chip ID, etc. + subItemID = 0x00 # Code for chips, color for programs + itemIndex = 1 + + def __init__(self, id, sender, type): + self.id = id + self.sender = sender + self.type = type + + def get_json(self): + json_data = { + "id": self.id, + "sender": self.sender, + "type": self.type, + "itemName": self.itemName, + "itemID": self.itemID, + "subItemID": self.subItemID, + "count": self.count, + "itemIndex": self.itemIndex + } + return json_data + + +def get_payload(ctx: MMBN3Context): + global debugEnabled + + items_sent = [] + for i, item in enumerate(ctx.items_received): + item_data = items_by_id[item.item] + new_item = ItemInfo(i, ctx.player_names[item.player], item_data.type) + new_item.itemIndex = i+1 + new_item.itemName = item_data.itemName + new_item.type = item_data.type + new_item.itemID = item_data.itemID + new_item.subItemID = item_data.subItemID + new_item.count = item_data.count + items_sent.append(new_item) + + return json.dumps({ + "items": [item.get_json() for item in items_sent], + "debug": debugEnabled + }) + + +async def parse_payload(payload: dict, ctx: MMBN3Context, force: bool): + # Game completion handling + if payload["gameComplete"] and not ctx.finished_game: + await ctx.send_msgs([{ + "cmd": "StatusUpdate", + "status": ClientStatus.CLIENT_GOAL + }]) + ctx.finished_game = True + + # Locations handling + if ctx.location_table != payload["locations"]: + ctx.location_table = payload["locations"] + locs = [loc.id for loc in all_locations + if check_location_packet(loc, ctx.location_table)] + await ctx.send_msgs([{ + "cmd": "LocationChecks", + "locations": locs + }]) + + # If trade hinting is enabled, send scout checks + if ctx.slot_data.get("trade_quest_hinting", 0) == 2: + trade_bits = [loc.id for loc in scoutable_locations + if check_location_scouted(loc, payload["locations"])] + scouted_locs = [loc for loc in trade_bits if loc not in ctx.sent_hints] + if len(scouted_locs) > 0: + ctx.sent_hints.extend(scouted_locs) + await ctx.send_msgs([{ + "cmd": "LocationScouts", + "locations": scouted_locs, + "create_as_hint": 2 + }]) + + +def check_location_packet(location, memory): + if len(memory) == 0: + return False + # Our keys have to be strings to come through the JSON lua plugin so we have to turn our memory address into a string as well + location_key = hex(location.flag_byte)[2:] + byte = memory.get(location_key) + if byte is not None: + return byte & location.flag_mask + + +def check_location_scouted(location, memory): + if len(memory) == 0: + return False + location_key = hex(location.hint_flag)[2:] + byte = memory.get(location_key) + if byte is not None: + return byte & location.hint_flag_mask + + +async def gba_sync_task(ctx: MMBN3Context): + logger.info("Starting GBA connector. Use /gba for status information.") + if ctx.patching_error: + logger.error('Unable to Patch ROM. No ROM provided or ROM does not match US GBA Blue Version.') + while not ctx.exit_event.is_set(): + error_status = None + if ctx.gba_streams: + (reader, writer) = ctx.gba_streams + msg = get_payload(ctx).encode() + writer.write(msg) + writer.write(b'\n') + try: + await asyncio.wait_for(writer.drain(), timeout=1.5) + try: + # Data will return a dict with up to four fields + # 1. str: player name (always) + # 2. int: script version (always) + # 3. dict[str, byte]: value of location's memory byte + # 4. bool: whether the game currently registers as complete + data = await asyncio.wait_for(reader.readline(), timeout=10) + data_decoded = json.loads(data.decode()) + reported_version = data_decoded.get("scriptVersion", 0) + if reported_version >= script_version: + if ctx.game is not None and "locations" in data_decoded: + # Not just a keep alive ping, parse + asyncio.create_task((parse_payload(data_decoded, ctx, False))) + if not ctx.auth: + ctx.auth_name = bytes(data_decoded["playerName"]) + + if ctx.awaiting_rom: + logger.info("Awaiting data from ROM...") + await ctx.server_auth(False) + else: + if not ctx.version_warning: + logger.warning(f"Your Lua script is version {reported_version}, expected {script_version}." + "Please update to the latest version." + "Your connection to the Archipelago server will not be accepted.") + ctx.version_warning = True + except asyncio.TimeoutError: + logger.debug("Read Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.gba_streams = None + except ConnectionResetError: + logger.debug("Read failed due to Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.gba_streams = None + except TimeoutError: + logger.debug("Connection Timed Out, Reconnecting") + error_status = CONNECTION_TIMING_OUT_STATUS + writer.close() + ctx.gba_streams = None + except ConnectionResetError: + logger.debug("Connection Lost, Reconnecting") + error_status = CONNECTION_RESET_STATUS + writer.close() + ctx.gba_streams = None + if ctx.gba_status == CONNECTION_TENTATIVE_STATUS: + if not error_status: + logger.info("Successfully Connected to GBA") + ctx.gba_status = CONNECTION_CONNECTED_STATUS + else: + ctx.gba_status = f"Was tentatively connected but error occurred: {error_status}" + elif error_status: + ctx.gba_status = error_status + logger.info("Lost connection to GBA and attempting to reconnect. Use /gba for status updates") + else: + try: + logger.debug("Attempting to connect to GBA") + ctx.gba_streams = await asyncio.wait_for(asyncio.open_connection("localhost", 28922), timeout=10) + ctx.gba_status = CONNECTION_TENTATIVE_STATUS + except TimeoutError: + logger.debug("Connection Timed Out, Trying Again") + ctx.gba_status = CONNECTION_TIMING_OUT_STATUS + continue + except ConnectionRefusedError: + logger.debug("Connection Refused, Trying Again") + ctx.gba_status = CONNECTION_REFUSED_STATUS + continue + + +async def run_game(romfile): + options = Utils.get_options().get("mmbn3_options", None) + if options is None: + auto_start = True + else: + auto_start = options.get("rom_start", True) + if auto_start: + import webbrowser + webbrowser.open(romfile) + elif os.path.isfile(auto_start): + subprocess.Popen([auto_start, romfile], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +async def patch_and_run_game(apmmbn3_file): + base_name = os.path.splitext(apmmbn3_file)[0] + + with zipfile.ZipFile(apmmbn3_file, 'r') as patch_archive: + try: + with patch_archive.open("delta.bsdiff4", 'r') as stream: + patch_data = stream.read() + except KeyError: + raise FileNotFoundError("Patch file missing from archive.") + rom_file = get_base_rom_path() + + with open(rom_file, 'rb') as rom: + rom_bytes = rom.read() + + patched_bytes = bsdiff4.patch(rom_bytes, patch_data) + patched_rom_file = base_name+".gba" + with open(patched_rom_file, 'wb') as patched_rom: + patched_rom.write(patched_bytes) + + asyncio.create_task(run_game(patched_rom_file)) + + +def confirm_checksum(): + rom_file = get_base_rom_path() + if not os.path.exists(rom_file): + return False + + with open(rom_file, 'rb') as rom: + rom_bytes = rom.read() + + basemd5 = hashlib.md5() + basemd5.update(rom_bytes) + return CHECKSUM_BLUE == basemd5.hexdigest() + + +if __name__ == "__main__": + Utils.init_logging("MMBN3Client") + + async def main(): + multiprocessing.freeze_support() + parser = get_base_parser() + parser.add_argument("patch_file", default="", type=str, nargs="?", + help="Path to an APMMBN3 file") + args = parser.parse_args() + checksum_matches = confirm_checksum() + if checksum_matches: + if args.patch_file: + asyncio.create_task(patch_and_run_game(args.patch_file)) + + ctx = MMBN3Context(args.connect, args.password) + if not checksum_matches: + ctx.patching_error = True + ctx.server_task = asyncio.create_task(server_loop(ctx), name="Server Loop") + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + ctx.gba_sync_task = asyncio.create_task(gba_sync_task(ctx), name="GBA Sync") + await ctx.exit_event.wait() + ctx.server_address = None + await ctx.shutdown() + + if ctx.gba_sync_task: + await ctx.gba_sync_task + + import colorama + + colorama.init() + + asyncio.run(main()) + colorama.deinit() diff --git a/Main.py b/Main.py index 372cadc5fd88..fe56dc7d9e09 100644 --- a/Main.py +++ b/Main.py @@ -1,34 +1,30 @@ import collections +import concurrent.futures import logging import os -import time -import zlib -import concurrent.futures import pickle import tempfile +import time import zipfile -from typing import Dict, List, Tuple, Optional, Set +import zlib +from typing import Dict, List, Optional, Set, Tuple, Union -from BaseClasses import Item, MultiWorld, CollectionState, Region, LocationProgressType, Location import worlds -from worlds.alttp.SubClasses import LTTPRegionType -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 FillDisabledShopSlots -from Utils import output_path, get_options, __version__, version_tuple -from worlds.generic.Rules import locality_rules, exclusion_rules +from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region +from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items +from Options import StartInventoryPool +from settings import get_settings +from Utils import __version__, output_path, version_tuple from worlds import AutoWorld +from worlds.generic.Rules import exclusion_rules, locality_rules -ordered_areas = ( - 'Light World', 'Dark World', 'Hyrule Castle', 'Agahnims Tower', 'Eastern Palace', 'Desert Palace', - 'Tower of Hera', 'Palace of Darkness', 'Swamp Palace', 'Skull Woods', 'Thieves Town', 'Ice Palace', - 'Misery Mire', 'Turtle Rock', 'Ganons Tower', "Total" -) +__all__ = ["main"] def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): if not baked_server_options: - baked_server_options = get_options()["server_options"] + baked_server_options = get_settings().server_options.as_dict() + assert isinstance(baked_server_options, dict) if args.outputpath: os.makedirs(args.outputpath, exist_ok=True) output_path.cached_path = args.outputpath @@ -116,6 +112,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for _ in range(count): world.push_precollected(world.create_item(item_name, player)) + for item_name, count in world.start_inventory_from_pool.setdefault(player, StartInventoryPool({})).value.items(): + for _ in range(count): + world.push_precollected(world.create_item(item_name, player)) + logger.info('Creating World.') AutoWorld.call_all(world, "create_regions") @@ -133,12 +133,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.non_local_items[player].value -= world.local_items[player].value world.non_local_items[player].value -= set(world.local_early_items[player]) - if world.players > 1: - locality_rules(world) - else: - world.non_local_items[1].value = set() - world.local_items[1].value = set() - AutoWorld.call_all(world, "set_rules") for player in world.player_ids: @@ -147,8 +141,46 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No for location_name in world.priority_locations[player].value: world.get_location(location_name, player).progress_type = LocationProgressType.PRIORITY + # Set local and non-local item rules. + if world.players > 1: + locality_rules(world) + else: + world.non_local_items[1].value = set() + world.local_items[1].value = set() + AutoWorld.call_all(world, "generate_basic") + # remove starting inventory from pool items. + # Because some worlds don't actually create items during create_items this has to be as late as possible. + if any(world.start_inventory_from_pool[player].value for player in world.player_ids): + new_items: List[Item] = [] + depletion_pool: Dict[int, Dict[str, int]] = { + player: world.start_inventory_from_pool[player].value.copy() for player in world.player_ids} + for player, items in depletion_pool.items(): + player_world: AutoWorld.World = world.worlds[player] + for count in items.values(): + new_items.append(player_world.create_filler()) + target: int = sum(sum(items.values()) for items in depletion_pool.values()) + for i, item in enumerate(world.itempool): + if depletion_pool[item.player].get(item.name, 0): + target -= 1 + depletion_pool[item.player][item.name] -= 1 + # quick abort if we have found all items + if not target: + new_items.extend(world.itempool[i+1:]) + break + else: + new_items.append(item) + + # leftovers? + if target: + for player, remaining_items in depletion_pool.items(): + remaining_items = {name: count for name, count in remaining_items.items() if count} + if remaining_items: + raise Exception(f"{world.get_player_name(player)}" + f" is trying to remove items from their pool that don't exist: {remaining_items}") + world.itempool[:] = new_items + # temporary home for item links, should be moved out of Main for group_id, group in world.groups.items(): def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ @@ -247,8 +279,10 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ AutoWorld.call_all(world, 'post_fill') - if world.players > 1: + if world.players > 1 and not args.skip_prog_balancing: balance_multiworld_progression(world) + else: + logger.info("Progression balancing skipped.") logger.info(f'Beginning output...') @@ -273,35 +307,6 @@ def find_common_pool(players: Set[int], shared_pool: Set[str]) -> Tuple[ 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)} - - for player in range(1, world.players + 1): - checks_in_area[player]["Total"] = 0 - - for location in world.get_filled_locations(): - if type(location.address) is int: - if location.game != "A Link to the Past": - checks_in_area[location.player]["Light 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 == LTTPRegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif location.parent_region.type == LTTPRegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - elif main_entrance.parent_region.type == LTTPRegionType.LightWorld: - checks_in_area[location.player]["Light World"].append(location.address) - elif main_entrance.parent_region.type == LTTPRegionType.DarkWorld: - checks_in_area[location.player]["Dark World"].append(location.address) - checks_in_area[location.player]["Total"] += 1 - - FillDisabledShopSlots(world) - def write_multidata(): import NetUtils slot_data = {} @@ -355,18 +360,17 @@ def precollect_hint(location): for player in world.groups.get(location.item.player, {}).get("players", [])]): precollect_hint(location) - # custom datapackage - datapackage = {} - for game_world in world.worlds.values(): - if game_world.data_version == 0 and game_world.game not in datapackage: - datapackage[game_world.game] = worlds.network_data_package["games"][game_world.game] - datapackage[game_world.game]["item_name_groups"] = game_world.item_name_groups - datapackage[game_world.game]["location_name_groups"] = game_world.location_name_groups + # embedded data package + data_package = { + game_world.game: worlds.network_data_package["games"][game_world.game] + for game_world in world.worlds.values() + } + + checks_in_area: Dict[int, Dict[str, Union[int, List[int]]]] = {} multidata = { "slot_data": slot_data, "slot_info": slot_info, - "names": names, # TODO: remove after 0.3.9 "connect_names": {name: (0, player) for player, name in world.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, @@ -378,7 +382,7 @@ def precollect_hint(location): "tags": ["AP"], "minimum_versions": minimum_versions, "seed_name": world.seed_name, - "datapackage": datapackage, + "datapackage": data_package, } AutoWorld.call_all(world, "modify_multidata", multidata) diff --git a/MinecraftClient.py b/MinecraftClient.py index 16b283f9d9e4..93385ec5385e 100644 --- a/MinecraftClient.py +++ b/MinecraftClient.py @@ -77,49 +77,34 @@ def read_apmc_file(apmc_file): return json.loads(b64decode(f.read())) -def update_mod(forge_dir, minecraft_version: str, get_prereleases=False): +def update_mod(forge_dir, url: str): """Check mod version, download new mod from GitHub releases page if needed. """ ap_randomizer = find_ap_randomizer_jar(forge_dir) - - client_releases_endpoint = "https://api.github.com/repos/KonoTyran/Minecraft_AP_Randomizer/releases" - resp = requests.get(client_releases_endpoint) - if resp.status_code == 200: # OK - try: - latest_release = next(filter(lambda release: (not release['prerelease'] or get_prereleases) and - (minecraft_version in release['assets'][0]['name']), - resp.json())) - if ap_randomizer != latest_release['assets'][0]['name']: - logging.info(f"A new release of the Minecraft AP randomizer mod was found: " - f"{latest_release['assets'][0]['name']}") - if ap_randomizer is not None: - logging.info(f"Your current mod is {ap_randomizer}.") - else: - logging.info(f"You do not have the AP randomizer mod installed.") - if prompt_yes_no("Would you like to update?"): - old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None - new_ap_mod = os.path.join(forge_dir, 'mods', latest_release['assets'][0]['name']) - logging.info("Downloading AP randomizer mod. This may take a moment...") - apmod_resp = requests.get(latest_release['assets'][0]['browser_download_url']) - if apmod_resp.status_code == 200: - with open(new_ap_mod, 'wb') as f: - f.write(apmod_resp.content) - logging.info(f"Wrote new mod file to {new_ap_mod}") - if old_ap_mod is not None: - os.remove(old_ap_mod) - logging.info(f"Removed old mod file from {old_ap_mod}") - else: - logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).") - logging.error(f"Please report this issue on the Archipelago Discord server.") - sys.exit(1) - except StopIteration: - logging.warning(f"No compatible mod version found for {minecraft_version}.") - if not prompt_yes_no("Run server anyway?"): - sys.exit(0) + os.path.basename(url) + if ap_randomizer is not None: + logging.info(f"Your current mod is {ap_randomizer}.") else: - logging.error(f"Error checking for randomizer mod updates (status code {resp.status_code}).") - logging.error(f"If this was not expected, please report this issue on the Archipelago Discord server.") - if not prompt_yes_no("Continue anyways?"): - sys.exit(0) + logging.info(f"You do not have the AP randomizer mod installed.") + + if ap_randomizer != os.path.basename(url): + logging.info(f"A new release of the Minecraft AP randomizer mod was found: " + f"{os.path.basename(url)}") + if prompt_yes_no("Would you like to update?"): + old_ap_mod = os.path.join(forge_dir, 'mods', ap_randomizer) if ap_randomizer is not None else None + new_ap_mod = os.path.join(forge_dir, 'mods', os.path.basename(url)) + logging.info("Downloading AP randomizer mod. This may take a moment...") + apmod_resp = requests.get(url) + if apmod_resp.status_code == 200: + with open(new_ap_mod, 'wb') as f: + f.write(apmod_resp.content) + logging.info(f"Wrote new mod file to {new_ap_mod}") + if old_ap_mod is not None: + os.remove(old_ap_mod) + logging.info(f"Removed old mod file from {old_ap_mod}") + else: + logging.error(f"Error retrieving the randomizer mod (status code {apmod_resp.status_code}).") + logging.error(f"Please report this issue on the Archipelago Discord server.") + sys.exit(1) def check_eula(forge_dir): @@ -264,8 +249,13 @@ def get_minecraft_versions(version, release_channel="release"): return next(filter(lambda entry: entry["version"] == version, data[release_channel])) else: return resp.json()[release_channel][0] - except StopIteration: - logging.error(f"No compatible mod version found for client version {version}.") + except (StopIteration, KeyError): + logging.error(f"No compatible mod version found for client version {version} on \"{release_channel}\" channel.") + if release_channel != "release": + logging.error("Consider switching \"release_channel\" to \"release\" in your Host.yaml file") + else: + logging.error("No suitable mod found on the \"release\" channel. Please Contact us on discord to report this error.") + sys.exit(0) def is_correct_forge(forge_dir) -> bool: @@ -286,6 +276,8 @@ def is_correct_forge(forge_dir) -> bool: help="specify java version.") parser.add_argument('--forge', '-f', metavar='1.18.2-40.1.0', dest='forge', type=str, default=False, action='store', help="specify forge version. (Minecraft Version-Forge Version)") + parser.add_argument('--version', '-v', metavar='9', dest='data_version', type=int, action='store', + help="specify Mod data version to download.") args = parser.parse_args() apmc_file = os.path.abspath(args.apmc_file) if args.apmc_file else None @@ -296,21 +288,22 @@ def is_correct_forge(forge_dir) -> bool: options = Utils.get_options() channel = args.channel or options["minecraft_options"]["release_channel"] apmc_data = None - data_version = None + data_version = args.data_version or None if apmc_file is None and not args.install: apmc_file = Utils.open_filename('Select APMC file', (('APMC File', ('.apmc',)),)) - if apmc_file is not None: + if apmc_file is not None and data_version is None: apmc_data = read_apmc_file(apmc_file) data_version = apmc_data.get('client_version', '') versions = get_minecraft_versions(data_version, channel) - forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"]) + forge_dir = options["minecraft_options"]["forge_directory"] max_heap = options["minecraft_options"]["max_heap_size"] forge_version = args.forge or versions["forge"] java_version = args.java or versions["java"] + mod_url = versions["url"] java_dir = find_jdk_dir(java_version) if args.install: @@ -344,7 +337,7 @@ def is_correct_forge(forge_dir) -> bool: if not max_heap_re.match(max_heap): raise Exception(f"Max heap size {max_heap} in incorrect format. Use a number followed by M or G, e.g. 512M or 2G.") - update_mod(forge_dir, f"MC{forge_version.split('-')[0]}", channel != "release") + update_mod(forge_dir, mod_url) replace_apmc_files(forge_dir, apmc_file) check_eula(forge_dir) server_process = run_forge_server(forge_dir, java_version, max_heap) diff --git a/ModuleUpdate.py b/ModuleUpdate.py index a8258ce17edf..209f2da67253 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -1,7 +1,7 @@ import os import sys import subprocess -import pkg_resources +import multiprocessing import warnings local_dir = os.path.dirname(__file__) @@ -10,7 +10,8 @@ if sys.version_info < (3, 8, 6): raise RuntimeError("Incompatible Python Version. 3.8.7+ is supported.") -update_ran = getattr(sys, "frozen", False) # don't run update if environment is frozen/compiled +# don't run update if environment is frozen/compiled or if not the parent process (skip in subprocess) +update_ran = getattr(sys, "frozen", False) or multiprocessing.parent_process() if not update_ran: for entry in os.scandir(os.path.join(local_dir, "worlds")): @@ -22,18 +23,50 @@ requirements_files.add(req_file) +def check_pip(): + # detect if pip is available + try: + import pip # noqa: F401 + except ImportError: + raise RuntimeError("pip not available. Please install pip.") + + +def confirm(msg: str): + try: + input(f"\n{msg}") + except KeyboardInterrupt: + print("\nAborting") + sys.exit(1) + + def update_command(): + check_pip() for file in requirements_files: - subprocess.call([sys.executable, '-m', 'pip', 'install', '-r', file, '--upgrade']) + subprocess.call([sys.executable, "-m", "pip", "install", "-r", file, "--upgrade"]) + + +def install_pkg_resources(yes=False): + try: + import pkg_resources # noqa: F401 + except ImportError: + check_pip() + if not yes: + confirm("pkg_resources not found, press enter to install it") + subprocess.call([sys.executable, "-m", "pip", "install", "--upgrade", "setuptools"]) def update(yes=False, force=False): global update_ran if not update_ran: update_ran = True + if force: update_command() return + + install_pkg_resources(yes=yes) + import pkg_resources + for req_file in requirements_files: path = os.path.join(os.path.dirname(sys.argv[0]), req_file) if not os.path.exists(path): @@ -52,7 +85,7 @@ def update(yes=False, force=False): egg = egg.split(";", 1)[0].rstrip() if any(compare in egg for compare in ("==", ">=", ">", "<", "<=", "!=")): warnings.warn(f"Specifying version as #egg={egg} will become unavailable in pip 25.0. " - "Use name @ url#version instead.", DeprecationWarning) + "Use name @ url#version instead.", DeprecationWarning) line = egg else: egg = "" @@ -79,11 +112,7 @@ def update(yes=False, force=False): if not yes: import traceback traceback.print_exc() - try: - input(f"\nRequirement {requirement} is not satisfied, press enter to install it") - except KeyboardInterrupt: - print("\nAborting") - sys.exit(1) + confirm(f"Requirement {requirement} is not satisfied, press enter to install it") update_command() return diff --git a/MultiServer.py b/MultiServer.py index 6c3106a93d9d..8be8d641324a 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -3,21 +3,21 @@ import argparse import asyncio import copy -import functools -import logging -import zlib import collections -import typing -import inspect -import weakref import datetime -import threading -import random -import pickle +import functools +import hashlib +import inspect import itertools -import time +import logging import operator -import hashlib +import pickle +import random +import threading +import time +import typing +import weakref +import zlib import ModuleUpdate @@ -38,7 +38,7 @@ import Utils from Utils import version_tuple, restricted_loads, Version, async_start from NetUtils import Endpoint, ClientStatus, NetworkItem, decode, encode, NetworkPlayer, Permission, NetworkSlot, \ - SlotType + SlotType, LocationStore min_client_version = Version(0, 1, 6) colorama.init() @@ -152,14 +152,17 @@ class Context: "compatibility": int} # team -> slot id -> list of clients authenticated to slot. clients: typing.Dict[int, typing.Dict[int, typing.List[Client]]] - locations: typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] + locations: LocationStore # typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]] + location_checks: typing.Dict[typing.Tuple[int, int], typing.Set[int]] + hints_used: typing.Dict[typing.Tuple[int, int], int] groups: typing.Dict[int, typing.Set[int]] save_version = 2 stored_data: typing.Dict[str, object] read_data: typing.Dict[str, object] stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] slot_info: typing.Dict[int, NetworkSlot] - + generator_version = Version(0, 0, 0) + checksums: typing.Dict[str, str] item_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown item (ID:{code})') item_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] location_names: typing.Dict[int, str] = Utils.KeyedDefaultDict(lambda code: f'Unknown location (ID:{code})') @@ -186,8 +189,6 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self.player_name_lookup: typing.Dict[str, team_slot] = {} self.connect_names = {} # names of slots clients can connect to self.allow_releases = {} - # player location_id item_id target_player_id - self.locations = {} self.host = host self.port = port self.server_password = server_password @@ -222,7 +223,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self.save_dirty = False self.tags = ['AP'] self.games: typing.Dict[int, str] = {} - self.minimum_client_versions: typing.Dict[int, Utils.Version] = {} + self.minimum_client_versions: typing.Dict[int, Version] = {} self.seed_name = "" self.groups = {} self.group_collected: typing.Dict[int, typing.Set[int]] = {} @@ -233,6 +234,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo # init empty to satisfy linter, I suppose self.gamespackage = {} + self.checksums = {} self.item_name_groups = {} self.location_name_groups = {} self.all_item_and_group_names = {} @@ -241,7 +243,7 @@ def __init__(self, host: str, port: int, server_password: str, password: str, lo self._load_game_data() - # Datapackage retrieval + # Data package retrieval def _load_game_data(self): import worlds self.gamespackage = worlds.network_data_package["games"] @@ -255,6 +257,8 @@ def _load_game_data(self): def _init_game_data(self): for game_name, game_package in self.gamespackage.items(): + if "checksum" in game_package: + self.checksums[game_name] = game_package["checksum"] for item_name, item_id in game_package["item_name_to_id"].items(): self.item_names[item_id] = item_name for location_name, location_id in game_package["location_name_to_id"].items(): @@ -262,7 +266,7 @@ def _init_game_data(self): self.all_item_and_group_names[game_name] = \ set(game_package["item_name_to_id"]) | set(self.item_name_groups[game_name]) self.all_location_and_group_names[game_name] = \ - set(game_package["location_name_to_id"]) | set(self.location_name_groups[game_name]) + set(game_package["location_name_to_id"]) | set(self.location_name_groups.get(game_name, [])) 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 @@ -280,6 +284,7 @@ async def send_msgs(self, endpoint: Endpoint, msgs: typing.Iterable[dict]) -> bo except websockets.ConnectionClosed: logging.exception(f"Exception during send_msgs, could not send {msg}") await self.disconnect(endpoint) + return False else: if self.log_network: logging.info(f"Outgoing message: {msg}") @@ -293,6 +298,7 @@ async def send_encoded_msgs(self, endpoint: Endpoint, msg: str) -> bool: except websockets.ConnectionClosed: logging.exception("Exception during send_encoded_msgs") await self.disconnect(endpoint) + return False else: if self.log_network: logging.info(f"Outgoing message: {msg}") @@ -307,6 +313,7 @@ async def broadcast_send_encoded_msgs(self, endpoints: typing.Iterable[Endpoint] websockets.broadcast(sockets, msg) except RuntimeError: logging.exception("Exception during broadcast_send_encoded_msgs") + return False else: if self.log_network: logging.info(f"Outgoing broadcast: {msg}") @@ -351,7 +358,6 @@ def notify_client_multiple(self, client: Client, texts: typing.List[str], additi for text in texts])) # loading - def load(self, multidatapath: str, use_embedded_server_options: bool = False): if multidatapath.lower().endswith(".zip"): import zipfile @@ -366,7 +372,7 @@ def load(self, multidatapath: str, use_embedded_server_options: bool = False): with open(multidatapath, 'rb') as f: data = f.read() - self._load(self.decompress(data), use_embedded_server_options) + self._load(self.decompress(data), {}, use_embedded_server_options) self.data_filename = multidatapath @staticmethod @@ -376,16 +382,19 @@ def decompress(data: bytes) -> dict: raise Utils.VersionException("Incompatible multidata.") return restricted_loads(zlib.decompress(data[1:])) - def _load(self, decoded_obj: dict, use_embedded_server_options: bool): + def _load(self, decoded_obj: dict, game_data_packages: typing.Dict[str, typing.Any], + use_embedded_server_options: bool): + self.read_data = {} mdata_ver = decoded_obj["minimum_versions"]["server"] - if mdata_ver > Utils.version_tuple: + if mdata_ver > version_tuple: raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}," - f"however this server is of version {Utils.version_tuple}") + f"however this server is of version {version_tuple}") + self.generator_version = Version(*decoded_obj["version"]) clients_ver = decoded_obj["minimum_versions"].get("clients", {}) self.minimum_client_versions = {} for player, version in clients_ver.items(): - self.minimum_client_versions[player] = max(Utils.Version(*version), min_client_version) + self.minimum_client_versions[player] = max(Version(*version), min_client_version) self.slot_info = decoded_obj["slot_info"] self.games = {slot: slot_info.game for slot, slot_info in self.slot_info.items()} @@ -407,7 +416,7 @@ def _load(self, decoded_obj: dict, use_embedded_server_options: bool): self.seed_name = decoded_obj["seed_name"] self.random.seed(self.seed_name) self.connect_names = decoded_obj['connect_names'] - self.locations = decoded_obj['locations'] + self.locations = LocationStore(decoded_obj.pop("locations")) # pre-emptively free memory self.slot_data = decoded_obj['slot_data'] for slot, data in self.slot_data.items(): self.read_data[f"slot_data_{slot}"] = lambda data=data: data @@ -431,14 +440,17 @@ def _load(self, decoded_obj: dict, use_embedded_server_options: bool): server_options = decoded_obj.get("server_options", {}) self._set_options(server_options) - # custom datapackage + # embedded data package for game_name, data in decoded_obj.get("datapackage", {}).items(): - logging.info(f"Loading custom datapackage for game {game_name}") + if game_name in game_data_packages: + data = game_data_packages[game_name] + logging.info(f"Loading embedded data package for game {game_name}") self.gamespackage[game_name] = data self.item_name_groups[game_name] = data["item_name_groups"] - self.location_name_groups[game_name] = data["location_name_groups"] - del data["item_name_groups"] # remove from datapackage, but keep in self.item_name_groups - del data["location_name_groups"] + if "location_name_groups" in data: + self.location_name_groups[game_name] = data["location_name_groups"] + del data["location_name_groups"] + del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups self._init_game_data() for game_name, data in self.item_name_groups.items(): self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame] @@ -534,7 +546,7 @@ def get_save(self) -> dict: "stored_data": self.stored_data, "game_options": {"hint_cost": self.hint_cost, "location_check_points": self.location_check_points, "server_password": self.server_password, "password": self.password, - "forfeit_mode": self.release_mode, "release_mode": self.release_mode, # TODO remove forfeit_mode around 0.4 + "release_mode": self.release_mode, "remaining_mode": self.remaining_mode, "collect_mode": self.collect_mode, "item_cheat": self.item_cheat, "compatibility": self.compatibility} @@ -587,7 +599,7 @@ def set_save(self, savedata: dict): def get_hint_cost(self, slot): if self.hint_cost: - return max(0, int(self.hint_cost * 0.01 * len(self.locations[slot]))) + return max(1, int(self.hint_cost * 0.01 * len(self.locations[slot]))) return 0 def recheck_hints(self, team: typing.Optional[int] = None, slot: typing.Optional[int] = None): @@ -690,6 +702,10 @@ def on_new_hint(self, team: int, slot: int): targets: typing.Set[Client] = set(self.stored_data_notification_clients[key]) if targets: self.broadcast(targets, [{"cmd": "SetReply", "key": key, "value": self.hints[team, slot]}]) + self.broadcast(self.clients[team][slot], [{ + "cmd": "RoomUpdate", + "hint_points": get_slot_points(self, team, slot) + }]) def update_aliases(ctx: Context, team: int): @@ -735,19 +751,24 @@ async def on_client_connected(ctx: Context, client: Client): NetworkPlayer(team, slot, ctx.name_aliases.get((team, slot), name), name) ) + games = {ctx.games[x] for x in range(1, len(ctx.games) + 1)} + games.add("Archipelago") await ctx.send_msgs(client, [{ 'cmd': 'RoomInfo', 'password': bool(ctx.password), - 'games': {ctx.games[x] for x in range(1, len(ctx.games) + 1)}, + 'games': games, # tags are for additional features in the communication. # Name them by feature or fork, as you feel is appropriate. 'tags': ctx.tags, - 'version': Utils.version_tuple, + 'version': version_tuple, + 'generator_version': ctx.generator_version, 'permissions': get_permissions(ctx), 'hint_cost': ctx.hint_cost, 'location_check_points': ctx.location_check_points, 'datapackage_versions': {game: game_data["version"] for game, game_data - in ctx.gamespackage.items()}, + in ctx.gamespackage.items() if game in games}, + 'datapackage_checksums': {game: game_data["checksum"] for game, game_data + in ctx.gamespackage.items() if game in games and "checksum" in game_data}, 'seed_name': ctx.seed_name, 'time': time.time(), }]) @@ -755,7 +776,6 @@ async def on_client_connected(ctx: Context, client: Client): def get_permissions(ctx) -> typing.Dict[str, Permission]: return { - "forfeit": Permission.from_text(ctx.release_mode), # TODO remove around 0.4 "release": Permission.from_text(ctx.release_mode), "remaining": Permission.from_text(ctx.remaining_mode), "collect": Permission.from_text(ctx.collect_mode) @@ -768,13 +788,14 @@ async def on_client_disconnected(ctx: Context, client: Client): async def on_client_joined(ctx: Context, client: Client): - update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED) + if ctx.client_game_state[client.team, client.slot] == ClientStatus.CLIENT_UNKNOWN: + update_client_status(ctx, client, ClientStatus.CLIENT_CONNECTED) version_str = '.'.join(str(x) for x in client.version) verb = "tracking" if "Tracker" in client.tags else "playing" ctx.broadcast_text_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) " f"{verb} {ctx.games[client.slot]} has joined. " - f"Client({version_str}), {client.tags}).", + f"Client({version_str}), {client.tags}.", {"type": "Join", "team": client.team, "slot": client.slot, "tags": client.tags}) ctx.notify_client(client, "Now that you are connected, " "you can use !help to list commands to run via the server. " @@ -785,11 +806,12 @@ async def on_client_joined(ctx: Context, client: Client): async def on_client_left(ctx: Context, client: Client): - update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN) + if len(ctx.clients[client.team][client.slot]) < 1: + update_client_status(ctx, client, ClientStatus.CLIENT_UNKNOWN) + ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) ctx.broadcast_text_all( "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1), {"type": "Part", "team": client.team, "slot": client.slot}) - ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) async def countdown(ctx: Context, timer: int): @@ -883,11 +905,7 @@ def release_player(ctx: Context, team: int, slot: int): def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): """register any locations that are in the multidata, pointing towards this player""" - all_locations = collections.defaultdict(set) - for source_slot, location_data in ctx.locations.items(): - for location_id, values in location_data.items(): - if values[1] == slot: - all_locations[source_slot].add(location_id) + all_locations = ctx.locations.get_for_player(slot) ctx.broadcast_text_all("%s (Team #%d) has collected their items from other worlds." % (ctx.player_names[(team, slot)], team + 1), @@ -906,11 +924,7 @@ def collect_player(ctx: Context, team: int, slot: int, is_group: bool = False): def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: - items = [] - for location_id in ctx.locations[slot]: - if location_id not in ctx.location_checks[team, slot]: - items.append(ctx.locations[slot][location_id][0]) # item ID - return sorted(items) + return ctx.locations.get_remaining(ctx.location_checks, team, slot) def send_items_to(ctx: Context, team: int, target_slot: int, *items: NetworkItem): @@ -958,13 +972,12 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st slots.add(group_id) 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: - found = location_id in ctx.location_checks[team, finding_player] - entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") - hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, - item_flags)) + for finding_player, location_id, item_id, receiving_player, item_flags \ + in ctx.locations.find_item(slots, seeked_item_id): + found = location_id in ctx.location_checks[team, finding_player] + entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") + hints.append(NetUtils.Hint(receiving_player, finding_player, location_id, item_id, found, entrance, + item_flags)) return hints @@ -1311,27 +1324,41 @@ def _cmd_remaining(self) -> bool: "Sorry, !remaining requires you to have beaten the game on this server") return False - def _cmd_missing(self) -> bool: - """List all missing location checks from the server's perspective""" + def _cmd_missing(self, filter_text="") -> bool: + """List all missing location checks from the server's perspective. + Can be given text, which will be used as filter.""" locations = get_missing_checks(self.ctx, self.client.team, self.client.slot) if locations: - texts = [f'Missing: {self.ctx.location_names[location]}' for location in locations] - texts.append(f"Found {len(locations)} missing location checks") + names = [self.ctx.location_names[location] for location in locations] + if filter_text: + names = [name for name in names if filter_text in name] + texts = [f'Missing: {name}' for name in names] + if filter_text: + texts.append(f"Found {len(locations)} missing location checks, displaying {len(names)} of them.") + else: + texts.append(f"Found {len(locations)} missing location checks") self.output_multiple(texts) else: self.output("No missing location checks found.") return True - def _cmd_checked(self) -> bool: - """List all done location checks from the server's perspective""" + def _cmd_checked(self, filter_text="") -> bool: + """List all done location checks from the server's perspective. + Can be given text, which will be used as filter.""" locations = get_checked_checks(self.ctx, self.client.team, self.client.slot) if locations: - texts = [f'Checked: {self.ctx.location_names[location]}' for location in locations] - texts.append(f"Found {len(locations)} done location checks") + names = [self.ctx.location_names[location] for location in locations] + if filter_text: + names = [name for name in names if filter_text in name] + texts = [f'Checked: {name}' for name in names] + if filter_text: + texts.append(f"Found {len(locations)} done location checks, displaying {len(names)} of them.") + else: + texts.append(f"Found {len(locations)} done location checks") self.output_multiple(texts) else: self.output("No done location checks found.") @@ -1522,15 +1549,11 @@ def _cmd_hint_location(self, location: str = "") -> bool: def get_checked_checks(ctx: Context, team: int, slot: int) -> typing.List[int]: - return [location_id for - location_id in ctx.locations[slot] if - location_id in ctx.location_checks[team, slot]] + return ctx.locations.get_checked(ctx.location_checks, team, slot) def get_missing_checks(ctx: Context, team: int, slot: int) -> typing.List[int]: - return [location_id for - location_id in ctx.locations[slot] if - location_id not in ctx.location_checks[team, slot]] + return ctx.locations.get_missing(ctx.location_checks, team, slot) def get_client_points(ctx: Context, client: Client) -> int: @@ -1610,7 +1633,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): "players": ctx.get_players_package(), "missing_locations": get_missing_checks(ctx, team, slot), "checked_locations": get_checked_checks(ctx, team, slot), - "slot_info": ctx.slot_info + "slot_info": ctx.slot_info, + "hint_points": get_slot_points(ctx, team, slot), } reply = [connected_packet] start_inventory = get_start_inventory(ctx, slot, client.remote_start_inventory) @@ -1712,6 +1736,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): hints.extend(collect_hint_location_id(ctx, client.team, client.slot, location)) locs.append(NetworkItem(target_item, location, target_player, flags)) ctx.notify_hints(client.team, hints, only_new=create_as_hint == 2) + if locs and create_as_hint: + ctx.save() await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) elif cmd == 'StatusUpdate': @@ -1770,6 +1796,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): targets.add(client) if targets: ctx.broadcast(targets, [args]) + ctx.save() elif cmd == "SetNotify": if "keys" not in args or type(args["keys"]) != list: @@ -1787,6 +1814,7 @@ def update_client_status(ctx: Context, client: Client, new_status: ClientStatus) ctx.on_goal_achieved(client) ctx.client_game_state[client.team, client.slot] = new_status + ctx.save() class ServerCommandProcessor(CommonCommandProcessor): @@ -1827,7 +1855,7 @@ def _cmd_status(self, tag: str = "") -> bool: def _cmd_exit(self) -> bool: """Shutdown the server""" - async_start(self.ctx.server.ws_server._close()) + self.ctx.server.ws_server.close() if self.ctx.shutdown_task: self.ctx.shutdown_task.cancel() self.ctx.exit_event.set() @@ -2090,13 +2118,15 @@ def attrtype(input_text: str): async def console(ctx: Context): import sys queue = asyncio.Queue() - Utils.stream_input(sys.stdin, queue) + worker = Utils.stream_input(sys.stdin, queue) while not ctx.exit_event.is_set(): try: # I don't get why this while loop is needed. Works fine without it on clients, # but the queue.get() for server never fulfills if the queue is empty when entering the await. while queue.qsize() == 0: await asyncio.sleep(0.05) + if not worker.is_alive(): + return input_text = await queue.get() queue.task_done() ctx.commandprocessor(input_text) @@ -2107,7 +2137,7 @@ async def console(ctx: Context): def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() - defaults = Utils.get_options()["server_options"] + defaults = Utils.get_options()["server_options"].as_dict() parser.add_argument('multidata', nargs="?", default=defaults["multidata"]) parser.add_argument('--host', default=defaults["host"]) parser.add_argument('--port', default=defaults["port"], type=int) @@ -2168,7 +2198,7 @@ async def auto_shutdown(ctx, to_cancel=None): await asyncio.sleep(ctx.auto_shutdown) while not ctx.exit_event.is_set(): if not ctx.client_activity_timers.values(): - async_start(ctx.server.ws_server._close()) + ctx.server.ws_server.close() ctx.exit_event.set() if to_cancel: for task in to_cancel: @@ -2179,7 +2209,7 @@ async def auto_shutdown(ctx, to_cancel=None): delta = datetime.datetime.now(datetime.timezone.utc) - newest_activity seconds = ctx.auto_shutdown - delta.total_seconds() if seconds < 0: - async_start(ctx.server.ws_server._close()) + ctx.server.ws_server.close() ctx.exit_event.set() if to_cancel: for task in to_cancel: @@ -2216,12 +2246,15 @@ async def main(args: argparse.Namespace): 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) + # when cx_Freeze'd the built-in exit is not available, so we import sys.exit instead + import sys + sys.exit(1) raise if not data_filename: logging.info("No file selected. Exiting.") - exit(1) + import sys + sys.exit(1) try: ctx.load(data_filename, args.use_embedded_options) @@ -2234,8 +2267,7 @@ async def main(args: argparse.Namespace): ssl_context = load_server_cert(args.cert, args.cert_key) if args.cert else None - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None, - ping_interval=None, ssl=ssl_context) + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ssl=ssl_context) ip = args.host if args.host else Utils.get_public_ipv4() logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, 'No password' if not ctx.password else 'Password: %s' % ctx.password)) diff --git a/NetUtils.py b/NetUtils.py index ca44fdea222c..c31aa695104c 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -2,11 +2,12 @@ import typing import enum +import warnings from json import JSONEncoder, JSONDecoder import websockets -from Utils import Version +from Utils import ByValue, Version class JSONMessagePart(typing.TypedDict, total=False): @@ -20,7 +21,7 @@ class JSONMessagePart(typing.TypedDict, total=False): flags: int -class ClientStatus(enum.IntEnum): +class ClientStatus(ByValue, enum.IntEnum): CLIENT_UNKNOWN = 0 CLIENT_CONNECTED = 5 CLIENT_READY = 10 @@ -28,18 +29,18 @@ class ClientStatus(enum.IntEnum): CLIENT_GOAL = 30 -class SlotType(enum.IntFlag): +class SlotType(ByValue, enum.IntFlag): spectator = 0b00 player = 0b01 group = 0b10 @property def always_goal(self) -> bool: - """Mark this slot has having reached its goal instantly.""" + """Mark this slot as having reached its goal instantly.""" return self.value != 0b01 -class Permission(enum.IntFlag): +class Permission(ByValue, enum.IntFlag): disabled = 0b000 # 0, completely disables access enabled = 0b001 # 1, allows manual use goal = 0b010 # 2, allows manual use after goal completion @@ -343,3 +344,77 @@ def as_network_message(self) -> dict: @property def local(self): return self.receiving_player == self.finding_player + + +class _LocationStore(dict, typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]): + def __init__(self, values: typing.MutableMapping[int, typing.Dict[int, typing.Tuple[int, int, int]]]): + super().__init__(values) + + if not self: + raise ValueError(f"Rejecting game with 0 players") + + if len(self) != max(self): + raise ValueError("Player IDs not continuous") + + if len(self.get(0, {})): + raise ValueError("Invalid player id 0 for location") + + def find_item(self, slots: typing.Set[int], seeked_item_id: int + ) -> typing.Generator[typing.Tuple[int, int, int, int, int], None, None]: + for finding_player, check_data in self.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: + yield finding_player, location_id, item_id, receiving_player, item_flags + + def get_for_player(self, slot: int) -> typing.Dict[int, typing.Set[int]]: + import collections + all_locations: typing.Dict[int, typing.Set[int]] = collections.defaultdict(set) + for source_slot, location_data in self.items(): + for location_id, values in location_data.items(): + if values[1] == slot: + all_locations[source_slot].add(location_id) + return all_locations + + def get_checked(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int + ) -> typing.List[int]: + checked = state[team, slot] + if not checked: + # This optimizes the case where everyone connects to a fresh game at the same time. + return [] + return [location_id for + location_id in self[slot] if + location_id in checked] + + def get_missing(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int + ) -> typing.List[int]: + checked = state[team, slot] + if not checked: + # This optimizes the case where everyone connects to a fresh game at the same time. + return list(self[slot]) + return [location_id for + location_id in self[slot] if + location_id not in checked] + + def get_remaining(self, state: typing.Dict[typing.Tuple[int, int], typing.Set[int]], team: int, slot: int + ) -> typing.List[int]: + checked = state[team, slot] + player_locations = self[slot] + return sorted([player_locations[location_id][0] for + location_id in player_locations if + location_id not in checked]) + + +if typing.TYPE_CHECKING: # type-check with pure python implementation until we have a typing stub + LocationStore = _LocationStore +else: + try: + import pyximport + pyximport.install() + except ImportError: + pyximport = None + try: + from _speedups import LocationStore + except ImportError: + warnings.warn("_speedups not available. Falling back to pure python LocationStore. " + "Install a matching C++ compiler for your platform to compile _speedups.") + LocationStore = _LocationStore diff --git a/OoTAdjuster.py b/OoTAdjuster.py index f449113d226f..38ebe62e2ae1 100644 --- a/OoTAdjuster.py +++ b/OoTAdjuster.py @@ -44,7 +44,7 @@ def adjustGUI(): StringVar, IntVar, Checkbutton, Frame, Label, X, Entry, Button, \ OptionMenu, filedialog, messagebox, ttk from argparse import Namespace - from Main import __version__ as MWVersion + from Utils import __version__ as MWVersion window = tk.Tk() window.wm_title(f"Archipelago {MWVersion} OoT Adjuster") diff --git a/OoTClient.py b/OoTClient.py index 696bf390160f..115490417334 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -17,9 +17,9 @@ from worlds.oot.Utils import data_path -CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart oot_connector.lua" -CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure oot_connector.lua is running" -CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart oot_connector.lua" +CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart connector_oot.lua" +CONNECTION_REFUSED_STATUS = "Connection refused. Please start your emulator and make sure connector_oot.lua is running" +CONNECTION_RESET_STATUS = "Connection was reset. Please restart your emulator, then restart connector_oot.lua" CONNECTION_TENTATIVE_STATUS = "Initial Connection Made" CONNECTION_CONNECTED_STATUS = "Connected" CONNECTION_INITIAL_STATUS = "Connection has not been initiated" @@ -100,7 +100,7 @@ async def server_auth(self, password_requested: bool = False): await super(OoTContext, self).server_auth(password_requested) if not self.auth: self.awaiting_rom = True - logger.info('Awaiting connection to Bizhawk to get player information') + logger.info('Awaiting connection to EmuHawk to get player information') return await self.send_connect() @@ -179,6 +179,12 @@ async def parse_payload(payload: dict, ctx: OoTContext, force: bool): locations = payload['locations'] collectibles = payload['collectibles'] + # The Lua JSON library serializes an empty table into a list instead of a dict. Verify types for safety: + if isinstance(locations, list): + locations = {} + if isinstance(collectibles, list): + collectibles = {} + if ctx.location_table != locations or ctx.collectible_table != collectibles: ctx.location_table = locations ctx.collectible_table = collectibles @@ -289,11 +295,17 @@ async def patch_and_run_game(apz5_file): decomp_path = base_name + '-decomp.z64' comp_path = base_name + '.z64' # Load vanilla ROM, patch file, compress ROM - rom = Rom(Utils.local_path(Utils.get_options()["oot_options"]["rom_file"])) - apply_patch_file(rom, apz5_file, - sub_file=(os.path.basename(base_name) + '.zpf' - if zipfile.is_zipfile(apz5_file) - else None)) + rom_file_name = Utils.get_options()["oot_options"]["rom_file"] + rom = Rom(rom_file_name) + + sub_file = None + if zipfile.is_zipfile(apz5_file): + for name in zipfile.ZipFile(apz5_file).namelist(): + if name.endswith('.zpf'): + sub_file = name + break + + apply_patch_file(rom, apz5_file, sub_file=sub_file) rom.write_to_file(decomp_path) os.chdir(data_path("Compress")) compress_rom_file(decomp_path, comp_path) diff --git a/Options.py b/Options.py index 8c73962602de..960e6c19d1ad 100644 --- a/Options.py +++ b/Options.py @@ -1,18 +1,21 @@ from __future__ import annotations + import abc import logging -from copy import deepcopy import math import numbers -import typing import random +import typing +from copy import deepcopy + +from schema import And, Optional, Or, Schema -from schema import Schema, And, Or, Optional from Utils import get_fuzzy_results if typing.TYPE_CHECKING: from BaseClasses import PlandoOptions from worlds.AutoWorld import World + import pathlib class AssembleOptions(abc.ABCMeta): @@ -715,8 +718,16 @@ def weighted_range(cls, text) -> Range: f"random-range-high--, or random-range--.") -class VerifyKeys: - valid_keys = frozenset() +class FreezeValidKeys(AssembleOptions): + def __new__(mcs, name, bases, attrs): + if "valid_keys" in attrs: + attrs["_valid_keys"] = frozenset(attrs["valid_keys"]) + return super(FreezeValidKeys, mcs).__new__(mcs, name, bases, attrs) + + +class VerifyKeys(metaclass=FreezeValidKeys): + valid_keys: typing.Iterable = [] + _valid_keys: frozenset # gets created by AssembleOptions from valid_keys valid_keys_casefold: bool = False convert_name_groups: bool = False verify_item_name: bool = False @@ -728,10 +739,10 @@ def verify_keys(cls, data: typing.List[str]): if cls.valid_keys: data = set(data) dataset = set(word.casefold() for word in data) if cls.valid_keys_casefold else set(data) - extra = dataset - cls.valid_keys + extra = dataset - cls._valid_keys if extra: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " - f"Allowed keys: {cls.valid_keys}.") + f"Allowed keys: {cls._valid_keys}.") def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if self.convert_name_groups and self.verify_item_name: @@ -760,7 +771,7 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P f"Did you mean '{picks[0][0]}' ({picks[0][1]}% sure)") -class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): +class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys, typing.Mapping[str, typing.Any]): default: typing.Dict[str, typing.Any] = {} supports_weighting = False @@ -778,8 +789,14 @@ def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: def get_option_name(self, value): return ", ".join(f"{key}: {v}" for key, v in value.items()) - def __contains__(self, item): - return item in self.value + def __getitem__(self, item: str) -> typing.Any: + return self.value.__getitem__(item) + + def __iter__(self) -> typing.Iterator[str]: + return self.value.__iter__() + + def __len__(self) -> int: + return self.value.__len__() class ItemDict(OptionDict): @@ -792,6 +809,10 @@ def __init__(self, value: typing.Dict[str, int]): class OptionList(Option[typing.List[typing.Any]], VerifyKeys): + # Supports duplicate entries and ordering. + # If only unique entries are needed and input order of elements does not matter, OptionSet should be used instead. + # Not a docstring so it doesn't get grabbed by the options system. + default: typing.List[typing.Any] = [] supports_weighting = False @@ -897,6 +918,13 @@ class StartInventory(ItemDict): display_name = "Start Inventory" +class StartInventoryPool(StartInventory): + """Start with these items and don't place them in the world. + The game decides what the replacement items will be.""" + verify_item_name = True + display_name = "Start Inventory from Pool" + + class StartHints(ItemSet): """Start with these item's locations prefilled into the !hint command.""" display_name = "Start Hints" @@ -904,6 +932,7 @@ class StartHints(ItemSet): class LocationSet(OptionSet): verify_location_name = True + convert_name_groups = True class StartLocationHints(LocationSet): @@ -928,6 +957,7 @@ class DeathLink(Toggle): class ItemLinks(OptionList): """Share part of your item pool with other players.""" + display_name = "Item Links" default = [] schema = Schema([ { @@ -1002,6 +1032,64 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P "item_links": ItemLinks } + +def generate_yaml_templates(target_folder: typing.Union[str, "pathlib.Path"], generate_hidden: bool = True): + import os + + import yaml + from jinja2 import Template + + from worlds import AutoWorldRegister + from Utils import local_path, __version__ + + full_path: str + + os.makedirs(target_folder, exist_ok=True) + + # clean out old + for file in os.listdir(target_folder): + full_path = os.path.join(target_folder, file) + if os.path.isfile(full_path) and full_path.endswith(".yaml"): + os.unlink(full_path) + + def dictify_range(option: typing.Union[Range, SpecialRange]): + data = {option.default: 50} + for sub_option in ["random", "random-low", "random-high"]: + if sub_option != option.default: + data[sub_option] = 0 + + notes = {} + for name, number in getattr(option, "special_range_names", {}).items(): + notes[name] = f"equivalent to {number}" + if number in data: + data[name] = data[number] + del data[number] + else: + data[name] = 0 + + return data, notes + + for game_name, world in AutoWorldRegister.world_types.items(): + if not world.hidden or generate_hidden: + all_options: typing.Dict[str, AssembleOptions] = { + **per_game_common_options, + **world.option_definitions + } + + with open(local_path("data", "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, + ) + + del file_data + + with open(os.path.join(target_folder, game_name + ".yaml"), "w", encoding="utf-8-sig") as f: + f.write(res) + + if __name__ == "__main__": from worlds.alttp.Options import Logic diff --git a/PokemonClient.py b/PokemonClient.py index e78e76fa00cc..6b43a53b8ff7 100644 --- a/PokemonClient.py +++ b/PokemonClient.py @@ -29,6 +29,9 @@ location_map[type(location.ram_address).__name__][location.ram_address.flag] = location.address location_bytes_bits[location.address] = {'byte': location.ram_address.byte, 'bit': location.ram_address.bit} +location_name_to_id = {location.name: location.address for location in location_data if location.type == "Item" + and location.address is not None} + SYSTEM_MESSAGE_ID = 0 CONNECTION_TIMING_OUT_STATUS = "Connection timing out. Please restart your emulator, then restart pkmn_rb.lua" @@ -72,13 +75,14 @@ def __init__(self, server_address, password): self.items_handling = 0b001 self.sent_release = False self.sent_collect = False + self.auto_hints = set() async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: await super(GBContext, self).server_auth(password_requested) if not self.auth: self.awaiting_rom = True - logger.info('Awaiting connection to Bizhawk to get Player information') + logger.info('Awaiting connection to EmuHawk to get Player information') return await self.send_connect() @@ -153,6 +157,33 @@ async def parse_locations(data: List, ctx: GBContext): locations.append(loc_id) elif flags[flag_type][location_bytes_bits[loc_id]['byte']] & 1 << location_bytes_bits[loc_id]['bit']: locations.append(loc_id) + + hints = [] + if flags["EventFlag"][280] & 16: + hints.append("Cerulean Bicycle Shop") + if flags["EventFlag"][280] & 32: + hints.append("Route 2 Gate - Oak's Aide") + if flags["EventFlag"][280] & 64: + hints.append("Route 11 Gate 2F - Oak's Aide") + if flags["EventFlag"][280] & 128: + hints.append("Route 15 Gate 2F - Oak's Aide") + if flags["EventFlag"][281] & 1: + hints += ["Celadon Prize Corner - Item Prize 1", "Celadon Prize Corner - Item Prize 2", + "Celadon Prize Corner - Item Prize 3"] + if (location_name_to_id["Fossil - Choice A"] in ctx.checked_locations and location_name_to_id["Fossil - Choice B"] + not in ctx.checked_locations): + hints.append("Fossil - Choice B") + elif (location_name_to_id["Fossil - Choice B"] in ctx.checked_locations and location_name_to_id["Fossil - Choice A"] + not in ctx.checked_locations): + hints.append("Fossil - Choice A") + hints = [ + location_name_to_id[loc] for loc in hints if location_name_to_id[loc] not in ctx.auto_hints and + location_name_to_id[loc] in ctx.missing_locations and location_name_to_id[loc] not in ctx.locations_checked + ] + if hints: + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": hints, "create_as_hint": 2}]) + ctx.auto_hints.update(hints) + if flags["EventFlag"][280] & 1 and not ctx.finished_game: await ctx.send_msgs([ {"cmd": "StatusUpdate", diff --git a/README.md b/README.md index b99182f4967e..54b659397f1b 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,18 @@ Currently, the following games are supported: * Stardew Valley * The Legend of Zelda * The Messenger +* Kingdom Hearts 2 +* The Legend of Zelda: Link's Awakening DX +* Clique +* Adventure +* DLC Quest +* Noita +* Undertale +* Bumper Stickers +* Mega Man Battle Network 3: Blue Version +* Muse Dash +* DOOM 1993 +* Terraria For setup and instructions check out our [tutorials page](https://archipelago.gg/tutorial/). Downloads can be found at [Releases](https://github.com/ArchipelagoMW/Archipelago/releases), including compiled diff --git a/SNIClient.py b/SNIClient.py index 8d402b1d5fe6..66d0b2ca9c22 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -115,8 +115,8 @@ def _cmd_snes_close(self) -> bool: class SNIContext(CommonContext): command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor - game = None # set in validate_rom - items_handling = None # set in game_watcher + game: typing.Optional[str] = None # set in validate_rom + items_handling: typing.Optional[int] = None # set in game_watcher snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None snes_autoreconnect_task: typing.Optional["asyncio.Task[None]"] = None @@ -315,7 +315,7 @@ def launch_sni() -> None: f"please start it yourself if it is not running") -async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol: +async def _snes_connect(ctx: SNIContext, address: str, retry: bool = True) -> WebSocketClientProtocol: address = f"ws://{address}" if "://" not in address else address snes_logger.info("Connecting to SNI at %s ..." % address) seen_problems: typing.Set[str] = set() @@ -336,6 +336,8 @@ async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtoco await asyncio.sleep(1) else: return snes_socket + if not retry: + break class SNESRequest(typing.TypedDict): @@ -563,14 +565,16 @@ async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int, PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} try: for address, data in write_list: - PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] - # REVIEW: above: `if snes_socket is None: return False` - # Does it need to be checked again? - if ctx.snes_socket is not None: - await ctx.snes_socket.send(dumps(PutAddress_Request)) - await ctx.snes_socket.send(data) - else: - snes_logger.warning(f"Could not send data to SNES: {data}") + while data: + # Divide the write into packets of 256 bytes. + PutAddress_Request['Operands'] = [hex(address)[2:], hex(min(len(data), 256))[2:]] + if ctx.snes_socket is not None: + await ctx.snes_socket.send(dumps(PutAddress_Request)) + await ctx.snes_socket.send(data[:256]) + address += 256 + data = data[256:] + else: + snes_logger.warning(f"Could not send data to SNES: {data}") except ConnectionClosed: return False @@ -684,6 +688,8 @@ async def main() -> None: logging.info(f"Wrote rom file to {romfile}") if args.diff_file.endswith(".apsoe"): import webbrowser + async_start(run_game(romfile)) + await _snes_connect(SNIContext(args.snes, args.connect, args.password), args.snes, False) webbrowser.open(f"http://www.evermizer.com/apclient/#server={meta['server']}") logging.info("Starting Evermizer Client in your Browser...") import time diff --git a/Starcraft2Client.py b/Starcraft2Client.py index cf16405766b4..cdcdb39a0b44 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -25,11 +25,10 @@ sc2_logger = logging.getLogger("Starcraft2") import nest_asyncio -import sc2 -from sc2.bot_ai import BotAI -from sc2.data import Race -from sc2.main import run_game -from sc2.player import Bot +from worlds._sc2common import bot +from worlds._sc2common.bot.data import Race +from worlds._sc2common.bot.main import run_game +from worlds._sc2common.bot.player import Bot 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 @@ -240,8 +239,6 @@ def run_gui(self): from kivy.uix.floatlayout import FloatLayout from kivy.properties import StringProperty - import Utils - class HoverableButton(HoverBehavior, Button): pass @@ -544,11 +541,11 @@ 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): - run_game(sc2.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), + run_game(bot.maps.get(maps_table[mission_id - 1]), [Bot(Race.Terran, ArchipelagoBot(ctx, mission_id), name="Archipelago", fullscreen=True)], realtime=True) -class ArchipelagoBot(sc2.bot_ai.BotAI): +class ArchipelagoBot(bot.bot_ai.BotAI): game_running: bool = False mission_completed: bool = False boni: typing.List[bool] @@ -867,7 +864,7 @@ def check_game_install_path() -> bool: documentspath = buf.value einfo = str(documentspath / Path("StarCraft II\\ExecuteInfo.txt")) else: - einfo = str(sc2.paths.get_home() / Path(sc2.paths.USERPATH[sc2.paths.PF])) + einfo = str(bot.paths.get_home() / Path(bot.paths.USERPATH[bot.paths.PF])) # Check if the file exists. if os.path.isfile(einfo): @@ -883,7 +880,7 @@ def check_game_install_path() -> bool: f"try again.") return False if os.path.exists(base): - executable = sc2.paths.latest_executeble(Path(base).expanduser() / "Versions") + executable = bot.paths.latest_executeble(Path(base).expanduser() / "Versions") # Finally, check the path for an actual executable. # If we find one, great. Set up the SC2PATH. diff --git a/UndertaleClient.py b/UndertaleClient.py new file mode 100644 index 000000000000..6419707211a6 --- /dev/null +++ b/UndertaleClient.py @@ -0,0 +1,512 @@ +from __future__ import annotations +import os +import sys +import asyncio +import typing +import bsdiff4 +import shutil + +import Utils + +from NetUtils import NetworkItem, ClientStatus +from worlds import undertale +from MultiServer import mark_raw +from CommonClient import CommonContext, server_loop, \ + gui_enabled, ClientCommandProcessor, logger, get_base_parser +from Utils import async_start + + +class UndertaleCommandProcessor(ClientCommandProcessor): + def __init__(self, ctx): + super().__init__(ctx) + + def _cmd_resync(self): + """Manually trigger a resync.""" + if isinstance(self.ctx, UndertaleContext): + self.output(f"Syncing items.") + self.ctx.syncing = True + + def _cmd_patch(self): + """Patch the game.""" + if isinstance(self.ctx, UndertaleContext): + os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + self.ctx.patch_game() + self.output("Patched.") + + def _cmd_savepath(self, directory: str): + """Redirect to proper save data folder. (Use before connecting!)""" + if isinstance(self.ctx, UndertaleContext): + UndertaleContext.save_game_folder = directory + self.output("Changed to the following directory: " + directory) + + @mark_raw + def _cmd_auto_patch(self, steaminstall: typing.Optional[str] = None): + """Patch the game automatically.""" + if isinstance(self.ctx, UndertaleContext): + os.makedirs(name=os.getcwd() + "\\Undertale", exist_ok=True) + tempInstall = steaminstall + if not os.path.isfile(os.path.join(tempInstall, "data.win")): + tempInstall = None + if tempInstall is None: + tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" + elif not os.path.exists(tempInstall): + tempInstall = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists("C:\\Program Files (x86)\\Steam\\steamapps\\common\\Undertale"): + tempInstall = "C:\\Program Files\\Steam\\steamapps\\common\\Undertale" + if not os.path.exists(tempInstall) or not os.path.exists(tempInstall) or not os.path.isfile(os.path.join(tempInstall, "data.win")): + self.output("ERROR: Cannot find Undertale. Please rerun the command with the correct folder." + " command. \"/auto_patch (Steam directory)\".") + else: + for file_name in os.listdir(tempInstall): + if file_name != "steam_api.dll": + shutil.copy(tempInstall+"\\"+file_name, + os.getcwd() + "\\Undertale\\" + file_name) + self.ctx.patch_game() + self.output("Patching successful!") + + def _cmd_online(self): + """Makes you no longer able to see other Undertale players.""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.update_online_mode(not ("Online" in self.ctx.tags)) + if "Online" in self.ctx.tags: + self.output(f"Now online.") + else: + self.output(f"Now offline.") + + def _cmd_deathlink(self): + """Toggles deathlink""" + if isinstance(self.ctx, UndertaleContext): + self.ctx.deathlink_status = not self.ctx.deathlink_status + if self.ctx.deathlink_status: + self.output(f"Deathlink enabled.") + else: + self.output(f"Deathlink disabled.") + + +class UndertaleContext(CommonContext): + tags = {"AP", "Online"} + game = "Undertale" + command_processor = UndertaleCommandProcessor + items_handling = 0b111 + route = None + pieces_needed = None + completed_routes = None + completed_count = 0 + save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + + def __init__(self, server_address, password): + super().__init__(server_address, password) + self.pieces_needed = 0 + self.finished_game = False + self.game = "Undertale" + self.got_deathlink = False + self.syncing = False + self.deathlink_status = False + self.tem_armor = False + self.completed_count = 0 + self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} + # self.save_game_folder: files go in this path to pass data between us and the actual game + self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + + def patch_game(self): + with open(os.getcwd() + "/Undertale/data.win", "rb") as f: + patchedFile = bsdiff4.patch(f.read(), undertale.data_path("patch.bsdiff")) + with open(os.getcwd() + "/Undertale/data.win", "wb") as f: + f.write(patchedFile) + os.makedirs(name=os.getcwd() + "\\Undertale\\" + "Custom Sprites", exist_ok=True) + with open(os.path.expandvars(os.getcwd() + "\\Undertale\\" + "Custom Sprites\\" + + "Which Character.txt"), "w") as f: + f.writelines(["// Put the folder name of the sprites you want to play as, make sure it is the only " + "line other than this one.\n", "frisk"]) + f.close() + + async def server_auth(self, password_requested: bool = False): + if password_requested and not self.password: + await super().server_auth(password_requested) + await self.get_username() + await self.send_connect() + + def clear_undertale_files(self): + path = self.save_game_folder + self.finished_game = False + for root, dirs, files in os.walk(path): + for file in files: + if "check.spot" == file or "scout" == file: + os.remove(os.path.join(root, file)) + elif file.endswith((".item", ".victory", ".route", ".playerspot", ".mad", + ".youDied", ".LV", ".mine", ".flag", ".hint")): + os.remove(os.path.join(root, file)) + + async def connect(self, address: typing.Optional[str] = None): + self.clear_undertale_files() + await super().connect(address) + + async def disconnect(self, allow_autoreconnect: bool = False): + self.clear_undertale_files() + await super().disconnect(allow_autoreconnect) + + async def connection_closed(self): + self.clear_undertale_files() + await super().connection_closed() + + async def shutdown(self): + self.clear_undertale_files() + await super().shutdown() + + def update_online_mode(self, online): + old_tags = self.tags.copy() + if online: + self.tags.add("Online") + else: + self.tags -= {"Online"} + if old_tags != self.tags and self.server and not self.server.socket.closed: + async_start(self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}])) + + def on_package(self, cmd: str, args: dict): + if cmd == "Connected": + self.game = self.slot_info[self.slot].game + async_start(process_undertale_cmd(self, cmd, args)) + + def run_gui(self): + from kvui import GameManager + + class UTManager(GameManager): + logging_pairs = [ + ("Client", "Archipelago") + ] + base_title = "Archipelago Undertale Client" + + self.ui = UTManager(self) + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + + def on_deathlink(self, data: typing.Dict[str, typing.Any]): + self.got_deathlink = True + super().on_deathlink(data) + + +def to_room_name(place_name: str): + if place_name == "Old Home Exit": + return "room_ruinsexit" + elif place_name == "Snowdin Forest": + return "room_tundra1" + elif place_name == "Snowdin Town Exit": + return "room_fogroom" + elif place_name == "Waterfall": + return "room_water1" + elif place_name == "Waterfall Exit": + return "room_fire2" + elif place_name == "Hotland": + return "room_fire_prelab" + elif place_name == "Hotland Exit": + return "room_fire_precore" + elif place_name == "Core": + return "room_fire_core1" + + +async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): + if cmd == "Connected": + if not os.path.exists(ctx.save_game_folder): + os.mkdir(ctx.save_game_folder) + ctx.route = args["slot_data"]["route"] + ctx.pieces_needed = args["slot_data"]["key_pieces"] + ctx.tem_armor = args["slot_data"]["temy_armor_include"] + + await ctx.send_msgs([{"cmd": "Get", "keys": [str(ctx.slot)+" RoutesDone neutral", + str(ctx.slot)+" RoutesDone pacifist", + str(ctx.slot)+" RoutesDone genocide"]}]) + await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", + str(ctx.slot)+" RoutesDone pacifist", + str(ctx.slot)+" RoutesDone genocide"]}]) + if args["slot_data"]["only_flakes"]: + with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: + f.close() + if not args["slot_data"]["key_hunt"]: + ctx.pieces_needed = 0 + if args["slot_data"]["rando_love"]: + filename = f"LOVErando.LV" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + if args["slot_data"]["rando_stats"]: + filename = f"STATrando.LV" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + filename = f"{ctx.route}.route" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.close() + filename = f"check.spot" + with open(os.path.join(ctx.save_game_folder, filename), "a") as f: + for ss in set(args["checked_locations"]): + f.write(str(ss-12000)+"\n") + f.close() + elif cmd == "LocationInfo": + for l in args["locations"]: + locationid = l.location + filename = f"{str(locationid-12000)}.hint" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + toDraw = "" + for i in range(20): + if i < len(str(ctx.item_names[l.item])): + toDraw += str(ctx.item_names[l.item])[i] + else: + break + f.write(toDraw) + f.close() + elif cmd == "Retrieved": + if str(ctx.slot)+" RoutesDone neutral" in args["keys"]: + if args["keys"][str(ctx.slot)+" RoutesDone neutral"] is not None: + ctx.completed_routes["neutral"] = args["keys"][str(ctx.slot)+" RoutesDone neutral"] + if str(ctx.slot)+" RoutesDone genocide" in args["keys"]: + if args["keys"][str(ctx.slot)+" RoutesDone genocide"] is not None: + ctx.completed_routes["genocide"] = args["keys"][str(ctx.slot)+" RoutesDone genocide"] + if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: + if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: + ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] + elif cmd == "SetReply": + if args["value"] is not None: + if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: + ctx.completed_routes["pacifist"] = args["value"] + elif str(ctx.slot)+" RoutesDone genocide" == args["key"]: + ctx.completed_routes["genocide"] = args["value"] + elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: + ctx.completed_routes["neutral"] = args["value"] + elif cmd == "ReceivedItems": + start_index = args["index"] + + if start_index == 0: + ctx.items_received = [] + elif start_index != len(ctx.items_received): + sync_msg = [{"cmd": "Sync"}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", + "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + if start_index == len(ctx.items_received): + counter = -1 + placedWeapon = 0 + placedArmor = 0 + for item in args["items"]: + id = NetworkItem(*item).location + while NetworkItem(*item).location < 0 and \ + counter <= id: + id -= 1 + if NetworkItem(*item).location < 0: + counter -= 1 + filename = f"{str(id)}PLR{str(NetworkItem(*item).player)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + if NetworkItem(*item).item == 77701: + if placedWeapon == 0: + f.write(str(77013-11000)) + elif placedWeapon == 1: + f.write(str(77014-11000)) + elif placedWeapon == 2: + f.write(str(77025-11000)) + elif placedWeapon == 3: + f.write(str(77045-11000)) + elif placedWeapon == 4: + f.write(str(77049-11000)) + elif placedWeapon == 5: + f.write(str(77047-11000)) + elif placedWeapon == 6: + if str(ctx.route) == "genocide" or str(ctx.route) == "all_routes": + f.write(str(77052-11000)) + else: + f.write(str(77051-11000)) + else: + f.write(str(77003-11000)) + placedWeapon += 1 + elif NetworkItem(*item).item == 77702: + if placedArmor == 0: + f.write(str(77012-11000)) + elif placedArmor == 1: + f.write(str(77015-11000)) + elif placedArmor == 2: + f.write(str(77024-11000)) + elif placedArmor == 3: + f.write(str(77044-11000)) + elif placedArmor == 4: + f.write(str(77048-11000)) + elif placedArmor == 5: + if str(ctx.route) == "genocide": + f.write(str(77053-11000)) + else: + f.write(str(77046-11000)) + elif placedArmor == 6 and ((not str(ctx.route) == "genocide") or ctx.tem_armor): + if str(ctx.route) == "all_routes": + f.write(str(77053-11000)) + elif str(ctx.route) == "genocide": + f.write(str(77064-11000)) + else: + f.write(str(77050-11000)) + elif placedArmor == 7 and ctx.tem_armor and not str(ctx.route) == "genocide": + f.write(str(77064-11000)) + else: + f.write(str(77004-11000)) + placedArmor += 1 + else: + f.write(str(NetworkItem(*item).item-11000)) + f.close() + ctx.items_received.append(NetworkItem(*item)) + if [item.item for item in ctx.items_received].count(77000) >= ctx.pieces_needed > 0: + filename = f"{str(-99999)}PLR{str(0)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(77787 - 11000)) + f.close() + filename = f"{str(-99998)}PLR{str(0)}.item" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(77789 - 11000)) + f.close() + ctx.watcher_event.set() + + elif cmd == "RoomUpdate": + if "checked_locations" in args: + filename = f"check.spot" + with open(os.path.join(ctx.save_game_folder, filename), "a") as f: + for ss in set(args["checked_locations"]): + f.write(str(ss-12000)+"\n") + f.close() + + elif cmd == "Bounced": + tags = args.get("tags", []) + if "Online" in tags: + data = args.get("data", {}) + if data["player"] != ctx.slot and data["player"] is not None: + filename = f"FRISK" + str(data["player"]) + ".playerspot" + with open(os.path.join(ctx.save_game_folder, filename), "w") as f: + f.write(str(data["x"]) + str(data["y"]) + str(data["room"]) + str( + data["spr"]) + str(data["frm"])) + f.close() + + +async def multi_watcher(ctx: UndertaleContext): + while not ctx.exit_event.is_set(): + path = ctx.save_game_folder + for root, dirs, files in os.walk(path): + for file in files: + if "spots.mine" in file and "Online" in ctx.tags: + with open(root + "/" + file, "r") as mine: + this_x = mine.readline() + this_y = mine.readline() + this_room = mine.readline() + this_sprite = mine.readline() + this_frame = mine.readline() + mine.close() + message = [{"cmd": "Bounce", "tags": ["Online"], + "data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, + "spr": this_sprite, "frm": this_frame}}] + await ctx.send_msgs(message) + + await asyncio.sleep(0.1) + + +async def game_watcher(ctx: UndertaleContext): + while not ctx.exit_event.is_set(): + await ctx.update_death_link(ctx.deathlink_status) + path = ctx.save_game_folder + if ctx.syncing: + for root, dirs, files in os.walk(path): + for file in files: + if ".item" in file: + os.remove(root+"/"+file) + sync_msg = [{"cmd": "Sync"}] + if ctx.locations_checked: + sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) + await ctx.send_msgs(sync_msg) + ctx.syncing = False + if ctx.got_deathlink: + ctx.got_deathlink = False + with open(os.path.join(ctx.save_game_folder, "WelcomeToTheDead.youDied"), "w") as f: + f.close() + sending = [] + victory = False + found_routes = 0 + for root, dirs, files in os.walk(path): + for file in files: + if "DontBeMad.mad" in file: + os.remove(root+"/"+file) + if "DeathLink" in ctx.tags: + await ctx.send_death() + if "scout" == file: + sending = [] + try: + with open(root+"/"+file, "r") as f: + lines = f.readlines() + for l in lines: + if ctx.server_locations.__contains__(int(l)+12000): + sending = sending + [int(l.rstrip('\n'))+12000] + finally: + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": sending, + "create_as_hint": int(2)}]) + os.remove(root+"/"+file) + if "check.spot" in file: + sending = [] + try: + with open(root+"/"+file, "r") as f: + lines = f.readlines() + for l in lines: + sending = sending+[(int(l.rstrip('\n')))+12000] + finally: + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) + if "victory" in file and str(ctx.route) in file: + victory = True + if ".playerspot" in file and "Online" not in ctx.tags: + os.remove(root+"/"+file) + if "victory" in file: + if str(ctx.route) == "all_routes": + if "neutral" in file and ctx.completed_routes["neutral"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone neutral", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + elif "pacifist" in file and ctx.completed_routes["pacifist"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone pacifist", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + elif "genocide" in file and ctx.completed_routes["genocide"] != 1: + await ctx.send_msgs([{"cmd": "Set", "key": str(ctx.slot)+" RoutesDone genocide", + "default": 0, "want_reply": True, "operations": [{"operation": "max", + "value": 1}]}]) + if str(ctx.route) == "all_routes": + found_routes += ctx.completed_routes["neutral"] + found_routes += ctx.completed_routes["pacifist"] + found_routes += ctx.completed_routes["genocide"] + if str(ctx.route) == "all_routes" and found_routes >= 3: + victory = True + ctx.locations_checked = sending + if (not ctx.finished_game) and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + await asyncio.sleep(0.1) + + +def main(): + Utils.init_logging("UndertaleClient", exception_logger="Client") + + async def _main(): + ctx = UndertaleContext(None, None) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") + asyncio.create_task( + game_watcher(ctx), name="UndertaleProgressionWatcher") + + asyncio.create_task( + multi_watcher(ctx), name="UndertaleMultiplayerWatcher") + + if gui_enabled: + ctx.run_gui() + ctx.run_cli() + + await ctx.exit_event.wait() + await ctx.shutdown() + + import colorama + + colorama.init() + + asyncio.run(_main()) + colorama.deinit() + + +if __name__ == "__main__": + parser = get_base_parser(description="Undertale Client, for text interfacing.") + args = parser.parse_args() + main() diff --git a/Utils.py b/Utils.py index 059168c85725..159c6cdcb161 100644 --- a/Utils.py +++ b/Utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import json import typing import builtins import os @@ -12,8 +13,10 @@ import collections import importlib import logging -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union +from argparse import Namespace +from settings import Settings, get_settings +from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union from yaml import load, load_all, dump, SafeLoader try: @@ -37,8 +40,11 @@ class Version(typing.NamedTuple): minor: int build: int + def as_simple_string(self) -> str: + return ".".join(str(item) for item in self) -__version__ = "0.3.9" + +__version__ = "0.4.2" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -87,7 +93,10 @@ def is_frozen() -> bool: def local_path(*path: str) -> str: - """Returns path to a file in the local Archipelago installation or source.""" + """ + Returns path to a file in the local Archipelago installation or source. + This might be read-only and user_path should be used instead for ROMs, configuration, etc. + """ if hasattr(local_path, 'cached_path'): pass elif is_frozen(): @@ -131,17 +140,31 @@ def user_path(*path: str) -> str: user_path.cached_path = local_path() else: user_path.cached_path = home_path() - # populate home from local - TODO: upgrade feature - if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): - import shutil - for dn in ("Players", "data/sprites"): - shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ("manifest.json", "host.yaml"): - shutil.copy2(local_path(fn), user_path(fn)) + # populate home from local + if user_path.cached_path != local_path(): + import filecmp + if not os.path.exists(user_path("manifest.json")) or \ + not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): + import shutil + for dn in ("Players", "data/sprites"): + shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) + for fn in ("manifest.json",): + shutil.copy2(local_path(fn), user_path(fn)) return os.path.join(user_path.cached_path, *path) +def cache_path(*path: str) -> str: + """Returns path to a file in the user's Archipelago cache directory.""" + if hasattr(cache_path, "cached_path"): + pass + else: + import platformdirs + cache_path.cached_path = platformdirs.user_cache_dir("Archipelago", False) + + return os.path.join(cache_path.cached_path, *path) + + def output_path(*path: str) -> str: if hasattr(output_path, 'cached_path'): return os.path.join(output_path.cached_path, *path) @@ -220,142 +243,15 @@ def get_public_ipv6() -> str: return ip -OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]] - - -@cache_argsless -def get_default_options() -> OptionsType: - # Refer to host.yaml for comments as to what all these options mean. - options = { - "general_options": { - "output_path": "output", - }, - "factorio_options": { - "executable": os.path.join("factorio", "bin", "x64", "factorio"), - "filter_item_sends": False, - "bridge_chat_out": True, - }, - "sni_options": { - "sni_path": "SNI", - "snes_rom_start": True, - }, - "sm_options": { - "rom_file": "Super Metroid (JU).sfc", - }, - "soe_options": { - "rom_file": "Secret of Evermore (USA).sfc", - }, - "lttp_options": { - "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", - }, - "server_options": { - "host": None, - "port": 38281, - "password": None, - "multidata": None, - "savefile": None, - "disable_save": False, - "loglevel": "info", - "server_password": None, - "disable_item_cheat": False, - "location_check_points": 1, - "hint_cost": 10, - "release_mode": "goal", - "collect_mode": "disabled", - "remaining_mode": "goal", - "auto_shutdown": 0, - "compatibility": 2, - "log_network": 0 - }, - "generator": { - "enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"), - "player_files_path": "Players", - "players": 0, - "weights_file_path": "weights.yaml", - "meta_file_path": "meta.yaml", - "spoiler": 3, - "glitch_triforce_room": 1, - "race": 0, - "plando_options": "bosses", - }, - "minecraft_options": { - "forge_directory": "Minecraft Forge server", - "max_heap_size": "2G", - "release_channel": "release" - }, - "oot_options": { - "rom_file": "The Legend of Zelda - Ocarina of Time.z64", - "rom_start": True - }, - "dkc3_options": { - "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - }, - "smw_options": { - "rom_file": "Super Mario World (USA).sfc", - }, - "zillion_options": { - "rom_file": "Zillion (UE) [!].sms", - # RetroArch doesn't make it easy to launch a game from the command line. - # You have to know the path to the emulator core library on the user's computer. - "rom_start": "retroarch", - }, - "pokemon_rb_options": { - "red_rom_file": "Pokemon Red (UE) [S][!].gb", - "blue_rom_file": "Pokemon Blue (UE) [S][!].gb", - "rom_start": True - }, - "ffr_options": { - "display_msgs": True, - }, - "lufia2ac_options": { - "rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc", - }, - "tloz_options": { - "rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes", - "rom_start": True, - "display_msgs": True, - }, - "wargroove_options": { - "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" - } - } - return options - - -def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: - for key, value in src.items(): - new_keys = keys.copy() - new_keys.append(key) - option_name = '.'.join(new_keys) - if key not in dest: - dest[key] = value - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} is missing {option_name}") - elif isinstance(value, dict): - if not isinstance(dest.get(key, None), dict): - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.") - dest[key] = value - else: - dest[key] = update_options(value, dest[key], filename, new_keys) - return dest +OptionsType = Settings # TODO: remove ~2 versions after 0.4.1 @cache_argsless -def get_options() -> OptionsType: - filenames = ("options.yaml", "host.yaml") - locations: typing.List[str] = [] - if os.path.join(os.getcwd()) != local_path(): - locations += filenames # use files from cwd only if it's not the local_path - locations += [user_path(filename) for filename in filenames] +def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1 + return Settings(None) - for location in locations: - if os.path.exists(location): - with open(location) as f: - options = parse_yaml(f.read()) - return update_options(get_default_options(), options, location, list()) - raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") +get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported def persistent_store(category: str, key: typing.Any, value: typing.Any): @@ -385,11 +281,65 @@ def persistent_load() -> typing.Dict[str, dict]: return storage -def get_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]: - adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) +def get_file_safe_name(name: str) -> str: + return "".join(c for c in name if c not in '<>:"/\\|?*') + + +def load_data_package_for_checksum(game: str, checksum: typing.Optional[str]) -> Dict[str, Any]: + if checksum and game: + if checksum != get_file_safe_name(checksum): + raise ValueError(f"Bad symbols in checksum: {checksum}") + path = cache_path("datapackage", get_file_safe_name(game), f"{checksum}.json") + if os.path.exists(path): + try: + with open(path, "r", encoding="utf-8-sig") as f: + return json.load(f) + except Exception as e: + logging.debug(f"Could not load data package: {e}") + + # fall back to old cache + cache = persistent_load().get("datapackage", {}).get("games", {}).get(game, {}) + if cache.get("checksum") == checksum: + return cache + + # cache does not match + return {} + + +def store_data_package_for_checksum(game: str, data: typing.Dict[str, Any]) -> None: + checksum = data.get("checksum") + if checksum and game: + if checksum != get_file_safe_name(checksum): + raise ValueError(f"Bad symbols in checksum: {checksum}") + game_folder = cache_path("datapackage", get_file_safe_name(game)) + os.makedirs(game_folder, exist_ok=True) + try: + with open(os.path.join(game_folder, f"{checksum}.json"), "w", encoding="utf-8-sig") as f: + json.dump(data, f, ensure_ascii=False, separators=(",", ":")) + except Exception as e: + logging.debug(f"Could not store data package: {e}") + +def get_default_adjuster_settings(game_name: str) -> Namespace: + import LttPAdjuster + adjuster_settings = Namespace() + if game_name == LttPAdjuster.GAME_ALTTP: + return LttPAdjuster.get_argparser().parse_known_args(args=[])[0] + return adjuster_settings +def get_adjuster_settings_no_defaults(game_name: str) -> Namespace: + return persistent_load().get("adjuster", {}).get(game_name, Namespace()) + + +def get_adjuster_settings(game_name: str) -> Namespace: + adjuster_settings = get_adjuster_settings_no_defaults(game_name) + default_settings = get_default_adjuster_settings(game_name) + + # Fill in any arguments from the argparser that we haven't seen before + return Namespace(**vars(adjuster_settings), **{k:v for k,v in vars(default_settings).items() if k not in vars(adjuster_settings)}) + + @cache_argsless def get_unique_identifier(): uuid = persistent_load().get("client", {}).get("uuid", None) @@ -442,6 +392,15 @@ def restricted_loads(s): return RestrictedUnpickler(io.BytesIO(s)).load() +class ByValue: + """ + Mixin for enums to pickle value instead of name (restores pre-3.11 behavior). Use as left-most parent. + See https://github.com/python/cpython/pull/26658 for why this exists. + """ + def __reduce_ex__(self, prot): + return self.__class__, (self._value_, ) + + class KeyedDefaultDict(collections.defaultdict): """defaultdict variant that uses the missing key as argument to default_factory""" default_factory: typing.Callable[[typing.Any], typing.Any] @@ -474,6 +433,7 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri root_logger.removeHandler(handler) handler.close() root_logger.setLevel(loglevel) + logging.getLogger("websockets").setLevel(loglevel) # make sure level is applied for websockets if "a" not in write_mode: name += f"_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}" file_handler = logging.FileHandler( @@ -597,7 +557,7 @@ def get_fuzzy_ratio(word1: str, word2: str) -> float: ) -def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ +def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ -> typing.Optional[str]: def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None @@ -608,11 +568,12 @@ def run(*args: str): kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) + return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - return run(zenity, f"--title={title}", "--file-selection", *z_filters) + selection = (f'--filename="{suggest}',) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -625,7 +586,38 @@ def run(*args: str): else: root = tkinter.Tk() root.withdraw() - return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes)) + return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None) + + +def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: + def run(*args: str): + return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None + + if is_linux: + # prefer native dialog + from shutil import which + kdialog = None#which("kdialog") + if kdialog: + return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".") + zenity = None#which("zenity") + if zenity: + z_filters = ("--directory",) + selection = (f'--filename="{suggest}',) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + + # fall back to tk + try: + import tkinter + import tkinter.filedialog + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed. ' + f'This attempt was made because open_filename was used for "{title}".') + raise e + else: + root = tkinter.Tk() + root.withdraw() + return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) def messagebox(title: str, text: str, error: bool = False) -> None: @@ -690,10 +682,10 @@ def read_snes_rom(stream: BinaryIO, strip_header: bool = True) -> bytearray: return buffer -_faf_tasks: "Set[asyncio.Task[None]]" = set() +_faf_tasks: "Set[asyncio.Task[typing.Any]]" = set() -def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] = None) -> None: +def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = None) -> None: """ Use this to start a task when you don't keep a reference to it or immediately await it, to prevent early garbage collection. "fire-and-forget" @@ -706,6 +698,60 @@ def async_start(co: Coroutine[typing.Any, typing.Any, bool], name: Optional[str] # ``` # This implementation follows the pattern given in that documentation. - task = asyncio.create_task(co, name=name) + task: asyncio.Task[typing.Any] = asyncio.create_task(co, name=name) _faf_tasks.add(task) task.add_done_callback(_faf_tasks.discard) + + +def deprecate(message: str): + if __debug__: + raise Exception(message) + import warnings + warnings.warn(message) + +def _extend_freeze_support() -> None: + """Extend multiprocessing.freeze_support() to also work on Non-Windows for spawn.""" + # upstream issue: https://github.com/python/cpython/issues/76327 + # code based on https://github.com/pyinstaller/pyinstaller/blob/develop/PyInstaller/hooks/rthooks/pyi_rth_multiprocessing.py#L26 + import multiprocessing + import multiprocessing.spawn + + def _freeze_support() -> None: + """Minimal freeze_support. Only apply this if frozen.""" + from subprocess import _args_from_interpreter_flags + + # Prevent `spawn` from trying to read `__main__` in from the main script + multiprocessing.process.ORIGINAL_DIR = None + + # Handle the first process that MP will create + if ( + len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith(( + 'from multiprocessing.semaphore_tracker import main', # Py<3.8 + 'from multiprocessing.resource_tracker import main', # Py>=3.8 + 'from multiprocessing.forkserver import main' + )) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags()) + ): + exec(sys.argv[-1]) + sys.exit() + + # Handle the second process that MP will create + if multiprocessing.spawn.is_forking(sys.argv): + kwargs = {} + for arg in sys.argv[2:]: + name, value = arg.split('=') + if value == 'None': + kwargs[name] = None + else: + kwargs[name] = int(value) + multiprocessing.spawn.spawn_main(**kwargs) + sys.exit() + + if not is_windows and is_frozen(): + multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support + + +def freeze_support() -> None: + """This behaves like multiprocessing.freeze_support but also works on Non-Windows.""" + import multiprocessing + _extend_freeze_support() + multiprocessing.freeze_support() diff --git a/WebHost.py b/WebHost.py index 40d366a02f9e..45d017cf1f67 100644 --- a/WebHost.py +++ b/WebHost.py @@ -10,6 +10,7 @@ # in case app gets imported by something like gunicorn import Utils +import settings Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 @@ -21,6 +22,7 @@ from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.options import create as create_options_files +settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home configpath = os.path.abspath(Utils.user_path('config.yaml')) @@ -72,6 +74,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]] with zipfile.ZipFile(zipfile_path) as zf: for zfile in zf.infolist(): if not zfile.is_dir() and "/docs/" in zfile.filename: + zfile.filename = os.path.basename(zfile.filename) zf.extract(zfile, target_path) else: source_path = Utils.local_path(os.path.dirname(world.__file__), "docs") diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 8bd3609c1db9..a59e3aa553f3 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -34,7 +34,7 @@ # if you want to deploy, make sure you have a non-guessable secret key app.config["SECRET_KEY"] = bytes(socket.gethostname(), encoding="utf-8") # at what amount of worlds should scheduling be used, instead of rolling in the web-thread -app.config["JOB_THRESHOLD"] = 2 +app.config["JOB_THRESHOLD"] = 1 # after what time in seconds should generation be aborted, freeing the queue slot. Can be set to None to disable. app.config["JOB_TIME"] = 600 app.config['SESSION_PERMANENT'] = True diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index eac19d84563b..102c3a49f6aa 100644 --- a/WebHostLib/api/__init__.py +++ b/WebHostLib/api/__init__.py @@ -39,12 +39,21 @@ def get_datapackage(): @api_endpoints.route('/datapackage_version') @cache.cached() - def get_datapackage_versions(): - from worlds import network_data_package, AutoWorldRegister + from worlds import AutoWorldRegister version_package = {game: world.data_version for game, world in AutoWorldRegister.world_types.items()} return version_package +@api_endpoints.route('/datapackage_checksum') +@cache.cached() +def get_datapackage_checksums(): + from worlds import network_data_package + version_package = { + game: game_data["checksum"] for game, game_data in network_data_package["games"].items() + } + return version_package + + from . import generate, user # trigger registration diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py index 1d9e6fd9c192..61e9164e2652 100644 --- a/WebHostLib/api/generate.py +++ b/WebHostLib/api/generate.py @@ -2,7 +2,8 @@ import pickle from uuid import UUID -from flask import request, session, url_for, Markup +from flask import request, session, url_for +from markupsafe import Markup from pony.orm import commit from WebHostLib import app @@ -48,9 +49,8 @@ def generate_api(): if len(options) > app.config["MAX_ROLL"]: return {"text": "Max size of multiworld exceeded", "detail": app.config["MAX_ROLL"]}, 409 - meta = get_meta(meta_options_source) - meta["race"] = race - results, gen_options = roll_options(options, meta["plando_options"]) + meta = get_meta(meta_options_source, race) + results, gen_options = roll_options(options, set(meta["plando_options"])) if any(type(result) == str for result in results.values()): return {"text": str(results), "detail": results}, 400 diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 484755b3c3a4..0475a6329727 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -135,7 +135,7 @@ def keep_running(): with Locker("autogen"): with multiprocessing.Pool(config["GENERATORS"], initializer=init_db, - initargs=(config["PONY"],)) as generator_pool: + initargs=(config["PONY"],), maxtasksperchild=10) as generator_pool: with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) diff --git a/WebHostLib/check.py b/WebHostLib/check.py index b7f215da024a..0c1e090dbe47 100644 --- a/WebHostLib/check.py +++ b/WebHostLib/check.py @@ -1,7 +1,8 @@ import zipfile from typing import * -from flask import request, flash, redirect, url_for, render_template, Markup +from flask import request, flash, redirect, url_for, render_template +from markupsafe import Markup from WebHostLib import app @@ -52,11 +53,12 @@ def get_yaml_data(file) -> Union[Dict[str, str], str, Markup]: if any(file.filename.endswith(".archipelago") for file in infolist): return Markup("Error: Your .zip file contains an .archipelago file. " - 'Did you mean to host a game?') + 'Did you mean to host a game?') for file in infolist: if file.filename.endswith(banned_zip_contents): - return "Uploaded data contained a rom file, which is likely to contain copyrighted material. Your file was deleted." + return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ + "Your file was deleted." elif file.filename.endswith((".yaml", ".json", ".yml", ".txt")): options[file.filename] = zfile.open(file, "r").read() else: @@ -90,7 +92,7 @@ def roll_options(options: Dict[str, Union[dict, str]], rolled_results[f"{filename}/{i + 1}"] = roll_settings(yaml_data, plando_options=plando_options) except Exception as e: - results[filename] = f"Failed to generate mystery in {filename}: {e}" + results[filename] = f"Failed to generate options in {filename}: {e}" else: results[filename] = True return results, rolled_results diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 584ca9fecab1..8fbf692dec20 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -18,8 +18,8 @@ import Utils from MultiServer import Context, server, auto_shutdown, ServerCommandProcessor, ClientMessageProcessor, load_server_cert -from Utils import get_public_ipv4, get_public_ipv6, restricted_loads, cache_argsless -from .models import Room, Command, db +from Utils import restricted_loads, cache_argsless +from .models import Command, GameDataPackage, Room, db class CustomClientMessageProcessor(ClientMessageProcessor): @@ -92,7 +92,21 @@ def load(self, room_id: int): else: self.port = get_random_port() - return self._load(self.decompress(room.seed.multidata), True) + multidata = self.decompress(room.seed.multidata) + game_data_packages = {} + for game in list(multidata.get("datapackage", {})): + game_data = multidata["datapackage"][game] + if "checksum" in game_data: + if self.gamespackage.get(game, {}).get("checksum") == game_data["checksum"]: + # non-custom. remove from multidata + # games package could be dropped from static data once all rooms embed data package + del multidata["datapackage"][game] + else: + row = GameDataPackage.get(checksum=game_data["checksum"]) + if row: # None if rolled on >= 0.3.9 but uploaded to <= 0.3.8. multidata should be complete + game_data_packages[game] = Utils.restricted_loads(row.data) + + return self._load(multidata, game_data_packages, True) @db_session def init_save(self, enabled: bool = True): @@ -155,13 +169,11 @@ async def main(): ctx.init_save() ssl_context = load_server_cert(cert_file, cert_key_file) if cert_file else None try: - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None, - ping_interval=None, ssl=ssl_context) + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ssl=ssl_context) await ctx.server except Exception: # likely port in use - in windows this is OSError, but I didn't check the others - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ping_timeout=None, - ping_interval=None, ssl=ssl_context) + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, 0, ssl=ssl_context) await ctx.server port = 0 @@ -190,6 +202,11 @@ async def main(): with Locker(room_id): try: asyncio.run(main()) + except KeyboardInterrupt: + with db_session: + room = Room.get(id=room_id) + # ensure the Room does not spin up again on its own, minute of safety buffer + room.last_activity = datetime.datetime.utcnow() - datetime.timedelta(minutes=1, seconds=room.timeout) except: with db_session: room = Room.get(id=room_id) diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index 02ea7320efeb..5cf503be1b2b 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -88,6 +88,8 @@ def download_slot_file(room_id, player_id: int): fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_SP.apsm64ex" elif slot_data.game == "Dark Souls III": fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}.json" + elif slot_data.game == "Kingdom Hearts 2": + fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.zip" else: return "Game download not supported." return send_file(io.BytesIO(slot_data.data), as_attachment=True, download_name=fname) diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index cbacd5153f9a..91d7594a1f23 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -6,7 +6,7 @@ import zipfile import concurrent.futures from collections import Counter -from typing import Dict, Optional, Any +from typing import Dict, Optional, Any, Union, List from flask import request, flash, redirect, url_for, session, render_template from pony.orm import commit, db_session @@ -22,7 +22,7 @@ from .upload import upload_zip_to_db -def get_meta(options_source: dict) -> dict: +def get_meta(options_source: dict, race: bool = False) -> Dict[str, Union[List[str], Dict[str, Any]]]: plando_options = { options_source.get("plando_bosses", ""), options_source.get("plando_items", ""), @@ -39,7 +39,21 @@ def get_meta(options_source: dict) -> dict: "item_cheat": bool(int(options_source.get("item_cheat", 1))), "server_password": options_source.get("server_password", None), } - return {"server_options": server_options, "plando_options": list(plando_options)} + generator_options = { + "spoiler": int(options_source.get("spoiler", 0)), + "race": race + } + + if race: + server_options["item_cheat"] = False + server_options["remaining_mode"] = "disabled" + generator_options["spoiler"] = 0 + + return { + "server_options": server_options, + "plando_options": list(plando_options), + "generator_options": generator_options, + } @app.route('/generate', methods=['GET', 'POST']) @@ -55,13 +69,8 @@ def generate(race=False): if isinstance(options, str): flash(options) else: - meta = get_meta(request.form) - meta["race"] = race - results, gen_options = roll_options(options, meta["plando_options"]) - - if race: - meta["server_options"]["item_cheat"] = False - meta["server_options"]["remaining_mode"] = "disabled" + meta = get_meta(request.form, race) + results, gen_options = roll_options(options, set(meta["plando_options"])) if any(type(result) == str for result in results.values()): return render_template("checkResult.html", results=results) @@ -97,7 +106,7 @@ def gen_game(gen_options: dict, meta: Optional[Dict[str, Any]] = None, owner=Non meta: Dict[str, Any] = {} meta.setdefault("server_options", {}).setdefault("hint_cost", 10) - race = meta.setdefault("race", False) + race = meta.setdefault("generator_options", {}).setdefault("race", False) def task(): target = tempfile.TemporaryDirectory() @@ -114,13 +123,14 @@ def task(): erargs = parse_arguments(['--multi', str(playercount)]) erargs.seed = seed erargs.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery - erargs.spoiler = 0 if race else 3 + erargs.spoiler = meta["generator_options"].get("spoiler", 0) erargs.race = race erargs.outputname = seedname erargs.outputpath = target.name erargs.teams = 1 erargs.plando_options = PlandoOptions.from_set(meta.setdefault("plando_options", - {"bosses", "items", "connections", "texts"})) + {"bosses", "items", "connections", "texts"})) + erargs.skip_prog_balancing = False name_counter = Counter() for player, (playerfile, settings) in enumerate(gen_options.items(), 1): diff --git a/WebHostLib/misc.py b/WebHostLib/misc.py index bf9f4e2fd7db..6d3e82c00c6d 100644 --- a/WebHostLib/misc.py +++ b/WebHostLib/misc.py @@ -116,7 +116,11 @@ def display_log(room: UUID): if room is None: return abort(404) if room.owner == session["_id"]: - return Response(_read_log(os.path.join("logs", str(room.id) + ".txt")), mimetype="text/plain;charset=UTF-8") + file_path = os.path.join("logs", str(room.id) + ".txt") + if os.path.exists(file_path): + return Response(_read_log(file_path), mimetype="text/plain;charset=UTF-8") + return "Log File does not exist." + return "Access Denied", 403 diff --git a/WebHostLib/models.py b/WebHostLib/models.py index dbd03b166c9a..7fa54f26a004 100644 --- a/WebHostLib/models.py +++ b/WebHostLib/models.py @@ -21,7 +21,7 @@ class Slot(db.Entity): class Room(db.Entity): id = PrimaryKey(UUID, default=uuid4) last_activity = Required(datetime, default=lambda: datetime.utcnow(), index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow()) + creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page owner = Required(UUID, index=True) commands = Set('Command') seed = Required('Seed', index=True) @@ -38,7 +38,7 @@ class Seed(db.Entity): rooms = Set(Room) multidata = Required(bytes, lazy=True) owner = Required(UUID, index=True) - creation_time = Required(datetime, default=lambda: datetime.utcnow()) + creation_time = Required(datetime, default=lambda: datetime.utcnow(), index=True) # index used by landing page slots = Set(Slot) spoiler = Optional(LongStr, lazy=True) meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags @@ -56,3 +56,8 @@ class Generation(db.Entity): options = Required(buffer, lazy=True) meta = Required(LongStr, default=lambda: "{\"race\": false}") state = Required(int, default=0, index=True) + + +class GameDataPackage(db.Entity): + checksum = PrimaryKey(str) + data = Required(bytes) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 8f366d4fbf31..fca01407e06b 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -11,35 +11,14 @@ from worlds.AutoWorld import AutoWorldRegister handled_in_js = {"start_inventory", "local_items", "non_local_items", "start_hints", "start_location_hints", - "exclude_locations"} + "exclude_locations", "priority_locations"} def create(): target_folder = local_path("WebHostLib", "static", "generated") yaml_folder = os.path.join(target_folder, "configs") - os.makedirs(yaml_folder, exist_ok=True) - - for file in os.listdir(yaml_folder): - full_path: str = os.path.join(yaml_folder, file) - if os.path.isfile(full_path): - os.unlink(full_path) - - def dictify_range(option: typing.Union[Options.Range, Options.SpecialRange]): - data = {option.default: 50} - for sub_option in ["random", "random-low", "random-high"]: - if sub_option != option.default: - data[sub_option] = 0 - - notes = {} - for name, number in getattr(option, "special_range_names", {}).items(): - notes[name] = f"equivalent to {number}" - if number in data: - data[name] = data[number] - del data[number] - else: - data[name] = 0 - return data, notes + Options.generate_yaml_templates(yaml_folder) def get_html_doc(option_type: type(Options.Option)) -> str: if not option_type.__doc__: @@ -61,23 +40,11 @@ def get_html_doc(option_type: type(Options.Option)) -> str: **Options.per_game_common_options, **world.option_definitions } - 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, - ) - - del file_data - - with open(os.path.join(target_folder, "configs", game_name + ".yaml"), "w", encoding="utf-8") as f: - f.write(res) # Generate JSON files for player-settings pages player_settings = { "baseOptions": { - "description": "Generated by https://archipelago.gg/", + "description": f"Generated by https://archipelago.gg/ for {game_name}", "game": game_name, "name": "Player", }, @@ -88,7 +55,7 @@ def get_html_doc(option_type: type(Options.Option)) -> str: if option_name in handled_in_js: pass - elif option.options: + elif issubclass(option, Options.Choice) or issubclass(option, Options.Toggle): game_options[option_name] = this_option = { "type": "select", "displayName": option.display_name if hasattr(option, "display_name") else option_name, @@ -98,15 +65,15 @@ def get_html_doc(option_type: type(Options.Option)) -> str: } for sub_option_id, sub_option_name in option.name_lookup.items(): - this_option["options"].append({ - "name": option.get_option_name(sub_option_id), - "value": sub_option_name, - }) - + if sub_option_name != "random": + this_option["options"].append({ + "name": option.get_option_name(sub_option_id), + "value": sub_option_name, + }) if sub_option_id == option.default: this_option["defaultValue"] = sub_option_name - if option.default == "random": + if not this_option["defaultValue"]: this_option["defaultValue"] = "random" elif issubclass(option, Options.Range): @@ -126,27 +93,30 @@ def get_html_doc(option_type: type(Options.Option)) -> str: for key, val in option.special_range_names.items(): game_options[option_name]["value_names"][key] = val - elif getattr(option, "verify_item_name", False): + elif issubclass(option, Options.ItemSet): game_options[option_name] = { "type": "items-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, "description": get_html_doc(option), + "defaultValue": list(option.default) } - elif getattr(option, "verify_location_name", False): + elif issubclass(option, Options.LocationSet): game_options[option_name] = { "type": "locations-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, "description": get_html_doc(option), + "defaultValue": list(option.default) } - elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet): + elif issubclass(option, Options.VerifyKeys) and not issubclass(option, Options.OptionDict): if option.valid_keys: game_options[option_name] = { "type": "custom-list", "displayName": option.display_name if hasattr(option, "display_name") else option_name, "description": get_html_doc(option), "options": list(option.valid_keys), + "defaultValue": list(option.default) if hasattr(option, "default") else [] } else: @@ -160,6 +130,14 @@ def get_html_doc(option_type: type(Options.Option)) -> str: json.dump(player_settings, f, indent=2, separators=(',', ': ')) if not world.hidden and world.web.settings_page is True: + # Add the random option to Choice, TextChoice, and Toggle settings + for option in game_options.values(): + if option["type"] == "select": + option["options"].append({"name": "Random", "value": "random"}) + + if not option["defaultValue"]: + option["defaultValue"] = "random" + weighted_settings["baseOptions"]["game"][game_name] = 0 weighted_settings["games"][game_name] = {} weighted_settings["games"][game_name]["gameSettings"] = game_options diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index d5c1719863e5..a8b2865aae34 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,7 +1,9 @@ flask>=2.2.3 -pony>=0.7.16 +pony>=0.7.16; python_version <= '3.10' +pony @ https://github.com/Berserker66/pony/releases/download/v0.7.16/pony-0.7.16-py3-none-any.whl#0.7.16 ; python_version >= '3.11' waitress>=2.1.2 Flask-Caching>=2.0.2 Flask-Compress>=1.13 Flask-Limiter>=3.3.0 -bokeh>=3.1.0 +bokeh>=3.1.1 +markupsafe>=2.1.3 diff --git a/WebHostLib/static/assets/baseHeader.js b/WebHostLib/static/assets/baseHeader.js index b8ee82dd63e3..7c9be7784058 100644 --- a/WebHostLib/static/assets/baseHeader.js +++ b/WebHostLib/static/assets/baseHeader.js @@ -1,9 +1,11 @@ window.addEventListener('load', () => { + // Mobile menu handling const menuButton = document.getElementById('base-header-mobile-menu-button'); const mobileMenu = document.getElementById('base-header-mobile-menu'); menuButton.addEventListener('click', (evt) => { evt.preventDefault(); + evt.stopPropagation(); if (!mobileMenu.style.display || mobileMenu.style.display === 'none') { return mobileMenu.style.display = 'flex'; @@ -15,4 +17,24 @@ window.addEventListener('load', () => { window.addEventListener('resize', () => { mobileMenu.style.display = 'none'; }); + + // Popover handling + const popoverText = document.getElementById('base-header-popover-text'); + const popoverMenu = document.getElementById('base-header-popover-menu'); + + popoverText.addEventListener('click', (evt) => { + evt.preventDefault(); + evt.stopPropagation(); + + if (!popoverMenu.style.display || popoverMenu.style.display === 'none') { + return popoverMenu.style.display = 'flex'; + } + + popoverMenu.style.display = 'none'; + }); + + document.body.addEventListener('click', () => { + mobileMenu.style.display = 'none'; + popoverMenu.style.display = 'none'; + }); }); diff --git a/WebHostLib/static/assets/player-settings.js b/WebHostLib/static/assets/player-settings.js index 5d4aaffa9d42..f75ba9060303 100644 --- a/WebHostLib/static/assets/player-settings.js +++ b/WebHostLib/static/assets/player-settings.js @@ -148,7 +148,7 @@ const buildOptionsTable = (settings, romOpts = false) => { randomButton.classList.add('randomize-button'); randomButton.setAttribute('data-key', setting); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, [select])); + randomButton.addEventListener('click', (event) => toggleRandomize(event, select)); if (currentSettings[gameName][setting] === 'random') { randomButton.classList.add('active'); select.disabled = true; @@ -185,7 +185,7 @@ const buildOptionsTable = (settings, romOpts = false) => { randomButton.classList.add('randomize-button'); randomButton.setAttribute('data-key', setting); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); - randomButton.addEventListener('click', (event) => toggleRandomize(event, [range])); + randomButton.addEventListener('click', (event) => toggleRandomize(event, range)); if (currentSettings[gameName][setting] === 'random') { randomButton.classList.add('active'); range.disabled = true; @@ -269,7 +269,7 @@ const buildOptionsTable = (settings, romOpts = false) => { randomButton.setAttribute('data-key', setting); randomButton.setAttribute('data-tooltip', 'Toggle randomization for this option!'); randomButton.addEventListener('click', (event) => toggleRandomize( - event, [specialRange, specialRangeSelect]) + event, specialRange, specialRangeSelect) ); if (currentSettings[gameName][setting] === 'random') { randomButton.classList.add('active'); @@ -294,23 +294,25 @@ const buildOptionsTable = (settings, romOpts = false) => { return table; }; -const toggleRandomize = (event, inputElements) => { +const toggleRandomize = (event, inputElement, optionalSelectElement = null) => { const active = event.target.classList.contains('active'); const randomButton = event.target; if (active) { randomButton.classList.remove('active'); - for (const element of inputElements) { - element.disabled = undefined; - updateGameSetting(element); + inputElement.disabled = undefined; + if (optionalSelectElement) { + optionalSelectElement.disabled = undefined; } } else { randomButton.classList.add('active'); - for (const element of inputElements) { - element.disabled = true; - updateGameSetting(randomButton); + inputElement.disabled = true; + if (optionalSelectElement) { + optionalSelectElement.disabled = true; } } + + updateGameSetting(randomButton); }; const updateBaseSetting = (event) => { @@ -364,6 +366,7 @@ const generateGame = (raceMode = false) => { weights: { player: settings }, presetData: { player: settings }, playerCount: 1, + spoiler: 3, race: raceMode ? '1' : '0', }).then((response) => { window.location.href = response.data.url; diff --git a/WebHostLib/static/assets/trackerCommon.js b/WebHostLib/static/assets/trackerCommon.js index c08590cbf7db..41c4020dace8 100644 --- a/WebHostLib/static/assets/trackerCommon.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -14,6 +14,17 @@ const adjustTableHeight = () => { } }; +/** + * Convert an integer number of seconds into a human readable HH:MM format + * @param {Number} seconds + * @returns {string} + */ +const secondsToHours = (seconds) => { + let hours = Math.floor(seconds / 3600); + let minutes = Math.floor((seconds - (hours * 3600)) / 60).toString().padStart(2, '0'); + return `${hours}:${minutes}`; +}; + window.addEventListener('load', () => { const tables = $(".table").DataTable({ paging: false, @@ -27,7 +38,18 @@ window.addEventListener('load', () => { stateLoadCallback: function(settings) { return JSON.parse(localStorage.getItem(`DataTables_${settings.sInstance}_/tracker`)); }, + footerCallback: function(tfoot, data, start, end, display) { + if (tfoot) { + const activityData = this.api().column('lastActivity:name').data().toArray().filter(x => !isNaN(x)); + Array.from(tfoot?.children).find(td => td.classList.contains('last-activity')).innerText = + (activityData.length) ? secondsToHours(Math.min(...activityData)) : 'None'; + } + }, columnDefs: [ + { + targets: 'last-activity', + name: 'lastActivity' + }, { targets: 'hours', render: function (data, type, row) { @@ -40,11 +62,7 @@ window.addEventListener('load', () => { if (data === "None") return data; - let hours = Math.floor(data / 3600); - let minutes = Math.floor((data - (hours * 3600)) / 60); - - if (minutes < 10) {minutes = "0"+minutes;} - return hours+':'+minutes; + return secondsToHours(data); } }, { @@ -114,11 +132,16 @@ window.addEventListener('load', () => { if (status === "success") { target.find(".table").each(function (i, new_table) { const new_trs = $(new_table).find("tbody>tr"); + const footer_tr = $(new_table).find("tfoot>tr"); const old_table = tables.eq(i); const topscroll = $(old_table.settings()[0].nScrollBody).scrollTop(); const leftscroll = $(old_table.settings()[0].nScrollBody).scrollLeft(); old_table.clear(); - old_table.rows.add(new_trs).draw(); + if (footer_tr.length) { + $(old_table.table).find("tfoot").html(footer_tr); + } + old_table.rows.add(new_trs); + old_table.draw(); $(old_table.settings()[0].nScrollBody).scrollTop(topscroll); $(old_table.settings()[0].nScrollBody).scrollLeft(leftscroll); }); diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index da4d60fcad8a..6e86d470f05c 100644 --- a/WebHostLib/static/assets/weighted-settings.js +++ b/WebHostLib/static/assets/weighted-settings.js @@ -78,8 +78,6 @@ const createDefaultSettings = (settingData) => { break; case 'range': case 'special_range': - newSettings[game][gameSetting][setting.min] = 0; - newSettings[game][gameSetting][setting.max] = 0; newSettings[game][gameSetting]['random'] = 0; newSettings[game][gameSetting]['random-low'] = 0; newSettings[game][gameSetting]['random-high'] = 0; @@ -93,7 +91,7 @@ const createDefaultSettings = (settingData) => { case 'items-list': case 'locations-list': case 'custom-list': - newSettings[game][gameSetting] = []; + newSettings[game][gameSetting] = setting.defaultValue; break; default: @@ -103,6 +101,7 @@ const createDefaultSettings = (settingData) => { newSettings[game].start_inventory = {}; newSettings[game].exclude_locations = []; + newSettings[game].priority_locations = []; newSettings[game].local_items = []; newSettings[game].non_local_items = []; newSettings[game].start_hints = []; @@ -138,21 +137,28 @@ const buildUI = (settingData) => { expandButton.classList.add('invisible'); gameDiv.appendChild(expandButton); - const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings); + settingData.games[game].gameItems.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0))); + settingData.games[game].gameLocations.sort((a, b) => (a > b ? 1 : (a < b ? -1 : 0))); + + const weightedSettingsDiv = buildWeightedSettingsDiv(game, settingData.games[game].gameSettings, + settingData.games[game].gameItems, settingData.games[game].gameLocations); gameDiv.appendChild(weightedSettingsDiv); - const itemsDiv = buildItemsDiv(game, settingData.games[game].gameItems); - gameDiv.appendChild(itemsDiv); + const itemPoolDiv = buildItemsDiv(game, settingData.games[game].gameItems); + gameDiv.appendChild(itemPoolDiv); const hintsDiv = buildHintsDiv(game, settingData.games[game].gameItems, settingData.games[game].gameLocations); gameDiv.appendChild(hintsDiv); + const locationsDiv = buildLocationsDiv(game, settingData.games[game].gameLocations); + gameDiv.appendChild(locationsDiv); + gamesWrapper.appendChild(gameDiv); collapseButton.addEventListener('click', () => { collapseButton.classList.add('invisible'); weightedSettingsDiv.classList.add('invisible'); - itemsDiv.classList.add('invisible'); + itemPoolDiv.classList.add('invisible'); hintsDiv.classList.add('invisible'); expandButton.classList.remove('invisible'); }); @@ -160,7 +166,7 @@ const buildUI = (settingData) => { expandButton.addEventListener('click', () => { collapseButton.classList.remove('invisible'); weightedSettingsDiv.classList.remove('invisible'); - itemsDiv.classList.remove('invisible'); + itemPoolDiv.classList.remove('invisible'); hintsDiv.classList.remove('invisible'); expandButton.classList.add('invisible'); }); @@ -228,7 +234,7 @@ const buildGameChoice = (games) => { gameChoiceDiv.appendChild(table); }; -const buildWeightedSettingsDiv = (game, settings) => { +const buildWeightedSettingsDiv = (game, settings, gameItems, gameLocations) => { const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); const settingsWrapper = document.createElement('div'); settingsWrapper.classList.add('settings-wrapper'); @@ -270,7 +276,7 @@ const buildWeightedSettingsDiv = (game, settings) => { range.setAttribute('data-type', setting.type); range.setAttribute('min', 0); range.setAttribute('max', 50); - range.addEventListener('change', updateGameSetting); + range.addEventListener('change', updateRangeSetting); range.value = currentSettings[game][settingName][option.value]; tdMiddle.appendChild(range); tr.appendChild(tdMiddle); @@ -296,33 +302,33 @@ const buildWeightedSettingsDiv = (game, settings) => { if (((setting.max - setting.min) + 1) < 11) { for (let i=setting.min; i <= setting.max; ++i) { const tr = document.createElement('tr'); - const tdLeft = document.createElement('td'); - tdLeft.classList.add('td-left'); - tdLeft.innerText = i; - tr.appendChild(tdLeft); + const tdLeft = document.createElement('td'); + tdLeft.classList.add('td-left'); + tdLeft.innerText = i; + tr.appendChild(tdLeft); - const tdMiddle = document.createElement('td'); - tdMiddle.classList.add('td-middle'); - const range = document.createElement('input'); - range.setAttribute('type', 'range'); - range.setAttribute('id', `${game}-${settingName}-${i}-range`); - range.setAttribute('data-game', game); - range.setAttribute('data-setting', settingName); - range.setAttribute('data-option', i); - range.setAttribute('min', 0); - range.setAttribute('max', 50); - range.addEventListener('change', updateGameSetting); - range.value = currentSettings[game][settingName][i]; - tdMiddle.appendChild(range); - tr.appendChild(tdMiddle); + const tdMiddle = document.createElement('td'); + tdMiddle.classList.add('td-middle'); + const range = document.createElement('input'); + range.setAttribute('type', 'range'); + range.setAttribute('id', `${game}-${settingName}-${i}-range`); + range.setAttribute('data-game', game); + range.setAttribute('data-setting', settingName); + range.setAttribute('data-option', i); + range.setAttribute('min', 0); + range.setAttribute('max', 50); + range.addEventListener('change', updateRangeSetting); + range.value = currentSettings[game][settingName][i] || 0; + tdMiddle.appendChild(range); + tr.appendChild(tdMiddle); - const tdRight = document.createElement('td'); - tdRight.setAttribute('id', `${game}-${settingName}-${i}`) - tdRight.classList.add('td-right'); - tdRight.innerText = range.value; - tr.appendChild(tdRight); + const tdRight = document.createElement('td'); + tdRight.setAttribute('id', `${game}-${settingName}-${i}`) + tdRight.classList.add('td-right'); + tdRight.innerText = range.value; + tr.appendChild(tdRight); - rangeTbody.appendChild(tr); + rangeTbody.appendChild(tr); } } else { const hintText = document.createElement('p'); @@ -379,7 +385,7 @@ const buildWeightedSettingsDiv = (game, settings) => { range.setAttribute('data-option', option); range.setAttribute('min', 0); range.setAttribute('max', 50); - range.addEventListener('change', updateGameSetting); + range.addEventListener('change', updateRangeSetting); range.value = currentSettings[game][settingName][parseInt(option, 10)]; tdMiddle.appendChild(range); tr.appendChild(tdMiddle); @@ -430,7 +436,7 @@ const buildWeightedSettingsDiv = (game, settings) => { range.setAttribute('data-option', option); range.setAttribute('min', 0); range.setAttribute('max', 50); - range.addEventListener('change', updateGameSetting); + range.addEventListener('change', updateRangeSetting); range.value = currentSettings[game][settingName][parseInt(option, 10)]; tdMiddle.appendChild(range); tr.appendChild(tdMiddle); @@ -464,7 +470,17 @@ const buildWeightedSettingsDiv = (game, settings) => { const tr = document.createElement('tr'); const tdLeft = document.createElement('td'); tdLeft.classList.add('td-left'); - tdLeft.innerText = option; + switch(option){ + case 'random': + tdLeft.innerText = 'Random'; + break; + case 'random-low': + tdLeft.innerText = "Random (Low)"; + break; + case 'random-high': + tdLeft.innerText = "Random (High)"; + break; + } tr.appendChild(tdLeft); const tdMiddle = document.createElement('td'); @@ -477,7 +493,7 @@ const buildWeightedSettingsDiv = (game, settings) => { range.setAttribute('data-option', option); range.setAttribute('min', 0); range.setAttribute('max', 50); - range.addEventListener('change', updateGameSetting); + range.addEventListener('change', updateRangeSetting); range.value = currentSettings[game][settingName][option]; tdMiddle.appendChild(range); tr.appendChild(tdMiddle); @@ -495,15 +511,108 @@ const buildWeightedSettingsDiv = (game, settings) => { break; case 'items-list': - // TODO + const itemsList = document.createElement('div'); + itemsList.classList.add('simple-list'); + + Object.values(gameItems).forEach((item) => { + const itemRow = document.createElement('div'); + itemRow.classList.add('list-row'); + + const itemLabel = document.createElement('label'); + itemLabel.setAttribute('for', `${game}-${settingName}-${item}`) + + const itemCheckbox = document.createElement('input'); + itemCheckbox.setAttribute('id', `${game}-${settingName}-${item}`); + itemCheckbox.setAttribute('type', 'checkbox'); + itemCheckbox.setAttribute('data-game', game); + itemCheckbox.setAttribute('data-setting', settingName); + itemCheckbox.setAttribute('data-option', item.toString()); + itemCheckbox.addEventListener('change', updateListSetting); + if (currentSettings[game][settingName].includes(item)) { + itemCheckbox.setAttribute('checked', '1'); + } + + const itemName = document.createElement('span'); + itemName.innerText = item.toString(); + + itemLabel.appendChild(itemCheckbox); + itemLabel.appendChild(itemName); + + itemRow.appendChild(itemLabel); + itemsList.appendChild((itemRow)); + }); + + settingWrapper.appendChild(itemsList); break; case 'locations-list': - // TODO + const locationsList = document.createElement('div'); + locationsList.classList.add('simple-list'); + + Object.values(gameLocations).forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${game}-${settingName}-${location}`) + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('id', `${game}-${settingName}-${location}`); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('data-game', game); + locationCheckbox.setAttribute('data-setting', settingName); + locationCheckbox.setAttribute('data-option', location.toString()); + locationCheckbox.addEventListener('change', updateListSetting); + if (currentSettings[game][settingName].includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + + const locationName = document.createElement('span'); + locationName.innerText = location.toString(); + + locationLabel.appendChild(locationCheckbox); + locationLabel.appendChild(locationName); + + locationRow.appendChild(locationLabel); + locationsList.appendChild((locationRow)); + }); + + settingWrapper.appendChild(locationsList); break; case 'custom-list': - // TODO + const customList = document.createElement('div'); + customList.classList.add('simple-list'); + + Object.values(settings[settingName].options).forEach((listItem) => { + const customListRow = document.createElement('div'); + customListRow.classList.add('list-row'); + + const customItemLabel = document.createElement('label'); + customItemLabel.setAttribute('for', `${game}-${settingName}-${listItem}`) + + const customItemCheckbox = document.createElement('input'); + customItemCheckbox.setAttribute('id', `${game}-${settingName}-${listItem}`); + customItemCheckbox.setAttribute('type', 'checkbox'); + customItemCheckbox.setAttribute('data-game', game); + customItemCheckbox.setAttribute('data-setting', settingName); + customItemCheckbox.setAttribute('data-option', listItem.toString()); + customItemCheckbox.addEventListener('change', updateListSetting); + if (currentSettings[game][settingName].includes(listItem)) { + customItemCheckbox.setAttribute('checked', '1'); + } + + const customItemName = document.createElement('span'); + customItemName.innerText = listItem.toString(); + + customItemLabel.appendChild(customItemCheckbox); + customItemLabel.appendChild(customItemName); + + customListRow.appendChild(customItemLabel); + customList.appendChild((customListRow)); + }); + + settingWrapper.appendChild(customList); break; default: @@ -729,21 +838,22 @@ const buildHintsDiv = (game, items, locations) => { const hintsDescription = document.createElement('p'); hintsDescription.classList.add('setting-description'); hintsDescription.innerText = 'Choose any items or locations to begin the game with the knowledge of where those ' + - ' items are, or what those locations contain. Excluded locations will not contain progression items.'; + ' items are, or what those locations contain.'; hintsDiv.appendChild(hintsDescription); const itemHintsContainer = document.createElement('div'); itemHintsContainer.classList.add('hints-container'); + // Item Hints const itemHintsWrapper = document.createElement('div'); itemHintsWrapper.classList.add('hints-wrapper'); itemHintsWrapper.innerText = 'Starting Item Hints'; const itemHintsDiv = document.createElement('div'); - itemHintsDiv.classList.add('item-container'); + itemHintsDiv.classList.add('simple-list'); items.forEach((item) => { - const itemDiv = document.createElement('div'); - itemDiv.classList.add('hint-div'); + const itemRow = document.createElement('div'); + itemRow.classList.add('list-row'); const itemLabel = document.createElement('label'); itemLabel.setAttribute('for', `${game}-start_hints-${item}`); @@ -757,29 +867,30 @@ const buildHintsDiv = (game, items, locations) => { if (currentSettings[game].start_hints.includes(item)) { itemCheckbox.setAttribute('checked', 'true'); } - itemCheckbox.addEventListener('change', hintChangeHandler); + itemCheckbox.addEventListener('change', updateListSetting); itemLabel.appendChild(itemCheckbox); const itemName = document.createElement('span'); itemName.innerText = item; itemLabel.appendChild(itemName); - itemDiv.appendChild(itemLabel); - itemHintsDiv.appendChild(itemDiv); + itemRow.appendChild(itemLabel); + itemHintsDiv.appendChild(itemRow); }); itemHintsWrapper.appendChild(itemHintsDiv); itemHintsContainer.appendChild(itemHintsWrapper); + // Starting Location Hints const locationHintsWrapper = document.createElement('div'); locationHintsWrapper.classList.add('hints-wrapper'); locationHintsWrapper.innerText = 'Starting Location Hints'; const locationHintsDiv = document.createElement('div'); - locationHintsDiv.classList.add('item-container'); + locationHintsDiv.classList.add('simple-list'); locations.forEach((location) => { - const locationDiv = document.createElement('div'); - locationDiv.classList.add('hint-div'); + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); const locationLabel = document.createElement('label'); locationLabel.setAttribute('for', `${game}-start_location_hints-${location}`); @@ -793,29 +904,89 @@ const buildHintsDiv = (game, items, locations) => { if (currentSettings[game].start_location_hints.includes(location)) { locationCheckbox.setAttribute('checked', '1'); } - locationCheckbox.addEventListener('change', hintChangeHandler); + locationCheckbox.addEventListener('change', updateListSetting); locationLabel.appendChild(locationCheckbox); const locationName = document.createElement('span'); locationName.innerText = location; locationLabel.appendChild(locationName); - locationDiv.appendChild(locationLabel); - locationHintsDiv.appendChild(locationDiv); + locationRow.appendChild(locationLabel); + locationHintsDiv.appendChild(locationRow); }); locationHintsWrapper.appendChild(locationHintsDiv); itemHintsContainer.appendChild(locationHintsWrapper); + hintsDiv.appendChild(itemHintsContainer); + return hintsDiv; +}; + +const buildLocationsDiv = (game, locations) => { + const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); + locations.sort(); // Sort alphabetical, in-place + + const locationsDiv = document.createElement('div'); + locationsDiv.classList.add('locations-div'); + const locationsHeader = document.createElement('h3'); + locationsHeader.innerText = 'Priority & Exclusion Locations'; + locationsDiv.appendChild(locationsHeader); + const locationsDescription = document.createElement('p'); + locationsDescription.classList.add('setting-description'); + locationsDescription.innerText = 'Priority locations guarantee a progression item will be placed there while ' + + 'excluded locations will not contain progression or useful items.'; + locationsDiv.appendChild(locationsDescription); + + const locationsContainer = document.createElement('div'); + locationsContainer.classList.add('locations-container'); + + // Priority Locations + const priorityLocationsWrapper = document.createElement('div'); + priorityLocationsWrapper.classList.add('locations-wrapper'); + priorityLocationsWrapper.innerText = 'Priority Locations'; + + const priorityLocationsDiv = document.createElement('div'); + priorityLocationsDiv.classList.add('simple-list'); + locations.forEach((location) => { + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); + + const locationLabel = document.createElement('label'); + locationLabel.setAttribute('for', `${game}-priority_locations-${location}`); + + const locationCheckbox = document.createElement('input'); + locationCheckbox.setAttribute('type', 'checkbox'); + locationCheckbox.setAttribute('id', `${game}-priority_locations-${location}`); + locationCheckbox.setAttribute('data-game', game); + locationCheckbox.setAttribute('data-setting', 'priority_locations'); + locationCheckbox.setAttribute('data-option', location); + if (currentSettings[game].priority_locations.includes(location)) { + locationCheckbox.setAttribute('checked', '1'); + } + locationCheckbox.addEventListener('change', updateListSetting); + locationLabel.appendChild(locationCheckbox); + + const locationName = document.createElement('span'); + locationName.innerText = location; + locationLabel.appendChild(locationName); + + locationRow.appendChild(locationLabel); + priorityLocationsDiv.appendChild(locationRow); + }); + + priorityLocationsWrapper.appendChild(priorityLocationsDiv); + locationsContainer.appendChild(priorityLocationsWrapper); + + // Exclude Locations const excludeLocationsWrapper = document.createElement('div'); - excludeLocationsWrapper.classList.add('hints-wrapper'); + excludeLocationsWrapper.classList.add('locations-wrapper'); excludeLocationsWrapper.innerText = 'Exclude Locations'; const excludeLocationsDiv = document.createElement('div'); - excludeLocationsDiv.classList.add('item-container'); + excludeLocationsDiv.classList.add('simple-list'); locations.forEach((location) => { - const locationDiv = document.createElement('div'); - locationDiv.classList.add('hint-div'); + const locationRow = document.createElement('div'); + locationRow.classList.add('list-row'); const locationLabel = document.createElement('label'); locationLabel.setAttribute('for', `${game}-exclude_locations-${location}`); @@ -829,40 +1000,22 @@ const buildHintsDiv = (game, items, locations) => { if (currentSettings[game].exclude_locations.includes(location)) { locationCheckbox.setAttribute('checked', '1'); } - locationCheckbox.addEventListener('change', hintChangeHandler); + locationCheckbox.addEventListener('change', updateListSetting); locationLabel.appendChild(locationCheckbox); const locationName = document.createElement('span'); locationName.innerText = location; locationLabel.appendChild(locationName); - locationDiv.appendChild(locationLabel); - excludeLocationsDiv.appendChild(locationDiv); + locationRow.appendChild(locationLabel); + excludeLocationsDiv.appendChild(locationRow); }); excludeLocationsWrapper.appendChild(excludeLocationsDiv); - itemHintsContainer.appendChild(excludeLocationsWrapper); + locationsContainer.appendChild(excludeLocationsWrapper); - hintsDiv.appendChild(itemHintsContainer); - return hintsDiv; -}; - -const hintChangeHandler = (evt) => { - const currentSettings = JSON.parse(localStorage.getItem('weighted-settings')); - const game = evt.target.getAttribute('data-game'); - const setting = evt.target.getAttribute('data-setting'); - const option = evt.target.getAttribute('data-option'); - - if (evt.target.checked) { - if (!currentSettings[game][setting].includes(option)) { - currentSettings[game][setting].push(option); - } - } else { - if (currentSettings[game][setting].includes(option)) { - currentSettings[game][setting].splice(currentSettings[game][setting].indexOf(option), 1); - } - } - localStorage.setItem('weighted-settings', JSON.stringify(currentSettings)); + locationsDiv.appendChild(locationsContainer); + return locationsDiv; }; const updateVisibleGames = () => { @@ -908,13 +1061,12 @@ const updateBaseSetting = (event) => { localStorage.setItem('weighted-settings', JSON.stringify(settings)); }; -const updateGameSetting = (evt) => { +const updateRangeSetting = (evt) => { const options = JSON.parse(localStorage.getItem('weighted-settings')); const game = evt.target.getAttribute('data-game'); const setting = evt.target.getAttribute('data-setting'); const option = evt.target.getAttribute('data-option'); document.getElementById(`${game}-${setting}-${option}`).innerText = evt.target.value; - console.log(event); if (evt.action && evt.action === 'rangeDelete') { delete options[game][setting][option]; } else { @@ -923,6 +1075,26 @@ const updateGameSetting = (evt) => { localStorage.setItem('weighted-settings', JSON.stringify(options)); }; +const updateListSetting = (evt) => { + const options = JSON.parse(localStorage.getItem('weighted-settings')); + const game = evt.target.getAttribute('data-game'); + const setting = evt.target.getAttribute('data-setting'); + const option = evt.target.getAttribute('data-option'); + + if (evt.target.checked) { + // If the option is to be enabled and it is already enabled, do nothing + if (options[game][setting].includes(option)) { return; } + + options[game][setting].push(option); + } else { + // If the option is to be disabled and it is already disabled, do nothing + if (!options[game][setting].includes(option)) { return; } + + options[game][setting].splice(options[game][setting].indexOf(option), 1); + } + localStorage.setItem('weighted-settings', JSON.stringify(options)); +}; + const updateItemSetting = (evt) => { const options = JSON.parse(localStorage.getItem('weighted-settings')); const game = evt.target.getAttribute('data-game'); @@ -1027,6 +1199,7 @@ const generateGame = (raceMode = false) => { weights: { player: JSON.stringify(settings) }, presetData: { player: JSON.stringify(settings) }, playerCount: 1, + spoiler: 3, race: raceMode ? '1' : '0', }).then((response) => { window.location.href = response.data.url; diff --git a/WebHostLib/static/static/button-images/popover.png b/WebHostLib/static/static/button-images/popover.png new file mode 100644 index 000000000000..cbc863410489 Binary files /dev/null and b/WebHostLib/static/static/button-images/popover.png differ diff --git a/WebHostLib/static/styles/islandFooter.css b/WebHostLib/static/styles/islandFooter.css index 96611f4eac89..7d5344a9bbf9 100644 --- a/WebHostLib/static/styles/islandFooter.css +++ b/WebHostLib/static/styles/islandFooter.css @@ -15,3 +15,33 @@ padding-left: 0.5rem; color: #dfedc6; } +@media all and (max-width: 900px) { + #island-footer{ + font-size: 17px; + font-size: 2vw; + } +} +@media all and (max-width: 768px) { + #island-footer{ + font-size: 15px; + font-size: 2vw; + } +} +@media all and (max-width: 650px) { + #island-footer{ + font-size: 13px; + font-size: 2vw; + } +} +@media all and (max-width: 580px) { + #island-footer{ + font-size: 11px; + font-size: 2vw; + } +} +@media all and (max-width: 512px) { + #island-footer{ + font-size: 9px; + font-size: 2vw; + } +} diff --git a/WebHostLib/static/styles/landing.css b/WebHostLib/static/styles/landing.css index ea142942e1cd..202c43badd5f 100644 --- a/WebHostLib/static/styles/landing.css +++ b/WebHostLib/static/styles/landing.css @@ -21,7 +21,6 @@ html{ margin-right: auto; margin-top: 10px; height: 140px; - z-index: 10; } #landing-header h4{ @@ -223,7 +222,7 @@ html{ } #landing{ - width: 700px; + max-width: 700px; min-height: 280px; margin-left: auto; margin-right: auto; diff --git a/WebHostLib/static/styles/player-settings.css b/WebHostLib/static/styles/player-settings.css index 9ba47d5fd02d..e6e0c292922a 100644 --- a/WebHostLib/static/styles/player-settings.css +++ b/WebHostLib/static/styles/player-settings.css @@ -5,7 +5,8 @@ html{ } #player-settings{ - max-width: 1000px; + box-sizing: border-box; + max-width: 1024px; margin-left: auto; margin-right: auto; background-color: rgba(0, 0, 0, 0.15); @@ -163,6 +164,11 @@ html{ background-color: #ffef00; /* Same as .interactive in globalStyles.css */ } +#player-settings table .randomize-button[data-tooltip]::after { + left: unset; + right: 0; +} + #player-settings table label{ display: block; min-width: 200px; @@ -177,18 +183,31 @@ html{ vertical-align: top; } -@media all and (max-width: 1000px), all and (orientation: portrait){ +@media all and (max-width: 1024px) { + #player-settings { + border-radius: 0; + } + #player-settings #game-options{ justify-content: flex-start; flex-wrap: wrap; } - #player-settings .left, #player-settings .right{ - flex-grow: unset; + #player-settings .left, + #player-settings .right { + margin: 0; + } + + #game-options table { + margin-bottom: 0; } #game-options table label{ display: block; min-width: 200px; } + + #game-options table tr td { + width: 50%; + } } diff --git a/WebHostLib/static/styles/themes/base.css b/WebHostLib/static/styles/themes/base.css index d38a8e610c2f..fdfe56af207c 100644 --- a/WebHostLib/static/styles/themes/base.css +++ b/WebHostLib/static/styles/themes/base.css @@ -30,6 +30,8 @@ html{ } #base-header-right{ + display: flex; + flex-direction: row; margin-top: 4px; } @@ -42,7 +44,7 @@ html{ margin-top: 4px; } -#base-header a, #base-header-mobile-menu a{ +#base-header a, #base-header-mobile-menu a, #base-header-popover-text{ color: #2f6b83; text-decoration: none; cursor: pointer; @@ -72,22 +74,92 @@ html{ position: absolute; top: 7rem; right: 0; - padding-top: 1rem; } #base-header-mobile-menu a{ - padding: 4rem 2rem; - font-size: 5rem; + padding: 3rem 1.5rem; + font-size: 4rem; line-height: 5rem; color: #699ca8; border-top: 1px solid #d3d3d3; } +#base-header-mobile-menu :first-child, #base-header-popover-menu :first-child{ + border-top: none; +} + #base-header-right-mobile img{ height: 3rem; } -@media all and (max-width: 1580px){ +#base-header-popover-menu{ + display: none; + flex-direction: column; + position: absolute; + background-color: #fff; + margin-left: -108px; + margin-top: 2.25rem; + border-radius: 10px; + border-left: 2px solid #d0ebe6; + border-bottom: 2px solid #d0ebe6; + border-right: 1px solid #d0ebe6; + filter: drop-shadow(-6px 6px 2px #2e3e83); +} + +#base-header-popover-menu a{ + color: #699ca8; + border-top: 1px solid #d3d3d3; + text-align: center; + font-size: 1.5rem; + line-height: 3rem; + margin-right: 2px; + padding: 0.25rem 1rem; +} + +#base-header-popover-icon { + width: 14px; + margin-bottom: 3px; + margin-left: 2px; +} + +@media all and (max-width: 960px), only screen and (max-device-width: 768px) { + #base-header-right{ + display: none; + } + + #base-header-right-mobile{ + display: unset; + } +} + +@media all and (max-width: 960px){ + #base-header-right-mobile{ + margin-top: 0.5rem; + margin-right: 0; + } + + #base-header-right-mobile img{ + height: 1.5rem; + } + + #base-header-mobile-menu{ + top: 3.3rem; + width: unset; + border-left: 2px solid #d0ebe6; + border-bottom: 2px solid #d0ebe6; + filter: drop-shadow(-6px 6px 2px #2e3e83); + border-top-left-radius: 10px; + } + + #base-header-mobile-menu a{ + font-size: 1.5rem; + line-height: 3rem; + margin: 0; + padding: 0.25rem 1rem; + } +} + +@media only screen and (max-device-width: 768px){ html{ padding-top: 260px; scroll-padding-top: 230px; @@ -103,12 +175,4 @@ html{ margin-top: 30px; margin-left: 20px; } - - #base-header-right{ - display: none; - } - - #base-header-right-mobile{ - display: unset; - } } diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css index 0e00553c72c8..0cc2ede59fe3 100644 --- a/WebHostLib/static/styles/tracker.css +++ b/WebHostLib/static/styles/tracker.css @@ -55,16 +55,16 @@ table.dataTable thead{ font-family: LexendDeca-Regular, sans-serif; } -table.dataTable tbody{ +table.dataTable tbody, table.dataTable tfoot{ background-color: #dce2bd; font-family: LexendDeca-Light, sans-serif; } -table.dataTable tbody tr:hover{ +table.dataTable tbody tr:hover, table.dataTable tfoot tr:hover{ background-color: #e2eabb; } -table.dataTable tbody td{ +table.dataTable tbody td, table.dataTable tfoot td{ padding: 4px 6px; } @@ -97,10 +97,14 @@ table.dataTable thead th.lower-row{ top: 46px; } -table.dataTable tbody td{ +table.dataTable tbody td, table.dataTable tfoot td{ border: 1px solid #bba967; } +table.dataTable tfoot td{ + font-weight: bold; +} + div.dataTables_scrollBody{ background-color: inherit !important; } diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-settings.css index 7639fa1c726e..cc5231634e5b 100644 --- a/WebHostLib/static/styles/weighted-settings.css +++ b/WebHostLib/static/styles/weighted-settings.css @@ -157,41 +157,29 @@ html{ background-color: rgba(0, 0, 0, 0.1); } -#weighted-settings .hints-div{ +#weighted-settings .hints-div, #weighted-settings .locations-div{ margin-top: 2rem; } -#weighted-settings .hints-div h3{ +#weighted-settings .hints-div h3, #weighted-settings .locations-div h3{ margin-bottom: 0.5rem; } -#weighted-settings .hints-div .hints-container{ +#weighted-settings .hints-container, #weighted-settings .locations-container{ display: flex; flex-direction: row; justify-content: space-between; - font-weight: bold; -} - -#weighted-settings .hints-div .hints-wrapper{ - width: 32.5%; -} - -#weighted-settings .hints-div .hints-wrapper .hint-div{ - display: flex; - flex-direction: row; - cursor: pointer; - user-select: none; - -moz-user-select: none; } -#weighted-settings .hints-div .hints-wrapper .hint-div:hover{ - background-color: rgba(0, 0, 0, 0.1); +#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{ + width: calc(50% - 0.5rem); + font-weight: bold; } -#weighted-settings .hints-div .hints-wrapper .hint-div label{ - flex-grow: 1; - padding: 0.125rem 0.5rem; - cursor: pointer; +#weighted-settings .hints-wrapper .simple-list, #weighted-settings .locations-wrapper .simple-list{ + margin-top: 0.25rem; + height: 300px; + font-weight: normal; } #weighted-settings #weighted-settings-button-row{ @@ -280,6 +268,30 @@ html{ flex-direction: column; } +#weighted-settings .simple-list{ + display: flex; + flex-direction: column; + + max-height: 300px; + overflow-y: auto; + border: 1px solid #ffffff; + border-radius: 4px; +} + +#weighted-settings .simple-list .list-row label{ + display: block; + width: calc(100% - 0.5rem); + padding: 0.0625rem 0.25rem; +} + +#weighted-settings .simple-list .list-row label:hover{ + background-color: rgba(0, 0, 0, 0.1); +} + +#weighted-settings .simple-list .list-row label input[type=checkbox]{ + margin-right: 0.5rem; +} + #weighted-settings .invisible{ display: none; } diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index b5fb83252eed..dd25a908049d 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -119,6 +119,28 @@

Generate Game{% if race %} (Race Mode){% endif %}

+ + + + + + + + diff --git a/WebHostLib/templates/header/baseHeader.html b/WebHostLib/templates/header/baseHeader.html index 80e8d6220db9..4090ff477f2d 100644 --- a/WebHostLib/templates/header/baseHeader.html +++ b/WebHostLib/templates/header/baseHeader.html @@ -11,10 +11,18 @@
@@ -22,12 +30,14 @@ Menu
+ - {% endblock %} diff --git a/WebHostLib/templates/hintTable.html b/WebHostLib/templates/hintTable.html new file mode 100644 index 000000000000..00b74111ea51 --- /dev/null +++ b/WebHostLib/templates/hintTable.html @@ -0,0 +1,28 @@ +{% for team, hints in hints.items() %} +
+ + + + + + + + + + + + + {%- for hint in hints -%} + + + + + + + + + {%- endfor -%} + +
FinderReceiverItemLocationEntranceFound
{{ long_player_names[team, hint.finding_player] }}{{ long_player_names[team, hint.receiving_player] }}{{ hint.item|item_name }}{{ hint.location|location_name }}{% if hint.entrance %}{{ hint.entrance }}{% else %}Vanilla{% endif %}{% if hint.found %}✔{% endif %}
+
+{% endfor %} \ No newline at end of file diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 6f02dc0944e0..ba15d64acac1 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -32,13 +32,18 @@ {% endif %} {{ macros.list_patches_room(room) }} {% if room.owner == session["_id"] %} -
-
- - -
-
+
+
+
+ + +
+
+ + Open Log File... + +