diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c51f155049..849e752305 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,10 +2,20 @@ name: Build -on: workflow_dispatch +on: + push: + paths: + - '.github/workflows/build.yml' + - 'setup.py' + - 'requirements.txt' + pull_request: + paths: + - '.github/workflows/build.yml' + - 'setup.py' + - 'requirements.txt' + workflow_dispatch: env: - SNI_VERSION: v0.0.88 ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 @@ -15,21 +25,18 @@ jobs: build-win-py38: # RCs will still be built and signed by hand runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.8' - name: Download run-time dependencies run: | - Invoke-WebRequest -Uri https://github.com/alttpo/sni/releases/download/${Env:SNI_VERSION}/sni-${Env:SNI_VERSION}-windows-amd64.zip -OutFile sni.zip - Expand-Archive -Path sni.zip -DestinationPath SNI -Force Invoke-WebRequest -Uri https://github.com/Ijwu/Enemizer/releases/download/${Env:ENEMIZER_VERSION}/win-x64.zip -OutFile enemizer.zip 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] $ZIP_NAME="Archipelago_$NAME.7z" @@ -39,24 +46,24 @@ jobs: Rename-Item exe.$NAME Archipelago 7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago - name: Store 7z - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ${{ env.ZIP_NAME }} 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@v2 + - uses: actions/checkout@v3 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install build-time dependencies @@ -69,10 +76,6 @@ jobs: chmod a+rx appimagetool - name: Download run-time dependencies run: | - wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz - tar xf sni-*.tar.xz - rm sni-*.tar.xz - mv sni-* SNI wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z - name: Build @@ -81,8 +84,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`" @@ -92,14 +94,18 @@ 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@v2 + uses: actions/upload-artifact@v3 with: name: ${{ env.APPIMAGE_NAME }} path: dist/${{ env.APPIMAGE_NAME }} retention-days: 7 - name: Store .tar.gz - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: ${{ env.TAR_NAME }} path: dist/${{ env.TAR_NAME }} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b331c25506..6aeb477a22 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -14,9 +14,17 @@ name: "CodeQL" on: push: branches: [ main ] + paths: + - '**.py' + - '**.js' + - '.github/workflows/codeql-analysis.yml' pull_request: # The branches below must be a subset of the branches above branches: [ main ] + paths: + - '**.py' + - '**.js' + - '.github/workflows/codeql-analysis.yml' schedule: - cron: '44 8 * * 1' @@ -35,11 +43,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v2 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -50,7 +58,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v2 # âšī¸ Command-line programs to run using the OS shell. # đ https://git.io/JvXDl @@ -64,4 +72,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 28adb50026..c20d244ad9 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,23 +3,29 @@ name: lint -on: [push, pull_request] +on: + push: + paths: + - '**.py' + pull_request: + paths: + - '**.py' jobs: - build: + flake8: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python 3.9 - uses: actions/setup-python@v1 + 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 + pip install flake8 if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - name: Lint with flake8 run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9559f7856..42594721d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,6 @@ on: - '*.*.*' env: - SNI_VERSION: v0.0.88 ENEMIZER_VERSION: 7.1 APPIMAGETOOL_VERSION: 13 @@ -30,20 +29,20 @@ 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 # - code below copied from build.yml - - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Install base dependencies run: | sudo apt update sudo apt -y install build-essential p7zip xz-utils wget libglib2.0-0 sudo apt -y install python3-gi libgirepository1.0-dev # should pull dependencies for gi installation below - name: Get a recent python - uses: actions/setup-python@v3 + uses: actions/setup-python@v4 with: python-version: '3.9' - name: Install build-time dependencies @@ -56,10 +55,6 @@ jobs: chmod a+rx appimagetool - name: Download run-time dependencies run: | - wget -nv https://github.com/alttpo/sni/releases/download/$SNI_VERSION/sni-$SNI_VERSION-manylinux2014-amd64.tar.xz - tar xf sni-*.tar.xz - rm sni-*.tar.xz - mv sni-* SNI wget -nv https://github.com/Ijwu/Enemizer/releases/download/$ENEMIZER_VERSION/ubuntu.16.04-x64.7z 7za x -oEnemizerCLI/ ubuntu.16.04-x64.7z - name: Build @@ -68,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 c86d637243..254d92dd6f 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -3,7 +3,25 @@ name: unittests -on: [push, pull_request] +on: + push: + paths: + - '**' + - '!docs/**' + - '!setup.py' + - '!*.iss' + - '!.gitignore' + - '!.github/workflows/**' + - '.github/workflows/unittests.yml' + pull_request: + paths: + - '**' + - '!docs/**' + - '!setup.py' + - '!*.iss' + - '!.gitignore' + - '!.github/workflows/**' + - '.github/workflows/unittests.yml' jobs: build: @@ -23,17 +41,19 @@ jobs: os: windows-latest - python: {version: '3.10'} # current os: windows-latest + - python: {version: '3.10'} # current + os: macos-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python.version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: 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" - name: Unittests run: | diff --git a/.gitignore b/.gitignore index e269202db9..5f8ad6b917 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ *.apm3 *.apmc *.apz5 +*.aptloz *.pyc *.pyd *.sfc @@ -25,6 +26,7 @@ *multisave *.archipelago *.apsave +*.BIN build bundle/components.wxs @@ -51,6 +53,7 @@ Output Logs/ /setup.ini /installdelete.iss /data/user.kv +/datapackage # Byte-compiled / optimized / DLL files __pycache__/ @@ -138,6 +141,7 @@ ENV/ env.bak/ venv.bak/ .code-workspace +shell.nix # Spyder project settings .spyderproject @@ -167,6 +171,7 @@ cython_debug/ jdk*/ minecraft*/ minecraft_versions.json +!worlds/minecraft/ # pyenv .python-version diff --git a/AdventureClient.py b/AdventureClient.py new file mode 100644 index 0000000000..06eea5215c --- /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 adventure_connector.lua" +CONNECTION_REFUSED_STATUS = \ + "Connection Refused. Please start your emulator and make sure adventure_connector.lua is running" +CONNECTION_RESET_STATUS = \ + "Connection was reset. Please restart your emulator, then restart adventure_connector.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 707f7bd76e..4e012429c2 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -2,13 +2,12 @@ from __future__ import annotations import copy import functools -import json import logging import random 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 +from collections import OrderedDict, Counter, deque, ChainMap from enum import IntEnum, IntFlag from typing import List, Dict, Optional, Set, Iterable, Union, Any, Tuple, TypedDict, Callable, NamedTuple @@ -72,6 +71,11 @@ class MultiWorld(): completion_condition: Dict[int, Callable[[CollectionState], bool]] indirect_connections: Dict[Region, Set[Entrance]] exclude_locations: Dict[int, Options.ExcludeLocations] + priority_locations: Dict[int, Options.PriorityLocations] + start_inventory: Dict[int, Options.StartInventory] + start_hints: Dict[int, Options.StartHints] + start_location_hints: Dict[int, Options.StartLocationHints] + item_links: Dict[int, Options.ItemLinks] game: Dict[int, str] @@ -332,7 +336,7 @@ class MultiWorld(): 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 """ @@ -761,169 +765,9 @@ class CollectionState(): found += self.prog_items[item_name, player] return found - def can_buy_unlimited(self, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(self) for - shop in self.multiworld.shops) - - def can_buy(self, item: str, player: int) -> bool: - return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(self) for - shop in self.multiworld.shops) - def item_count(self, item: str, player: int) -> int: return self.prog_items[item, player] - def has_triforce_pieces(self, count: int, player: int) -> bool: - return self.item_count('Triforce Piece', player) + self.item_count('Power Star', player) >= count - - def has_crystals(self, count: int, player: int) -> bool: - found: int = 0 - for crystalnumber in range(1, 8): - found += self.prog_items[f"Crystal {crystalnumber}", player] - if found >= count: - return True - return False - - def can_lift_rocks(self, player: int): - return self.has('Power Glove', player) or self.has('Titans Mitts', player) - - def bottle_count(self, player: int) -> int: - return min(self.multiworld.difficulty_requirements[player].progressive_bottle_limit, - self.count_group("Bottles", player)) - - def has_hearts(self, player: int, count: int) -> int: - # Warning: This only considers items that are marked as advancement items - return self.heart_count(player) >= count - - def heart_count(self, player: int) -> int: - # Warning: This only considers items that are marked as advancement items - diff = self.multiworld.difficulty_requirements[player] - return min(self.item_count('Boss Heart Container', player), diff.boss_heart_container_limit) \ - + self.item_count('Sanctuary Heart Container', player) \ - + min(self.item_count('Piece of Heart', player), diff.heart_piece_limit) // 4 \ - + 3 # starting hearts - - def can_lift_heavy_rocks(self, player: int) -> bool: - return self.has('Titans Mitts', player) - - def can_extend_magic(self, player: int, smallmagic: int = 16, - fullrefill: bool = False): # This reflects the total magic Link has, not the total extra he has. - basemagic = 8 - if self.has('Magic Upgrade (1/4)', player): - basemagic = 32 - elif self.has('Magic Upgrade (1/2)', player): - basemagic = 16 - if self.can_buy_unlimited('Green Potion', player) or self.can_buy_unlimited('Blue Potion', player): - if self.multiworld.item_functionality[player] == 'hard' and not fullrefill: - basemagic = basemagic + int(basemagic * 0.5 * self.bottle_count(player)) - elif self.multiworld.item_functionality[player] == 'expert' and not fullrefill: - basemagic = basemagic + int(basemagic * 0.25 * self.bottle_count(player)) - else: - basemagic = basemagic + basemagic * self.bottle_count(player) - return basemagic >= smallmagic - - def can_kill_most_things(self, player: int, enemies: int = 5) -> bool: - return (self.has_melee_weapon(player) - or self.has('Cane of Somaria', player) - or (self.has('Cane of Byrna', player) and (enemies < 6 or self.can_extend_magic(player))) - or self.can_shoot_arrows(player) - or self.has('Fire Rod', player) - or (self.has('Bombs (10)', player) and enemies < 6)) - - def can_shoot_arrows(self, player: int) -> bool: - if self.multiworld.retro_bow[player]: - return (self.has('Bow', player) or self.has('Silver Bow', player)) and self.can_buy('Single Arrow', player) - return self.has('Bow', player) or self.has('Silver Bow', player) - - def can_get_good_bee(self, player: int) -> bool: - cave = self.multiworld.get_region('Good Bee Cave', player) - return ( - self.has_group("Bottles", player) and - self.has('Bug Catching Net', player) and - (self.has('Pegasus Boots', player) or (self.has_sword(player) and self.has('Quake', player))) and - cave.can_reach(self) and - self.is_not_bunny(cave, player) - ) - - def can_retrieve_tablet(self, player: int) -> bool: - return self.has('Book of Mudora', player) and (self.has_beam_sword(player) or - (self.multiworld.swordless[player] and - self.has("Hammer", player))) - - def has_sword(self, player: int) -> bool: - return self.has('Fighter Sword', player) \ - or self.has('Master Sword', player) \ - or self.has('Tempered Sword', player) \ - or self.has('Golden Sword', player) - - def has_beam_sword(self, player: int) -> bool: - return self.has('Master Sword', player) or self.has('Tempered Sword', player) or self.has('Golden Sword', - player) - - def has_melee_weapon(self, player: int) -> bool: - return self.has_sword(player) or self.has('Hammer', player) - - def has_fire_source(self, player: int) -> bool: - return self.has('Fire Rod', player) or self.has('Lamp', player) - - def can_melt_things(self, player: int) -> bool: - return self.has('Fire Rod', player) or \ - (self.has('Bombos', player) and - (self.multiworld.swordless[player] or - self.has_sword(player))) - - def can_avoid_lasers(self, player: int) -> bool: - return self.has('Mirror Shield', player) or self.has('Cane of Byrna', player) or self.has('Cape', player) - - def is_not_bunny(self, region: Region, player: int) -> bool: - if self.has('Moon Pearl', player): - return True - - return region.is_light_world if self.multiworld.mode[player] != 'inverted' else region.is_dark_world - - def can_reach_light_world(self, player: int) -> bool: - if True in [i.is_light_world for i in self.reachable_regions[player]]: - return True - return False - - def can_reach_dark_world(self, player: int) -> bool: - if True in [i.is_dark_world for i in self.reachable_regions[player]]: - return True - return False - - def has_misery_mire_medallion(self, player: int) -> bool: - return self.has(self.multiworld.required_medallions[player][0], player) - - def has_turtle_rock_medallion(self, player: int) -> bool: - return self.has(self.multiworld.required_medallions[player][1], player) - - def can_boots_clip_lw(self, player: int) -> bool: - if self.multiworld.mode[player] == 'inverted': - return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) - return self.has('Pegasus Boots', player) - - def can_boots_clip_dw(self, player: int) -> bool: - if self.multiworld.mode[player] != 'inverted': - return self.has('Pegasus Boots', player) and self.has('Moon Pearl', player) - return self.has('Pegasus Boots', player) - - def can_get_glitched_speed_lw(self, player: int) -> bool: - rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] - if self.multiworld.mode[player] == 'inverted': - rules.append(self.has('Moon Pearl', player)) - return all(rules) - - def can_superbunny_mirror_with_sword(self, player: int) -> bool: - return self.has('Magic Mirror', player) and self.has_sword(player) - - def can_get_glitched_speed_dw(self, player: int) -> bool: - rules = [self.has('Pegasus Boots', player), any([self.has('Hookshot', player), self.has_sword(player)])] - if self.multiworld.mode[player] != 'inverted': - rules.append(self.has('Moon Pearl', player)) - return all(rules) - - def can_bomb_clip(self, region: Region, player: int) -> bool: - return self.is_not_bunny(region, player) and self.has('Pegasus Boots', player) - def collect(self, item: Item, event: bool = False, location: Optional[Location] = None) -> bool: if location: self.locations_checked.add(location) @@ -959,12 +803,6 @@ class Region: exits: List[Entrance] locations: List[Location] dungeon: Optional[Dungeon] = None - shop: Optional = None - - # LttP specific. TODO: move to a LttPRegion - # will be set after making connections. - is_light_world: bool = False - is_dark_world: bool = False def __init__(self, name: str, player: int, multiworld: MultiWorld, hint: Optional[str] = None): self.name = name @@ -1129,7 +967,7 @@ class Location: self.parent_region = parent def can_fill(self, state: CollectionState, item: Item, check_access=True) -> bool: - return (self.always_allow(state, item) + return ((self.always_allow(state, item) and item.name not in state.multiworld.non_local_items[item.player]) or ((self.progress_type != LocationProgressType.EXCLUDED or not (item.advancement or item.useful)) and self.item_rule(item) and (not check_access or self.can_reach(state)))) @@ -1371,7 +1209,7 @@ class Spoiler(): 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])} @@ -1429,7 +1267,7 @@ class Spoiler(): res = getattr(self.multiworld, option_key)[player] display_name = getattr(option_obj, "display_name", option_key) try: - outfile.write(f'{display_name + ":":33}{res.get_current_option_name()}\n') + outfile.write(f'{display_name + ":":33}{res.current_option_name}\n') except: raise Exception @@ -1446,12 +1284,11 @@ class Spoiler(): if self.multiworld.players > 1: outfile.write('\nPlayer %d: %s\n' % (player, self.multiworld.get_player_name(player))) outfile.write('Game: %s\n' % self.multiworld.game[player]) - for f_option, option in Options.per_game_common_options.items(): + + options = ChainMap(Options.per_game_common_options, self.multiworld.worlds[player].option_definitions) + for f_option, option in options.items(): write_option(f_option, option) - options = self.multiworld.worlds[player].option_definitions - if options: - for f_option, option in options.items(): - write_option(f_option, option) + AutoWorld.call_single(self.multiworld, "write_spoiler_header", player, outfile) if self.entrances: @@ -1465,7 +1302,7 @@ class Spoiler(): 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()] + 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])) diff --git a/CommonClient.py b/CommonClient.py index 92f8d76a66..4892f69f06 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -63,19 +63,22 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_received(self) -> bool: """List all received items""" - logger.info(f'{len(self.ctx.items_received)} received items:') + self.output(f'{len(self.ctx.items_received)} received items:') for index, item in enumerate(self.ctx.items_received, 1): 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 +139,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})') @@ -223,7 +226,7 @@ class CommonContext: 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") @@ -399,32 +402,40 @@ class CommonContext: 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 +445,17 @@ class CommonContext: 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) # DeathLink hooks @@ -661,14 +674,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"] diff --git a/Fill.py b/Fill.py index ac3ae8fc6d..6fa5ecb00d 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,27 @@ 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] = [] swapped_items: typing.Counter[typing.Tuple[int, str]] = 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 +50,9 @@ 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) + item_pool.remove(item) 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) @@ -111,7 +122,7 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: reachable_items[placed_item.player].appendleft( placed_item) - itempool.append(placed_item) + item_pool.append(placed_item) break @@ -133,6 +144,21 @@ def fill_restrictive(world: MultiWorld, base_state: CollectionState, locations: if on_place: on_place(spot_to_fill) + 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 +168,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 +525,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 +551,10 @@ def balance_multiworld_progression(world: MultiWorld) -> None: 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 +828,6 @@ def distribute_planned(world: MultiWorld) -> None: for player in worlds: locations += non_early_locations[player] - block['locations'] = locations if not block['count']: @@ -840,8 +869,7 @@ def distribute_planned(world: MultiWorld) -> None: maxcount = placement['count']['target'] from_pool = placement['from_pool'] - candidates = list(location for location in world.get_unfilled_locations_for_players(locations, - worlds)) + candidates = list(world.get_unfilled_locations_for_players(locations, sorted(worlds))) world.random.shuffle(candidates) world.random.shuffle(items) count = 0 diff --git a/Generate.py b/Generate.py index dadabd7ac6..afb34f11c6 100644 --- a/Generate.py +++ b/Generate.py @@ -107,7 +107,7 @@ def main(args=None, callback=ERmain): player_files = {} for file in os.scandir(args.player_files_path): fname = file.name - if file.is_file() and not file.name.startswith(".") and \ + if file.is_file() and not fname.startswith(".") and \ os.path.join(args.player_files_path, fname) not in {args.meta_file_path, args.weights_file_path}: path = os.path.join(args.player_files_path, fname) try: diff --git a/KH2Client.py b/KH2Client.py new file mode 100644 index 0000000000..5223d8a111 --- /dev/null +++ b/KH2Client.py @@ -0,0 +1,906 @@ +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 = [] + # flag for if the player has gotten their starting inventory from the server + self.hasStartingInvo = False + # list used to keep track of locations+items player has. Used for disoneccting + self.kh2seedsave = {"checked_locations": {"0": []}, + "starting_inventory": self.hasStartingInvo, + + # Character: [back of invo, front of invo] + "SoraInvo": [0x25CC, 0x2546], + "DonaldInvo": [0x2678, 0x2658], + "GoofyInvo": [0x278E, 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 + "worldIdChecks": { + "1": [], # world of darkness (story cutscenes) + "2": [], + "3": [], # destiny island doesn't have checks to ima put tt checks here + "4": [], + "5": [], + "6": [], + "7": [], + "8": [], + "9": [], + "10": [], + "11": [], + # atlantica isn't a supported world. if you go in atlantica it will check dc + "12": [], + "13": [], + "14": [], + "15": [], + # world map, but you only go to the world map while on the way to goa so checking hb + "16": [], + "17": [], + "18": [], + "255": [], # starting screen + }, + "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} + } + 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, 0x25CE], + "Quick Run": [0x62, 0x65, 0x25D0], + "Dodge Roll": [0x234, 0x237, 0x25D2], + "Aerial Dodge": [0x066, 0x069, 0x25D4], + "Glide": [0x6A, 0x6D, 0x25D6]} + 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"): + with open(os.path.join(self.game_communication_path, f"kh2save{self.kh2seedname}{self.auth}.json"), + 'wt') as f: + pass + 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) + + if cmd in {"Connected"}: + for player in args['players']: + if str(player.slot) not in self.kh2seedsave["checked_locations"]: + self.kh2seedsave["checked_locations"].update({str(player.slot): []}) + self.kh2slotdata = args['slot_data'] + self.serverconneced = True + 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 != len(self.items_received): + for item in args['items']: + # starting invo from server + if item.location in {-2}: + if not self.kh2seedsave["starting_inventory"]: + asyncio.create_task(self.give_item(item.item)) + # if location is not already given or is !getitem + elif item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \ + or item.location in {-1}: + asyncio.create_task(self.give_item(item.item)) + if item.location not in self.kh2seedsave["checked_locations"][str(item.player)] \ + and item.location not in {-1, -2}: + self.kh2seedsave["checked_locations"][str(item.player)].append(item.location) + if not self.kh2seedsave["starting_inventory"]: + self.kh2seedsave["starting_inventory"] = True + + 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(): + if location 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.locations_checked.add(location) + self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + 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") + if location not in self.locations_checked \ + and currentLevel >= data.bitIndex: + if self.kh2seedsave["Levels"]["SoraLevel"] < currentLevel: + self.kh2seedsave["Levels"]["SoraLevel"] = currentLevel + self.locations_checked.add(location) + self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + 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") + if location 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.locations_checked.add(location) + self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + 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(): + if location 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.locations_checked.add(location) + self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + + for location, data in formSlots.items(): + if location 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.add(location) + self.sending = self.sending + [(int(kh2_loc_name_to_id[location]))] + 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: + currentworld = str(int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + 0x0714DB8, 1), "big")) + for location in self.kh2seedsave["worldIdChecks"][currentworld]: + 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) + + def verifyLocation(self, location): + locationData = self.location_name_to_worlddata[location] + locationName = self.lookup_id_to_Location[location] + isChecked = True + + if locationName not in levels_locations: + if (int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1), + "big") & 0x1 << locationData.bitIndex) == 0: + isChecked = False + elif locationName in SoraLevels: + if int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + 0x24FF, 1), + "big") < locationData.bitIndex: + isChecked = False + elif int.from_bytes(self.kh2.read_bytes(self.kh2.base_address + self.Save + locationData.addrObtained, 1), + "big") < locationData.bitIndex: + isChecked = False + return isChecked + + 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): + self.kh2.write_short(self.kh2.base_address + self.Save + slot, itemData.memaddr) + + 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: + 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] + + 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: + 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: + currentWorld = int.from_bytes(ctx.kh2.read_bytes(ctx.kh2.base_address + 0x0714DB8, 1), "big") + if location not in ctx.kh2seedsave["worldIdChecks"][str(currentWorld)]: + ctx.kh2seedsave["worldIdChecks"][str(currentWorld)].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 d5ade1f184..be40987e32 100644 --- a/Launcher.py +++ b/Launcher.py @@ -14,10 +14,11 @@ import itertools import shlex import subprocess import sys -from enum import Enum, auto from os.path import isfile from shutil import which -from typing import Iterable, Sequence, Callable, Union, Optional +from typing import Sequence, Union, Optional + +from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier if __name__ == "__main__": import ModuleUpdate @@ -70,101 +71,12 @@ def browse_files(): webbrowser.open(file) -# 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')), - 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')), - # 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') -} +]) def identify(path: Union[None, str]): diff --git a/LinksAwakeningClient.py b/LinksAwakeningClient.py new file mode 100644 index 0000000000..e0557e4af4 --- /dev/null +++ b/LinksAwakeningClient.py @@ -0,0 +1,609 @@ +import ModuleUpdate +ModuleUpdate.update() + +import Utils + +if __name__ == "__main__": + Utils.init_logging("LinksAwakeningContext", exception_logger="Client") + +import asyncio +import base64 +import binascii +import io +import logging +import select +import socket +import time +import typing +import urllib + +import colorama + + +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 = 0xDDFE # 0xDB58 + 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) + + def get_retroarch_version(self): + self.send(b'VERSION\n') + select.select([self.socket], [], []) + response_str, addr = self.socket.recvfrom(16) + return response_str.rstrip() + + def get_retroarch_status(self, timeout): + self.send(b'GET_STATUS\n') + select.select([self.socket], [], [], timeout) + response_str, addr = self.socket.recvfrom(1000, ) + return response_str.rstrip() + + 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): + response = await asyncio.get_event_loop().sock_recv(self.socket, 4096) + 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(): + 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 read_memory(self, address, size=1): + command = "READ_CORE_MEMORY" + + self.send(f'{command} {hex(address)} {size}\n') + response = self.recv() + + splits = response.decode().split(" ", 2) + + assert (splits[0] == command) + # Ignore the address for now + + # TODO: transform to bytes + if splits[2][:2] == "-1" or splits[0] != "READ_CORE_MEMORY": + raise BadRetroArchResponse() + 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() + response = response[:-1] + splits = response.decode().split(" ", 2) + + assert (splits[0] == command) + # Ignore the address for now + + # TODO: transform to bytes + return bytearray.fromhex(splits[2]) + + 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) + + 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 = {} + + 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.gameboy = RAGameboy(retroarch_address, retroarch_port) + + async def wait_for_retroarch_connection(self): + logger.info("Waiting on connection to Retroarch...") + while True: + try: + version = 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: + try: + status = self.gameboy.get_retroarch_status(0.1) + 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 + except (BlockingIOError, TimeoutError) as e: + await asyncio.sleep(0.1) + pass + logger.info(f"Connected to Retroarch {version} {info}") + self.gameboy.read_memory(0x1000) + return + except ConnectionResetError: + await asyncio.sleep(1.0) + pass + + def reset_auth(self): + auth = binascii.hexlify(self.gameboy.read_memory(0x0134, 12)).decode() + + if self.auth: + assert (auth == self.auth) + + 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, [next_index]) + + 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): + pass + logger.info("Ready!") + last_index = 0 + + 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() + + next_index = self.gameboy.read_memory(LAClientConstants.wRecvIndex)[0] + if next_index != self.last_index: + self.last_index = next_index + # logger.info(f"Got new index {next_index}") + + 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 = (await self.gameboy.async_read_memory_safe(LAClientConstants.wRecvIndex))[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 = MagpieBridge() + magpie_task = None + won = False + + def __init__(self, server_address: typing.Optional[str], password: typing.Optional[str]) -> None: + self.client = LinksAwakeningClient() + 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() + + 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) + + 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()) + 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) + self.auth = self.client.auth + await self.get_username() + 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"], args["index"]): + self.client.recvd_checks[index] = item + + 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() + + 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 + logger.info("(Re)Starting game loop") + self.found_checks.clear() + await self.client.wait_for_retroarch_connection() + self.client.reset_auth() + 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() + 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 GameboyException: + time.sleep(1.0) + pass + + +async def main(): + parser = get_base_parser(description="Link's Awakening Client.") + parser.add_argument("--url", help="Archipelago connection url") + + parser.add_argument('diff_file', default="", type=str, nargs="?", + help='Path to a .apladx Archipelago Binary Patch file') + args = parser.parse_args() + logger.info(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: + args.url = meta["server"] + logger.info(f"wrote rom file to {rom_file}") + + if args.url: + url = urllib.parse.urlparse(args.url) + args.connect = url.netloc + if url.password: + args.password = urllib.parse.unquote(url.password) + + ctx = LinksAwakeningContext(args.connect, args.password) + + 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() + + await ctx.exit_event.wait() + await ctx.shutdown() + +if __name__ == '__main__': + colorama.init() + asyncio.run(main()) + colorama.deinit() diff --git a/Main.py b/Main.py index 04a7e3bff6..03b2e1b155 100644 --- a/Main.py +++ b/Main.py @@ -38,7 +38,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world = MultiWorld(args.multi) logger = logging.getLogger() - world.set_seed(seed, args.race, str(args.outputname if args.outputname else world.seed)) + world.set_seed(seed, args.race, str(args.outputname) if args.outputname else None) world.plando_options = args.plando_options world.shuffle = args.shuffle.copy() @@ -53,7 +53,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.enemy_damage = args.enemy_damage.copy() world.beemizer_total_chance = args.beemizer_total_chance.copy() world.beemizer_trap_chance = args.beemizer_trap_chance.copy() - world.timer = args.timer.copy() world.countdown_start_time = args.countdown_start_time.copy() world.red_clock_time = args.red_clock_time.copy() world.blue_clock_time = args.blue_clock_time.copy() @@ -79,7 +78,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No world.state = CollectionState(world) logger.info('Archipelago Version %s - Seed: %s\n', __version__, world.seed) - logger.info("Found World Types:") + logger.info(f"Found {len(AutoWorld.AutoWorldRegister.world_types)} World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) max_item = 0 @@ -356,12 +355,11 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No 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 + # embedded data package + data_package = { + game_world.game: worlds.network_data_package["games"][game_world.game] + for game_world in world.worlds.values() + } multidata = { "slot_data": slot_data, @@ -378,7 +376,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No "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 16b283f9d9..dd7a5cfd3e 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 @@ if __name__ == '__main__': 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,12 +288,12 @@ if __name__ == '__main__': 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', '') @@ -311,6 +303,7 @@ if __name__ == '__main__': 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 @@ if __name__ == '__main__': 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 a8258ce17e..ac40dbd66b 100644 --- a/ModuleUpdate.py +++ b/ModuleUpdate.py @@ -1,7 +1,6 @@ import os import sys import subprocess -import pkg_resources import warnings local_dir = os.path.dirname(__file__) @@ -22,18 +21,50 @@ if not update_ran: 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 +83,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 +110,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 faeb1b220b..ea055b662e 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -7,17 +7,20 @@ import functools import logging import zlib import collections -import typing -import inspect -import weakref import datetime -import threading -import random -import pickle -import itertools -import time -import operator +import functools import hashlib +import inspect +import itertools +import logging +import operator +import pickle +import random +import threading +import time +import typing +import weakref +import zlib import ModuleUpdate @@ -160,10 +163,13 @@ class Context: stored_data_notification_clients: typing.Dict[str, typing.Set[Client]] slot_info: typing.Dict[int, NetworkSlot] + 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})') + location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]] all_item_and_group_names: typing.Dict[str, typing.Set[str]] + all_location_and_group_names: typing.Dict[str, typing.Set[str]] non_hintable_names: typing.Dict[str, typing.Set[str]] def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int, @@ -231,30 +237,39 @@ class Context: # 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 = {} + self.all_location_and_group_names = {} self.non_hintable_names = collections.defaultdict(frozenset) self._load_game_data() - # Datapackage retrieval + # Data package retrieval def _load_game_data(self): import worlds self.gamespackage = worlds.network_data_package["games"] self.item_name_groups = {world_name: world.item_name_groups for world_name, world in worlds.AutoWorldRegister.world_types.items()} + self.location_name_groups = {world_name: world.location_name_groups for world_name, world in + worlds.AutoWorldRegister.world_types.items()} for world_name, world in worlds.AutoWorldRegister.world_types.items(): self.non_hintable_names[world_name] = world.hint_blacklist 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(): self.location_names[location_id] = location_name 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.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 @@ -343,7 +358,6 @@ class Context: for text in texts])) # loading - def load(self, multidatapath: str, use_embedded_server_options: bool = False): if multidatapath.lower().endswith(".zip"): import zipfile @@ -358,7 +372,7 @@ class Context: 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 @@ -368,7 +382,8 @@ class Context: 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: @@ -423,15 +438,22 @@ class Context: 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"] - del data["item_name_groups"] # remove from datapackage, but keep in self.item_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] + for game_name, data in self.location_name_groups.items(): + self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame] # saving @@ -575,7 +597,7 @@ class Context: 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): @@ -723,10 +745,12 @@ 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, @@ -735,7 +759,9 @@ async def on_client_connected(ctx: Context, client: Client): '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(), }]) @@ -756,7 +782,8 @@ 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( @@ -773,11 +800,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): @@ -1299,27 +1327,41 @@ class ClientMessageProcessor(CommonCommandProcessor): "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.") @@ -1403,7 +1445,7 @@ class ClientMessageProcessor(CommonCommandProcessor): if game not in self.ctx.all_item_and_group_names: self.output("Can't look up item/location for unknown game. Hint for ID instead.") return False - names = self.ctx.location_names_for_game(game) \ + names = self.ctx.all_location_and_group_names[game] \ if for_location else \ self.ctx.all_item_and_group_names[game] hint_name, usable, response = get_intended_text(input_text, names) @@ -1419,6 +1461,11 @@ class ClientMessageProcessor(CommonCommandProcessor): hints.extend(collect_hints(self.ctx, self.client.team, self.client.slot, item_name)) elif not for_location and hint_name in self.ctx.item_names_for_game(game): # item name hints = collect_hints(self.ctx, self.client.team, self.client.slot, hint_name) + elif hint_name in self.ctx.location_name_groups[game]: # location group name + hints = [] + for loc_name in self.ctx.location_name_groups[game][hint_name]: + if loc_name in self.ctx.location_names_for_game(game): + hints.extend(collect_hint_location_name(self.ctx, self.client.team, self.client.slot, loc_name)) else: # location name hints = collect_hint_location_name(self.ctx, self.client.team, self.client.slot, hint_name) diff --git a/NetUtils.py b/NetUtils.py index ca44fdea22..2b9a653123 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -35,7 +35,7 @@ class SlotType(enum.IntFlag): @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 diff --git a/OoTClient.py b/OoTClient.py index 696bf39016..f8a052402f 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -289,7 +289,10 @@ 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"])) + rom_file_name = Utils.get_options()["oot_options"]["rom_file"] + if not os.path.exists(rom_file_name): + rom_file_name = Utils.user_path(rom_file_name) + rom = Rom(rom_file_name) apply_patch_file(rom, apz5_file, sub_file=(os.path.basename(base_name) + '.zpf' if zipfile.is_zipfile(apz5_file) diff --git a/Options.py b/Options.py index 78cbd3f034..b6c55468ca 100644 --- a/Options.py +++ b/Options.py @@ -1,5 +1,6 @@ from __future__ import annotations import abc +import logging from copy import deepcopy import math import numbers @@ -9,6 +10,10 @@ import random 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 + class AssembleOptions(abc.ABCMeta): def __new__(mcs, name, bases, attrs): @@ -95,11 +100,11 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): supports_weighting = True # filled by AssembleOptions: - name_lookup: typing.Dict[int, str] + name_lookup: typing.Dict[T, str] options: typing.Dict[str, int] def __repr__(self) -> str: - return f"{self.__class__.__name__}({self.get_current_option_name()})" + return f"{self.__class__.__name__}({self.current_option_name})" def __hash__(self) -> int: return hash(self.value) @@ -109,7 +114,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): return self.name_lookup[self.value] def get_current_option_name(self) -> str: - """For display purposes.""" + """Deprecated. use current_option_name instead. TODO remove around 0.4""" + logging.warning(DeprecationWarning(f"get_current_option_name for {self.__class__.__name__} is deprecated." + f" use current_option_name instead. Worlds should use {self}.current_key")) + return self.current_option_name + + @property + def current_option_name(self) -> str: + """For display purposes. Worlds should be using current_key.""" return self.get_option_name(self.value) @classmethod @@ -131,17 +143,14 @@ class Option(typing.Generic[T], metaclass=AssembleOptions): ... if typing.TYPE_CHECKING: - from Generate import PlandoOptions - from worlds.AutoWorld import World - - def verify(self, world: World, player_name: str, plando_options: PlandoOptions) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: PlandoOptions) -> None: pass else: def verify(self, *args, **kwargs) -> None: pass -class FreeText(Option): +class FreeText(Option[str]): """Text option that allows users to enter strings. Needs to be validated by the world or option definition.""" @@ -162,7 +171,7 @@ class FreeText(Option): return cls.from_text(str(data)) @classmethod - def get_option_name(cls, value: T) -> str: + def get_option_name(cls, value: str) -> str: return value @@ -424,6 +433,7 @@ class Choice(NumericOption): class TextChoice(Choice): """Allows custom string input and offers choices. Choices will resolve to int and text will resolve to string""" + value: typing.Union[str, int] def __init__(self, value: typing.Union[str, int]): assert isinstance(value, str) or isinstance(value, int), \ @@ -434,8 +444,7 @@ class TextChoice(Choice): def current_key(self) -> str: if isinstance(self.value, str): return self.value - else: - return self.name_lookup[self.value] + return super().current_key @classmethod def from_text(cls, text: str) -> TextChoice: @@ -450,7 +459,7 @@ class TextChoice(Choice): def get_option_name(cls, value: T) -> str: if isinstance(value, str): return value - return cls.name_lookup[value] + return super().get_option_name(value) def __eq__(self, other: typing.Any): if isinstance(other, self.__class__): @@ -573,12 +582,11 @@ class PlandoBosses(TextChoice, metaclass=BossMeta): def valid_location_name(cls, value: str) -> bool: return value in cls.locations - def verify(self, world, player_name: str, plando_options) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if isinstance(self.value, int): return - from Generate import PlandoOptions + from BaseClasses import PlandoOptions if not(PlandoOptions.bosses & plando_options): - import logging # plando is disabled but plando options were given so pull the option and change it to an int option = self.value.split(";")[-1] self.value = self.options[option] @@ -716,7 +724,7 @@ class VerifyKeys: value: typing.Any @classmethod - def verify_keys(cls, data): + 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) @@ -725,12 +733,17 @@ class VerifyKeys: raise Exception(f"Found unexpected key {', '.join(extra)} in {cls}. " f"Allowed keys: {cls.valid_keys}.") - def verify(self, world, player_name: str, plando_options) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: if self.convert_name_groups and self.verify_item_name: new_value = type(self.value)() # empty container of whatever value is for item_name in self.value: new_value |= world.item_name_groups.get(item_name, {item_name}) self.value = new_value + elif self.convert_name_groups and self.verify_location_name: + new_value = type(self.value)() + for loc_name in self.value: + new_value |= world.location_name_groups.get(loc_name, {loc_name}) + self.value = new_value if self.verify_item_name: for item_name in self.value: if item_name not in world.item_names: @@ -830,7 +843,9 @@ class OptionSet(Option[typing.Set[str]], VerifyKeys): return item in self.value -local_objective = Toggle # local triforce pieces, local dungeon prizes etc. +class ItemSet(OptionSet): + verify_item_name = True + convert_name_groups = True class Accessibility(Choice): @@ -873,11 +888,6 @@ common_options = { } -class ItemSet(OptionSet): - verify_item_name = True - convert_name_groups = True - - class LocalItems(ItemSet): """Forces these items to be in their native world.""" display_name = "Local Items" @@ -899,22 +909,24 @@ class StartHints(ItemSet): display_name = "Start Hints" -class StartLocationHints(OptionSet): +class LocationSet(OptionSet): + verify_location_name = True + convert_name_groups = True + + +class StartLocationHints(LocationSet): """Start with these locations and their item prefilled into the !hint command""" display_name = "Start Location Hints" - verify_location_name = True -class ExcludeLocations(OptionSet): +class ExcludeLocations(LocationSet): """Prevent these locations from having an important item""" display_name = "Excluded Locations" - verify_location_name = True -class PriorityLocations(OptionSet): +class PriorityLocations(LocationSet): """Prevent these locations from having an unimportant item""" display_name = "Priority Locations" - verify_location_name = True class DeathLink(Toggle): @@ -955,7 +967,7 @@ class ItemLinks(OptionList): pool |= {item_name} return pool - def verify(self, world, player_name: str, plando_options) -> None: + def verify(self, world: typing.Type[World], player_name: str, plando_options: "PlandoOptions") -> None: link: dict super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() diff --git a/PokemonClient.py b/PokemonClient.py index eb1f124391..e78e76fa00 100644 --- a/PokemonClient.py +++ b/PokemonClient.py @@ -17,7 +17,7 @@ from CommonClient import CommonContext, server_loop, gui_enabled, ClientCommandP from worlds.pokemon_rb.locations import location_data from worlds.pokemon_rb.rom import RedDeltaPatch, BlueDeltaPatch -location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}} +location_map = {"Rod": {}, "EventFlag": {}, "Missable": {}, "Hidden": {}, "list": {}, "DexSanityFlag": {}} location_bytes_bits = {} for location in location_data: if location.ram_address is not None: @@ -40,7 +40,7 @@ CONNECTION_INITIAL_STATUS = "Connection has not been initiated" DISPLAY_MSGS = True -SCRIPT_VERSION = 1 +SCRIPT_VERSION = 3 class GBCommandProcessor(ClientCommandProcessor): @@ -70,6 +70,8 @@ class GBContext(CommonContext): self.set_deathlink = False self.client_compatibility_mode = 0 self.items_handling = 0b001 + self.sent_release = False + self.sent_collect = False async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -124,7 +126,8 @@ def get_payload(ctx: GBContext): "items": [item.item for item in ctx.items_received], "messages": {f'{key[0]}:{key[1]}': value for key, value in ctx.messages.items() if key[0] > current_time - 10}, - "deathlink": ctx.deathlink_pending + "deathlink": ctx.deathlink_pending, + "options": ((ctx.permissions['release'] in ('goal', 'enabled')) * 2) + (ctx.permissions['collect'] in ('goal', 'enabled')) } ) ctx.deathlink_pending = False @@ -134,10 +137,13 @@ def get_payload(ctx: GBContext): async def parse_locations(data: List, ctx: GBContext): locations = [] flags = {"EventFlag": data[:0x140], "Missable": data[0x140:0x140 + 0x20], - "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], "Rod": data[0x140 + 0x20 + 0x0E:]} + "Hidden": data[0x140 + 0x20: 0x140 + 0x20 + 0x0E], + "Rod": data[0x140 + 0x20 + 0x0E:0x140 + 0x20 + 0x0E + 0x01]} - if len(flags['Rod']) > 1: - return + if len(data) > 0x140 + 0x20 + 0x0E + 0x01: + flags["DexSanityFlag"] = data[0x140 + 0x20 + 0x0E + 0x01:] + else: + flags["DexSanityFlag"] = [0] * 19 for flag_type, loc_map in location_map.items(): for flag, loc_id in loc_map.items(): @@ -207,6 +213,16 @@ async def gb_sync_task(ctx: GBContext): async_start(parse_locations(data_decoded['locations'], ctx)) if 'deathLink' in data_decoded and data_decoded['deathLink'] and 'DeathLink' in ctx.tags: await ctx.send_death(ctx.auth + " is out of usable PokÊmon! " + ctx.auth + " blacked out!") + if 'options' in data_decoded: + msgs = [] + if data_decoded['options'] & 4 and not ctx.sent_release: + ctx.sent_release = True + msgs.append({"cmd": "Say", "text": "!release"}) + if data_decoded['options'] & 8 and not ctx.sent_collect: + ctx.sent_collect = True + msgs.append({"cmd": "Say", "text": "!collect"}) + if msgs: + await ctx.send_msgs(msgs) if ctx.set_deathlink: await ctx.update_death_link(True) except asyncio.TimeoutError: diff --git a/README.md b/README.md index 42493b5904..9454d0f168 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,15 @@ Currently, the following games are supported: * Overcooked! 2 * Zillion * Lufia II Ancient Cave +* Blasphemous +* Wargroove +* Stardew Valley +* The Legend of Zelda +* The Messenger +* Kingdom Hearts 2 +* The Legend of Zelda: Link's Awakening DX +* Clique +* Adventure 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 8d402b1d5f..f4ad53c617 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -115,8 +115,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor): 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 diff --git a/Starcraft2Client.py b/Starcraft2Client.py index 3b05f5aa87..cf16405766 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -52,9 +52,9 @@ class StarcraftClientProcessor(ClientCommandProcessor): """Overrides the current difficulty set for the seed. Takes the argument casual, normal, hard, or brutal""" options = difficulty.split() num_options = len(options) - difficulty_choice = options[0].lower() if num_options > 0: + difficulty_choice = options[0].lower() if difficulty_choice == "casual": self.ctx.difficulty_override = 0 elif difficulty_choice == "normal": @@ -71,7 +71,11 @@ class StarcraftClientProcessor(ClientCommandProcessor): return True else: - self.output("Difficulty needs to be specified in the command.") + if self.ctx.difficulty == -1: + self.output("Please connect to a seed before checking difficulty.") + else: + self.output("Current difficulty: " + ["Casual", "Normal", "Hard", "Brutal"][self.ctx.difficulty]) + self.output("To change the difficulty, add the name of the difficulty after the command.") return False def _cmd_disable_mission_check(self) -> bool: diff --git a/Utils.py b/Utils.py index 098d5f01e7..60b3904ff6 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 @@ -38,7 +39,7 @@ class Version(typing.NamedTuple): build: int -__version__ = "0.3.9" +__version__ = "0.4.0" version_tuple = tuplize_version(__version__) is_linux = sys.platform.startswith("linux") @@ -87,7 +88,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(): @@ -142,6 +146,17 @@ def user_path(*path: str) -> str: 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) @@ -195,11 +210,11 @@ def get_public_ipv4() -> str: ip = socket.gethostbyname(socket.gethostname()) ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx).read().decode("utf8").strip() + ip = urllib.request.urlopen("https://checkip.amazonaws.com/", context=ctx, timeout=10).read().decode("utf8").strip() except Exception as e: # noinspection PyBroadException try: - ip = urllib.request.urlopen("https://v4.ident.me", context=ctx).read().decode("utf8").strip() + ip = urllib.request.urlopen("https://v4.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() except Exception: logging.exception(e) pass # we could be offline, in a local game, so no point in erroring out @@ -213,7 +228,7 @@ def get_public_ipv6() -> str: ip = socket.gethostbyname(socket.gethostname()) ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen("https://v6.ident.me", context=ctx).read().decode("utf8").strip() + ip = urllib.request.urlopen("https://v6.ident.me", context=ctx, timeout=10).read().decode("utf8").strip() except Exception as e: logging.exception(e) pass # we could be offline, in a local game, or ipv6 may not be available @@ -248,6 +263,9 @@ def get_default_options() -> OptionsType: "lttp_options": { "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", }, + "ladx_options": { + "rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc", + }, "server_options": { "host": None, "port": 38281, @@ -310,9 +328,20 @@ def get_default_options() -> OptionsType: "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" - } + }, + "adventure_options": { + "rom_file": "ADVNTURE.BIN", + "display_msgs": True, + "rom_start": True, + "rom_args": "" + }, } return options @@ -380,6 +409,45 @@ def persistent_load() -> typing.Dict[str, dict]: return storage +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_adjuster_settings(game_name: str) -> typing.Dict[str, typing.Any]: adjuster_settings = persistent_load().get("adjuster", {}).get(game_name, {}) return adjuster_settings diff --git a/WargrooveClient.py b/WargrooveClient.py index fec20cc861..16bfeb15ab 100644 --- a/WargrooveClient.py +++ b/WargrooveClient.py @@ -1,4 +1,6 @@ from __future__ import annotations + +import atexit import os import sys import asyncio @@ -78,17 +80,18 @@ class WargrooveContext(CommonContext): # self.game_communication_path: files go in this path to pass data between us and the actual game if "appdata" in os.environ: options = Utils.get_options() - root_directory = options["wargroove_options"]["root_directory"].replace("/", "\\") - data_directory = "lib\\worlds\\wargroove\\data\\" - dev_data_directory = "worlds\\wargroove\\data\\" - appdata_wargroove = os.path.expandvars("%APPDATA%\\Chucklefish\\Wargroove\\") - if not os.path.isfile(root_directory + "\\win64_bin\\wargroove64.exe"): + root_directory = os.path.join(options["wargroove_options"]["root_directory"]) + data_directory = os.path.join("lib", "worlds", "wargroove", "data") + dev_data_directory = os.path.join("worlds", "wargroove", "data") + appdata_wargroove = os.path.expandvars(os.path.join("%APPDATA%", "Chucklefish", "Wargroove")) + if not os.path.isfile(os.path.join(root_directory, "win64_bin", "wargroove64.exe")): print_error_and_close("WargrooveClient couldn't find wargroove64.exe. " "Unable to infer required game_communication_path") - self.game_communication_path = root_directory + "\\AP" + self.game_communication_path = os.path.join(root_directory, "AP") if not os.path.exists(self.game_communication_path): os.makedirs(self.game_communication_path) - + self.remove_communication_files() + atexit.register(self.remove_communication_files) if not os.path.isdir(appdata_wargroove): print_error_and_close("WargrooveClient couldn't find Wargoove in appdata!" "Boot Wargroove and then close it to attempt to fix this error") @@ -109,10 +112,7 @@ class WargrooveContext(CommonContext): async def connection_closed(self): await super(WargrooveContext, self).connection_closed() - for root, dirs, files in os.walk(self.game_communication_path): - for file in files: - if file.find("obtain") <= -1: - os.remove(root + "/" + file) + self.remove_communication_files() @property def endpoints(self): @@ -123,10 +123,12 @@ class WargrooveContext(CommonContext): async def shutdown(self): await super(WargrooveContext, self).shutdown() + self.remove_communication_files() + + def remove_communication_files(self): for root, dirs, files in os.walk(self.game_communication_path): for file in files: - if file.find("obtain") <= -1: - os.remove(root+"/"+file) + os.remove(root + "/" + file) def on_package(self, cmd: str, args: dict): if cmd in {"Connected"}: diff --git a/WebHost.py b/WebHost.py index d098f6e7fb..40d366a02f 100644 --- a/WebHost.py +++ b/WebHost.py @@ -33,6 +33,11 @@ def get_app(): import yaml app.config.from_file(configpath, yaml.safe_load) logging.info(f"Updated config from {configpath}") + if not app.config["HOST_ADDRESS"]: + logging.info("Getting public IP, as HOST_ADDRESS is empty.") + app.config["HOST_ADDRESS"] = Utils.get_public_ipv4() + logging.info(f"HOST_ADDRESS was set to {app.config['HOST_ADDRESS']}") + db.bind(**app.config["PONY"]) db.generate_mapping(create_tables=True) return app diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index e8e5b59d89..8bd3609c1d 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -51,7 +51,7 @@ app.config["PONY"] = { app.config["MAX_ROLL"] = 20 app.config["CACHE_TYPE"] = "flask_caching.backends.SimpleCache" app.config["JSON_AS_ASCII"] = False -app.config["PATCH_TARGET"] = "archipelago.gg" +app.config["HOST_ADDRESS"] = "" cache = Cache(app) Compress(app) diff --git a/WebHostLib/api/__init__.py b/WebHostLib/api/__init__.py index eac19d8456..102c3a49f6 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/autolauncher.py b/WebHostLib/autolauncher.py index 4cf7243302..484755b3c3 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -179,6 +179,7 @@ class MultiworldInstance(): self.ponyconfig = config["PONY"] self.cert = config["SELFLAUNCHCERT"] self.key = config["SELFLAUNCHKEY"] + self.host = config["HOST_ADDRESS"] def start(self): if self.process and self.process.is_alive(): @@ -187,7 +188,7 @@ class MultiworldInstance(): logging.info(f"Spinning up {self.room_id}") process = multiprocessing.Process(group=None, target=run_server_process, args=(self.room_id, self.ponyconfig, get_static_server_data(), - self.cert, self.key), + self.cert, self.key, self.host), name="MultiHost") process.start() # bind after start to prevent thread sync issues with guardian. diff --git a/WebHostLib/customserver.py b/WebHostLib/customserver.py index 9c21fca4f9..b34e196178 100644 --- a/WebHostLib/customserver.py +++ b/WebHostLib/customserver.py @@ -19,7 +19,7 @@ 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 .models import Command, GameDataPackage, Room, db class CustomClientMessageProcessor(ClientMessageProcessor): @@ -92,7 +92,21 @@ class WebHostContext(Context): 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): @@ -131,6 +145,8 @@ def get_static_server_data() -> dict: "gamespackage": worlds.network_data_package["games"], "item_name_groups": {world_name: world.item_name_groups for world_name, world in worlds.AutoWorldRegister.world_types.items()}, + "location_name_groups": {world_name: world.location_name_groups for world_name, world in + worlds.AutoWorldRegister.world_types.items()}, } for world_name, world in worlds.AutoWorldRegister.world_types.items(): @@ -140,7 +156,8 @@ def get_static_server_data() -> dict: def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, - cert_file: typing.Optional[str], cert_key_file: typing.Optional[str]): + cert_file: typing.Optional[str], cert_key_file: typing.Optional[str], + host: str): # establish DB connection for multidata and multisave db.bind(**ponyconfig) db.generate_mapping(check_tables=False) @@ -165,17 +182,18 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, for wssocket in ctx.server.ws_server.sockets: socketname = wssocket.getsockname() if wssocket.family == socket.AF_INET6: - logging.info(f'Hosting game at [{get_public_ipv6()}]:{socketname[1]}') # Prefer IPv4, as most users seem to not have working ipv6 support if not port: port = socketname[1] elif wssocket.family == socket.AF_INET: - logging.info(f'Hosting game at {get_public_ipv4()}:{socketname[1]}') port = socketname[1] if port: + logging.info(f'Hosting game at {host}:{port}') with db_session: room = Room.get(id=ctx.room_id) room.last_port = port + else: + logging.exception("Could not determine port. Likely hosting failure.") with db_session: ctx.auto_shutdown = Room.get(id=room_id).timeout ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [])) @@ -186,6 +204,11 @@ def run_server_process(room_id, ponyconfig: dict, static_server_data: dict, 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 d9600d2d16..5cf503be1b 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -26,7 +26,7 @@ def download_patch(room_id, patch_id): with zipfile.ZipFile(filelike, "a") as zf: with zf.open("archipelago.json", "r") as f: manifest = json.load(f) - manifest["server"] = f"{app.config['PATCH_TARGET']}:{last_port}" if last_port else None + manifest["server"] = f"{app.config['HOST_ADDRESS']}:{last_port}" if last_port else None with zipfile.ZipFile(new_file, "w") as new_zip: for file in zf.infolist(): if file.filename == "archipelago.json": @@ -64,7 +64,7 @@ def download_slot_file(room_id, player_id: int): if slot_data.game == "Minecraft": from worlds.minecraft import mc_update_output fname = f"AP_{app.jinja_env.filters['suuid'](room_id)}_P{slot_data.player_id}_{slot_data.player_name}.apmc" - data = mc_update_output(slot_data.data, server=app.config['PATCH_TARGET'], port=room.last_port) + data = mc_update_output(slot_data.data, server=app.config['HOST_ADDRESS'], port=room.last_port) return send_file(io.BytesIO(data), as_attachment=True, download_name=fname) elif slot_data.game == "Factorio": with zipfile.ZipFile(io.BytesIO(slot_data.data)) as zf: @@ -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/models.py b/WebHostLib/models.py index dbd03b166c..eba5c4eb4d 100644 --- a/WebHostLib/models.py +++ b/WebHostLib/models.py @@ -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 8f366d4fbf..a4d7ccc17c 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -11,7 +11,7 @@ from Utils import __version__, local_path 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(): @@ -88,7 +88,7 @@ def create(): 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 +98,15 @@ def create(): } 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,21 +126,21 @@ def create(): 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), } - 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), } - elif issubclass(option, Options.OptionList) or issubclass(option, Options.OptionSet): + elif issubclass(option, Options.VerifyKeys): if option.valid_keys: game_options[option_name] = { "type": "custom-list", @@ -160,6 +160,14 @@ def create(): 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 2397bf91b4..d5c1719863 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -1,7 +1,7 @@ -flask>=2.2.2 +flask>=2.2.3 pony>=0.7.16 waitress>=2.1.2 -Flask-Caching>=2.0.1 +Flask-Caching>=2.0.2 Flask-Compress>=1.13 -Flask-Limiter>=2.8.1 -bokeh>=3.0.2 +Flask-Limiter>=3.3.0 +bokeh>=3.1.0 diff --git a/WebHostLib/static/assets/baseHeader.js b/WebHostLib/static/assets/baseHeader.js new file mode 100644 index 0000000000..7c9be77840 --- /dev/null +++ b/WebHostLib/static/assets/baseHeader.js @@ -0,0 +1,40 @@ +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'; + } + + mobileMenu.style.display = 'none'; + }); + + 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/checksfinderTracker.js b/WebHostLib/static/assets/checksfinderTracker.js new file mode 100644 index 0000000000..61cf1e1559 --- /dev/null +++ b/WebHostLib/static/assets/checksfinderTracker.js @@ -0,0 +1,49 @@ +window.addEventListener('load', () => { + // Reload tracker every 60 seconds + const url = window.location; + setInterval(() => { + const ajax = new XMLHttpRequest(); + ajax.onreadystatechange = () => { + if (ajax.readyState !== 4) { return; } + + // Create a fake DOM using the returned HTML + const domParser = new DOMParser(); + const fakeDOM = domParser.parseFromString(ajax.responseText, 'text/html'); + + // Update item tracker + document.getElementById('inventory-table').innerHTML = fakeDOM.getElementById('inventory-table').innerHTML; + // Update only counters in the location-table + let counters = document.getElementsByClassName('counter'); + const fakeCounters = fakeDOM.getElementsByClassName('counter'); + for (let i = 0; i < counters.length; i++) { + counters[i].innerHTML = fakeCounters[i].innerHTML; + } + }; + ajax.open('GET', url); + ajax.send(); +}, 60000) + + // Collapsible advancement sections + const categories = document.getElementsByClassName("location-category"); + for (let i = 0; i < categories.length; i++) { + let hide_id = categories[i].id.split('-')[0]; + if (hide_id == 'Total') { + continue; + } + categories[i].addEventListener('click', function() { + // Toggle the advancement list + document.getElementById(hide_id).classList.toggle("hide"); + // Change text of the header + const tab_header = document.getElementById(hide_id+'-header').children[0]; + const orig_text = tab_header.innerHTML; + let new_text; + if (orig_text.includes("âŧ")) { + new_text = orig_text.replace("âŧ", "â˛"); + } + else { + new_text = orig_text.replace("â˛", "âŧ"); + } + tab_header.innerHTML = new_text; + }); + } +}); diff --git a/WebHostLib/static/assets/lttpMultiTracker.js b/WebHostLib/static/assets/lttpMultiTracker.js new file mode 100644 index 0000000000..e90331028d --- /dev/null +++ b/WebHostLib/static/assets/lttpMultiTracker.js @@ -0,0 +1,6 @@ +window.addEventListener('load', () => { + $(".table-wrapper").scrollsync({ + y_sync: true, + x_sync: true + }); +}); diff --git a/WebHostLib/static/assets/tracker.js b/WebHostLib/static/assets/trackerCommon.js similarity index 97% rename from WebHostLib/static/assets/tracker.js rename to WebHostLib/static/assets/trackerCommon.js index 23e7f979a5..c08590cbf7 100644 --- a/WebHostLib/static/assets/tracker.js +++ b/WebHostLib/static/assets/trackerCommon.js @@ -1,5 +1,7 @@ const adjustTableHeight = () => { const tablesContainer = document.getElementById('tables-container'); + if (!tablesContainer) + return; const upperDistance = tablesContainer.getBoundingClientRect().top; const containerHeight = window.innerHeight - upperDistance; @@ -108,7 +110,7 @@ window.addEventListener('load', () => { const update = () => { const target = $("
"); console.log("Updating Tracker..."); - target.load("/tracker/" + tracker, function (response, status) { + target.load(location.href, function (response, status) { if (status === "success") { target.find(".table").each(function (i, new_table) { const new_trs = $(new_table).find("tbody>tr"); @@ -135,10 +137,5 @@ window.addEventListener('load', () => { tables.draw(); }); - $(".table-wrapper").scrollsync({ - y_sync: true, - x_sync: true - }); - adjustTableHeight(); }); diff --git a/WebHostLib/static/assets/weighted-settings.js b/WebHostLib/static/assets/weighted-settings.js index da4d60fcad..e471e0837a 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; @@ -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'); diff --git a/WebHostLib/static/static/button-images/hamburger-menu-icon.png b/WebHostLib/static/static/button-images/hamburger-menu-icon.png new file mode 100644 index 0000000000..f1c9631635 Binary files /dev/null and b/WebHostLib/static/static/button-images/hamburger-menu-icon.png differ diff --git a/WebHostLib/static/static/button-images/popover.png b/WebHostLib/static/static/button-images/popover.png new file mode 100644 index 0000000000..cbc8634104 Binary files /dev/null and b/WebHostLib/static/static/button-images/popover.png differ diff --git a/WebHostLib/static/styles/checksfinderTracker.css b/WebHostLib/static/styles/checksfinderTracker.css new file mode 100644 index 0000000000..e0cde61241 --- /dev/null +++ b/WebHostLib/static/styles/checksfinderTracker.css @@ -0,0 +1,30 @@ +#player-tracker-wrapper{ + margin: 0; +} + +#inventory-table{ + padding: 8px 10px 2px 6px; + background-color: #42b149; + border-radius: 4px; + border: 2px solid black; +} + +#inventory-table tr.column-headers td { + font-size: 1rem; + padding: 0 5rem 0 0; +} + +#inventory-table td{ + padding: 0 0.5rem 0.5rem; + font-family: LexendDeca-Light, monospace; + font-size: 2.5rem; + color: #ffffff; +} + +#inventory-table td img{ + vertical-align: middle; +} + +.hide { + display: none; +} diff --git a/WebHostLib/static/styles/islandFooter.css b/WebHostLib/static/styles/islandFooter.css index 96611f4eac..7d5344a9bb 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 ea142942e1..202c43badd 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/themes/base.css b/WebHostLib/static/styles/themes/base.css index fca65a51c1..fdfe56af20 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 a, #base-header-mobile-menu a, #base-header-popover-text{ color: #2f6b83; text-decoration: none; cursor: pointer; @@ -51,3 +53,126 @@ html{ font-family: LondrinaSolid-Light, sans-serif; text-transform: uppercase; } + +#base-header-right-mobile{ + display: none; + margin-top: 2rem; + margin-right: 1rem; +} + +#base-header-mobile-menu{ + display: none; + flex-direction: column; + background-color: #ffffff; + text-align: center; + overflow-y: auto; + z-index: 10000; + width: 100vw; + border-bottom-left-radius: 20px; + border-bottom-right-radius: 20px; + + position: absolute; + top: 7rem; + right: 0; +} + +#base-header-mobile-menu a{ + 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; +} + +#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; + } + + #base-header{ + height: 200px; + background-size: auto 200px; + } + + #base-header #site-title img{ + height: calc(38px * 2); + margin-top: 30px; + margin-left: 20px; + } +} diff --git a/WebHostLib/static/styles/tracker.css b/WebHostLib/static/styles/tracker.css index e203d9e97d..0e00553c72 100644 --- a/WebHostLib/static/styles/tracker.css +++ b/WebHostLib/static/styles/tracker.css @@ -119,6 +119,33 @@ img.alttp-sprite { background-color: #d3c97d; } +#tracker-navigation { + display: inline-flex; + background-color: #b0a77d; + margin: 0.5rem; + border-radius: 4px; +} + +.tracker-navigation-button { + display: block; + margin: 4px; + padding-left: 12px; + padding-right: 12px; + border-radius: 4px; + text-align: center; + font-size: 14px; + color: #000; + font-weight: lighter; +} + +.tracker-navigation-button:hover { + background-color: #e2eabb !important; +} + +.tracker-navigation-button.selected { + background-color: rgb(220, 226, 189); +} + @media all and (max-width: 1700px) { table.dataTable thead th.upper-row{ position: -webkit-sticky; diff --git a/WebHostLib/static/styles/weighted-settings.css b/WebHostLib/static/styles/weighted-settings.css index 7639fa1c72..cc5231634e 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; +} + +#weighted-settings .hints-wrapper, #weighted-settings .locations-wrapper{ + width: calc(50% - 0.5rem); 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-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/checksfinderTracker.html b/WebHostLib/templates/checksfinderTracker.html new file mode 100644 index 0000000000..5df77f5e74 --- /dev/null +++ b/WebHostLib/templates/checksfinderTracker.html @@ -0,0 +1,35 @@ + + + +| Checks Available: | +Map Bombs: | +||
| {{ checks_available }} | +{{ bombs_display }}/20 | +||
| Map Width: | +Map Height: | +||
| {{ width_display }}/10 | +{{ height_display }}/10 | +||
| # | +Name | +Game | + {% block custom_table_headers %} + {# implement this block in game-specific multi trackers #} + {% endblock %} +Checks | +% | +Status | +Last Activity |
+ |
|---|---|---|---|---|---|---|---|
| {{ loop.index }} | +{{ player_names[(team, loop.index)]|e }} | +{{ games[player] }} | + {% block custom_table_row scoped %} + {# implement this block in game-specific multi trackers #} + {% endblock %} +{{ checks["Total"] }}/{{ checks_in_area[player]["Total"] }} | +{{ percent_total_checks_done[team][player] }} | +{{ {0: "Disconnected", 5: "Connected", 10: "Ready", 20: "Playing", + 30: "Goal Completed"}.get(states[team, player], "Unknown State") }} | + {%- if activity_timers[team, player] -%} +{{ activity_timers[team, player].total_seconds() }} | + {%- else -%} +None | + {%- endif -%} +
| Finder | +Receiver | +Item | +Location | +Entrance | +Found | +
|---|---|---|---|---|---|
| {{ 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 %} | +
All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed.
+The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations.
+Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5.
+ +The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items.
+To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right.
+ +Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else.
+ +In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando).
+If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out.
+The flying rooster is (optionally) available as an item.
+You can access the Bird Key cave item with the L2 Power Bracelet.
+Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool.
+Your inventory has been increased by four, to accommodate these items now coexisting with eachother.
+ +The ghost mini-quest after D4 never shows up, his seashell reward is always available.
+The walrus is moved a bit, so that you can access the desert without taking Marin on a date.
+ +Depending on your settings, you can only steal after you find the sword, always, or never.
+Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers.
+Killing enemies with bombs is in normal logic. You can switch to casual logic if you do not want this.
+D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet.
+ +The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang.
+The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.
diff --git a/worlds/ladx/docs/setup_en.md b/worlds/ladx/docs/setup_en.md new file mode 100644 index 0000000000..2fbd67dafa --- /dev/null +++ b/worlds/ladx/docs/setup_en.md @@ -0,0 +1,100 @@ +# Links Awakening DX Multiworld Setup Guide + +## Required Software + +- [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases). Make sure to check the box for `Links Awakening DX` +- Software capable of loading and playing GBC ROM files + - [RetroArch](https://retroarch.com?page=platforms) 1.10.3 or newer. + - [BizHawk](https://tasvideos.org/BizHawk) 2.8 or newer. +- Your American 1.0 ROM file, probably named `Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc` + +## Installation Procedures + +1. Download and install LinksAwakeningClient from the link above, making sure to install the most recent version. + **The installer file is located in the assets section at the bottom of the version information**. + - During setup, you will be asked to locate your base ROM file. This is your Links Awakening DX ROM file. + +2. You should assign your emulator as your default program for launching ROM + files. + 1. Extract your emulator's folder to your Desktop, or somewhere you will remember. + 2. Right-click on a ROM file and select **Open with...** + 3. Check the box next to **Always use this app to open .gbc files** + 4. Scroll to the bottom of the list and click the grey text **Look for another App on this PC** + 5. Browse for your emulator's `.exe` file and click **Open**. This file should be located inside the folder you + extracted in step one. + +## Create a Config (.yaml) File + +### What is a config file and why do I need one? + +Your config file contains a set of configuration options which provide the generator with information about how it +should generate your game. Each player of a multiworld will provide their own config file. This setup allows each player +to enjoy an experience customized for their taste, and different players in the same multiworld can all have different +options. + +### Where do I get a config file? + +The [Player Settings](/games/Links%20Awakening%20DX/player-settings) page on the website allows you to configure +your personal settings and export a config file from them. + +### Verifying your config file + +If you would like to validate your config file to make sure it works, you may do so on the +[YAML Validator](/mysterycheck) page. + +## Generating a Single-Player Game + +1. Navigate to the [Player Settings](/games/Links%20Awakening%20DX/player-settings) page, configure your options, + and click the "Generate Game" button. +2. You will be presented with a "Seed Info" page. +3. Click the "Create New Room" link. +4. You will be presented with a server page, from which you can download your patch file. +5. Double-click on your patch file, and Links Awakening DX will launch automatically, and create your ROM from the patch file. +6. Since this is a single-player game, you will no longer need the client, so feel free to close it. + +## Joining a MultiWorld Game + +### Obtain your patch file and create your ROM + +When you join a multiworld game, you will be asked to provide your config file to whoever is hosting. Once that is done, +the host will provide you with either a link to download your patch file, or with a zip file containing everyone's patch +files. Your patch file should have a `.apladx` extension. + +Put your patch file on your desktop or somewhere convenient, and double click it. This should automatically launch the +client, and will also create your ROM in the same place as your patch file. + +### Connect to the client + +#### RetroArch 1.10.3 or newer + +You only have to do these steps once. Note, RetroArch 1.9.x will not work as it is older than 1.10.3. + +1. Enter the RetroArch main menu screen. +2. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. +3. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default + Network Command Port at 55355. + + +4. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and select "Nintendo - Gameboy / Color (SameBoy)". + +#### BizHawk 2.8 or newer (older versions untested) + +1. With the ROM loaded, click on "Tools" --> "Lua Console" +2. In the new window, click on "Script" --> "Open Script..." +3. Navigate to the folder Archipelago is installed in, and choose data/lua/connector_ladx_bizhawk.lua +4. Keep the Lua Console open during gameplay (minimizing it is fine!) + +### Connect to the Archipelago Server + +The patch file which launched your client should have automatically connected you to the AP Server. There are a few +reasons this may not happen, however, including if the game is hosted on the website but was generated elsewhere. If the +client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it +into the "Server" input field then press enter. + +The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected". + +### Play the game + +When the client shows both Retroarch and Server as connected, you're ready to begin playing. Congratulations on +successfully joining a multiworld game! You can execute various commands in your client. For more information regarding +these commands you can use `/help` for local client commands and `!help` for server commands. diff --git a/worlds/ladx/test/TestDungeonLogic.py b/worlds/ladx/test/TestDungeonLogic.py new file mode 100644 index 0000000000..b9b9672b9b --- /dev/null +++ b/worlds/ladx/test/TestDungeonLogic.py @@ -0,0 +1,35 @@ +from . import LADXTestBase + +from ..Items import ItemName + +class TestD6(LADXTestBase): + # Force keys into pool for testing + options = { + "shuffle_small_keys": "any_world" + } + + def test_keylogic(self): + keys = self.get_items_by_name(ItemName.KEY6) + self.collect_by_name([ItemName.FACE_KEY, ItemName.HOOKSHOT, ItemName.POWER_BRACELET, ItemName.BOMB, ItemName.FEATHER, ItemName.FLIPPERS]) + # Can reach an un-keylocked item in the dungeon + self.assertTrue(self.can_reach_location("L2 Bracelet Chest (Face Shrine)")) + + # For each location, add a key and check that the right thing unlocks + location_1 = "Tile Room Key (Face Shrine)" + location_2 = "Top Right Horse Heads Chest (Face Shrine)" + location_3 = "Pot Locked Chest (Face Shrine)" + self.assertFalse(self.can_reach_location(location_1)) + self.assertFalse(self.can_reach_location(location_2)) + self.assertFalse(self.can_reach_location(location_3)) + self.collect(keys[0]) + self.assertTrue(self.can_reach_location(location_1)) + self.assertFalse(self.can_reach_location(location_2)) + self.assertFalse(self.can_reach_location(location_3)) + self.collect(keys[1]) + self.assertTrue(self.can_reach_location(location_1)) + self.assertTrue(self.can_reach_location(location_2)) + self.assertFalse(self.can_reach_location(location_3)) + self.collect(keys[2]) + self.assertTrue(self.can_reach_location(location_1)) + self.assertTrue(self.can_reach_location(location_2)) + self.assertTrue(self.can_reach_location(location_3)) diff --git a/worlds/ladx/test/__init__.py b/worlds/ladx/test/__init__.py new file mode 100644 index 0000000000..0e616ac557 --- /dev/null +++ b/worlds/ladx/test/__init__.py @@ -0,0 +1,4 @@ +from test.TestBase import WorldTestBase +from ..Common import LINKS_AWAKENING +class LADXTestBase(WorldTestBase): + game = LINKS_AWAKENING diff --git a/worlds/lufia2ac/Client.py b/worlds/lufia2ac/Client.py index 002b257dbd..58ee7f87f9 100644 --- a/worlds/lufia2ac/Client.py +++ b/worlds/lufia2ac/Client.py @@ -2,10 +2,11 @@ import logging import time import typing from logging import Logger -from typing import Dict +from typing import Optional from NetUtils import ClientStatus, NetworkItem from worlds.AutoSNIClient import SNIClient +from .Enemies import enemy_id_to_name from .Items import start_id as items_start_id from .Locations import start_id as locations_start_id @@ -24,233 +25,6 @@ L2AC_DEATH_ADDR: int = SRAM_START + 0x203D L2AC_TX_ADDR: int = SRAM_START + 0x2040 L2AC_RX_ADDR: int = SRAM_START + 0x2800 -enemy_names: Dict[int, str] = { - 0x00: "a Goblin", - 0x01: "an Armor goblin", - 0x02: "a Regal Goblin", - 0x03: "a Goblin Mage", - 0x04: "a Troll", - 0x05: "an Ork", - 0x06: "a Fighter ork", - 0x07: "an Ork Mage", - 0x08: "a Lizardman", - 0x09: "a Skull Lizard", - 0x0A: "an Armour Dait", - 0x0B: "a Dragonian", - 0x0C: "a Cyclops", - 0x0D: "a Mega Cyclops", - 0x0E: "a Flame genie", - 0x0F: "a Well Genie", - 0x10: "a Wind Genie", - 0x11: "an Earth Genie", - 0x12: "a Cobalt", - 0x13: "a Merman", - 0x14: "an Aqualoi", - 0x15: "an Imp", - 0x16: "a Fiend", - 0x17: "an Archfiend", - 0x18: "a Hound", - 0x19: "a Doben", - 0x1A: "a Winger", - 0x1B: "a Serfaco", - 0x1C: "a Pug", - 0x1D: "a Salamander", - 0x1E: "a Brinz Lizard", - 0x1F: "a Seahorse", - 0x20: "a Seirein", - 0x21: "an Earth Viper", - 0x22: "a Gnome", - 0x23: "a Wispy", - 0x24: "a Thunderbeast", - 0x25: "a Lunar bear", - 0x26: "a Shadowfly", - 0x27: "a Shadow", - 0x28: "a Lion", - 0x29: "a Sphinx", - 0x2A: "a Mad horse", - 0x2B: "an Armor horse", - 0x2C: "a Buffalo", - 0x2D: "a Bruse", - 0x2E: "a Bat", - 0x2F: "a Big Bat", - 0x30: "a Red Bat", - 0x31: "an Eagle", - 0x32: "a Hawk", - 0x33: "a Crow", - 0x34: "a Baby Frog", - 0x35: "a King Frog", - 0x36: "a Lizard", - 0x37: "a Newt", - 0x38: "a Needle Lizard", - 0x39: "a Poison Lizard", - 0x3A: "a Medusa", - 0x3B: "a Ramia", - 0x3C: "a Basilisk", - 0x3D: "a Cokatoris", - 0x3E: "a Scorpion", - 0x3F: "an Antares", - 0x40: "a Small Crab", - 0x41: "a Big Crab", - 0x42: "a Red Lobster", - 0x43: "a Spider", - 0x44: "a Web Spider", - 0x45: "a Beetle", - 0x46: "a Poison Beetle", - 0x47: "a Mosquito", - 0x48: "a Coridras", - 0x49: "a Spinner", - 0x4A: "a Tartona", - 0x4B: "an Armour Nail", - 0x4C: "a Moth", - 0x4D: "a Mega Moth", - 0x4E: "a Big Bee", - 0x4F: "a Dark Fly", - 0x50: "a Stinger", - 0x51: "an Armor Bee", - 0x52: "a Sentopez", - 0x53: "a Cancer", - 0x54: "a Garbost", - 0x55: "a Bolt Fish", - 0x56: "a Moray", - 0x57: "a She Viper", - 0x58: "an Angler fish", - 0x59: "a Unicorn", - 0x5A: "an Evil Shell", - 0x5B: "a Drill Shell", - 0x5C: "a Snell", - 0x5D: "an Ammonite", - 0x5E: "an Evil Fish", - 0x5F: "a Squid", - 0x60: "a Kraken", - 0x61: "a Killer Whale", - 0x62: "a White Whale", - 0x63: "a Grianos", - 0x64: "a Behemoth", - 0x65: "a Perch", - 0x66: "a Current", - 0x67: "a Vampire Rose", - 0x68: "a Desert Rose", - 0x69: "a Venus Fly", - 0x6A: "a Moray Vine", - 0x6B: "a Torrent", - 0x6C: "a Mad Ent", - 0x6D: "a Crow Kelp", - 0x6E: "a Red Plant", - 0x6F: "La Fleshia", - 0x70: "a Wheel Eel", - 0x71: "a Skeleton", - 0x72: "a Ghoul", - 0x73: "a Zombie", - 0x74: "a Specter", - 0x75: "a Dark Spirit", - 0x76: "a Snatcher", - 0x77: "a Jurahan", - 0x78: "a Demise", - 0x79: "a Leech", - 0x7A: "a Necromancer", - 0x7B: "a Hade Chariot", - 0x7C: "a Hades", - 0x7D: "a Dark Skull", - 0x7E: "a Hades Skull", - 0x7F: "a Mummy", - 0x80: "a Vampire", - 0x81: "a Nosferato", - 0x82: "a Ghost Ship", - 0x83: "a Deadly Sword", - 0x84: "a Deadly Armor", - 0x85: "a T Rex", - 0x86: "a Brokion", - 0x87: "a Pumpkin Head", - 0x88: "a Mad Head", - 0x89: "a Snow Gas", - 0x8A: "a Great Coca", - 0x8B: "a Gargoyle", - 0x8C: "a Rogue Shape", - 0x8D: "a Bone Gorem", - 0x8E: "a Nuborg", - 0x8F: "a Wood Gorem", - 0x90: "a Mad Gorem", - 0x91: "a Green Clay", - 0x92: "a Sand Gorem", - 0x93: "a Magma Gorem", - 0x94: "an Iron Gorem", - 0x95: "a Gold Gorem", - 0x96: "a Hidora", - 0x97: "a Sea Hidora", - 0x98: "a High Hidora", - 0x99: "a King Hidora", - 0x9A: "an Orky", - 0x9B: "a Waiban", - 0x9C: "a White Dragon", - 0x9D: "a Red Dragon", - 0x9E: "a Blue Dragon", - 0x9F: "a Green Dragon", - 0xA0: "a Black Dragon", - 0xA1: "a Copper Dragon", - 0xA2: "a Silver Dragon", - 0xA3: "a Gold Dragon", - 0xA4: "a Red Jelly", - 0xA5: "a Blue Jelly", - 0xA6: "a Bili Jelly", - 0xA7: "a Red Core", - 0xA8: "a Blue Core", - 0xA9: "a Green Core", - 0xAA: "a No Core", - 0xAB: "a Mimic", - 0xAC: "a Blue Mimic", - 0xAD: "an Ice Roge", - 0xAE: "a Mushroom", - 0xAF: "a Big Mushr'm", - 0xB0: "a Minataurus", - 0xB1: "a Gorgon", - 0xB2: "a Ninja", - 0xB3: "an Asashin", - 0xB4: "a Samurai", - 0xB5: "a Dark Warrior", - 0xB6: "an Ochi Warrior", - 0xB7: "a Sly Fox", - 0xB8: "a Tengu", - 0xB9: "a Warm Eye", - 0xBA: "a Wizard", - 0xBB: "a Dark Sum'ner", - 0xBC: "the Big Catfish", - 0xBD: "a Follower", - 0xBE: "the Tarantula", - 0xBF: "Pierre", - 0xC0: "Daniele", - 0xC1: "the Venge Ghost", - 0xC2: "the Fire Dragon", - 0xC3: "the Tank", - 0xC4: "Idura", - 0xC5: "Camu", - 0xC6: "Gades", - 0xC7: "Amon", - 0xC8: "Erim", - 0xC9: "Daos", - 0xCA: "a Lizard Man", - 0xCB: "a Goblin", - 0xCC: "a Skeleton", - 0xCD: "a Regal Goblin", - 0xCE: "a Goblin", - 0xCF: "a Goblin Mage", - 0xD0: "a Slave", - 0xD1: "a Follower", - 0xD2: "a Groupie", - 0xD3: "the Egg Dragon", - 0xD4: "a Mummy", - 0xD5: "a Troll", - 0xD6: "Gades", - 0xD7: "Idura", - 0xD8: "a Lion", - 0xD9: "the Rogue Flower", - 0xDA: "a Gargoyle", - 0xDB: "a Ghost Ship", - 0xDC: "Idura", - 0xDD: "a Soldier", - 0xDE: "Gades", - 0xDF: "the Master", -} - class L2ACSNIClient(SNIClient): game: str = "Lufia II Ancient Cave" @@ -258,7 +32,7 @@ class L2ACSNIClient(SNIClient): async def validate_rom(self, ctx: SNIContext) -> bool: from SNIClient import snes_read - rom_name: bytes = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) + rom_name: Optional[bytes] = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) if rom_name is None or rom_name[:4] != b"L2AC": return False @@ -272,7 +46,7 @@ class L2ACSNIClient(SNIClient): async def game_watcher(self, ctx: SNIContext) -> None: from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - rom: bytes = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) + rom: Optional[bytes] = await snes_read(ctx, L2AC_ROMNAME_START, 0x15) if rom != ctx.rom: ctx.rom = None return @@ -281,30 +55,30 @@ class L2ACSNIClient(SNIClient): # not successfully connected to a multiworld server, cannot process the game sending items return - signature: bytes = await snes_read(ctx, L2AC_SIGN_ADDR, 16) + signature: Optional[bytes] = await snes_read(ctx, L2AC_SIGN_ADDR, 16) if signature != b"ArchipelagoLufia": return # Goal if not ctx.finished_game: - goal_data: bytes = await snes_read(ctx, L2AC_GOAL_ADDR, 10) + goal_data: Optional[bytes] = await snes_read(ctx, L2AC_GOAL_ADDR, 10) if goal_data is not None and goal_data[goal_data[0]] == 0x01: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) ctx.finished_game = True # DeathLink TX - death_data: bytes = await snes_read(ctx, L2AC_DEATH_ADDR, 3) + death_data: Optional[bytes] = await snes_read(ctx, L2AC_DEATH_ADDR, 3) if death_data is not None: await ctx.update_death_link(bool(death_data[0])) if death_data[1] != 0x00: snes_buffered_write(ctx, L2AC_DEATH_ADDR + 1, b"\x00") if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time(): player_name: str = ctx.player_names.get(ctx.slot, str(ctx.slot)) - enemy_name: str = enemy_names.get(death_data[1] - 1, hex(death_data[1] - 1)) + enemy_name: str = enemy_id_to_name.get(death_data[1] - 1, hex(death_data[1] - 1)) await ctx.send_death(f"{player_name} was totally defeated by {enemy_name}.") # TX - tx_data: bytes = await snes_read(ctx, L2AC_TX_ADDR, 8) + tx_data: Optional[bytes] = await snes_read(ctx, L2AC_TX_ADDR, 8) if tx_data is not None: snes_items_sent = int.from_bytes(tx_data[:2], "little") client_items_sent = int.from_bytes(tx_data[2:4], "little") @@ -316,7 +90,7 @@ class L2ACSNIClient(SNIClient): client_items_sent += 1 ctx.locations_checked.add(location_id) - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + await ctx.send_msgs([{"cmd": "LocationChecks", "locations": [location_id]}]) snes_logger.info("New Check: %s (%d/%d)" % ( location, @@ -329,7 +103,7 @@ class L2ACSNIClient(SNIClient): snes_buffered_write(ctx, L2AC_TX_ADDR + 4, ap_items_found.to_bytes(2, "little")) # RX - rx_data: bytes = await snes_read(ctx, L2AC_RX_ADDR, 4) + rx_data: Optional[bytes] = await snes_read(ctx, L2AC_RX_ADDR, 4) if rx_data is not None: snes_items_received = int.from_bytes(rx_data[:2], "little") @@ -343,7 +117,7 @@ class L2ACSNIClient(SNIClient): ctx.player_names[item.player], ctx.location_names[item.location], snes_items_received, len(ctx.items_received))) - snes_buffered_write(ctx, L2AC_RX_ADDR + 2 * (snes_items_received + 1), item_code.to_bytes(2, 'little')) + snes_buffered_write(ctx, L2AC_RX_ADDR + 2 * (snes_items_received + 1), item_code.to_bytes(2, "little")) snes_buffered_write(ctx, L2AC_RX_ADDR, snes_items_received.to_bytes(2, "little")) await snes_flush_writes(ctx) @@ -352,7 +126,7 @@ class L2ACSNIClient(SNIClient): from SNIClient import DeathState, snes_buffered_write, snes_flush_writes # DeathLink RX - if "DeathLink" in ctx.tags and ctx.last_death_link + 1 < time.time(): + if "DeathLink" in ctx.tags: snes_buffered_write(ctx, L2AC_DEATH_ADDR + 2, b"\x01") else: snes_buffered_write(ctx, L2AC_DEATH_ADDR + 2, b"\x00") diff --git a/worlds/lufia2ac/Enemies.py b/worlds/lufia2ac/Enemies.py new file mode 100644 index 0000000000..5d82966c0c --- /dev/null +++ b/worlds/lufia2ac/Enemies.py @@ -0,0 +1,382 @@ +from typing import Dict + +enemy_id_to_name: Dict[int, str] = { + 0x00: "a Goblin", + 0x01: "an Armor goblin", + 0x02: "a Regal Goblin", + 0x03: "a Goblin Mage", + 0x04: "a Troll", + 0x05: "an Ork", + 0x06: "a Fighter ork", + 0x07: "an Ork Mage", + 0x08: "a Lizardman", + 0x09: "a Skull Lizard", + 0x0A: "an Armour Dait", + 0x0B: "a Dragonian", + 0x0C: "a Cyclops", + 0x0D: "a Mega Cyclops", + 0x0E: "a Flame genie", + 0x0F: "a Well Genie", + 0x10: "a Wind Genie", + 0x11: "an Earth Genie", + 0x12: "a Cobalt", + 0x13: "a Merman", + 0x14: "an Aqualoi", + 0x15: "an Imp", + 0x16: "a Fiend", + 0x17: "an Archfiend", + 0x18: "a Hound", + 0x19: "a Doben", + 0x1A: "a Winger", + 0x1B: "a Serfaco", + 0x1C: "a Pug", + 0x1D: "a Salamander", + 0x1E: "a Brinz Lizard", + 0x1F: "a Seahorse", + 0x20: "a Seirein", + 0x21: "an Earth Viper", + 0x22: "a Gnome", + 0x23: "a Wispy", + 0x24: "a Thunderbeast", + 0x25: "a Lunar bear", + 0x26: "a Shadowfly", + 0x27: "a Shadow", + 0x28: "a Lion", + 0x29: "a Sphinx", + 0x2A: "a Mad horse", + 0x2B: "an Armor horse", + 0x2C: "a Buffalo", + 0x2D: "a Bruse", + 0x2E: "a Bat", + 0x2F: "a Big Bat", + 0x30: "a Red Bat", + 0x31: "an Eagle", + 0x32: "a Hawk", + 0x33: "a Crow", + 0x34: "a Baby Frog", + 0x35: "a King Frog", + 0x36: "a Lizard", + 0x37: "a Newt", + 0x38: "a Needle Lizard", + 0x39: "a Poison Lizard", + 0x3A: "a Medusa", + 0x3B: "a Ramia", + 0x3C: "a Basilisk", + 0x3D: "a Cokatoris", + 0x3E: "a Scorpion", + 0x3F: "an Antares", + 0x40: "a Small Crab", + 0x41: "a Big Crab", + 0x42: "a Red Lobster", + 0x43: "a Spider", + 0x44: "a Web Spider", + 0x45: "a Beetle", + 0x46: "a Poison Beetle", + 0x47: "a Mosquito", + 0x48: "a Coridras", + 0x49: "a Spinner", + 0x4A: "a Tartona", + 0x4B: "an Armour Nail", + 0x4C: "a Moth", + 0x4D: "a Mega Moth", + 0x4E: "a Big Bee", + 0x4F: "a Dark Fly", + 0x50: "a Stinger", + 0x51: "an Armor Bee", + 0x52: "a Sentopez", + 0x53: "a Cancer", + 0x54: "a Garbost", + 0x55: "a Bolt Fish", + 0x56: "a Moray", + 0x57: "a She Viper", + 0x58: "an Angler fish", + 0x59: "a Unicorn", + 0x5A: "an Evil Shell", + 0x5B: "a Drill Shell", + 0x5C: "a Snell", + 0x5D: "an Ammonite", + 0x5E: "an Evil Fish", + 0x5F: "a Squid", + 0x60: "a Kraken", + 0x61: "a Killer Whale", + 0x62: "a White Whale", + 0x63: "a Grianos", + 0x64: "a Behemoth", + 0x65: "a Perch", + 0x66: "a Current", + 0x67: "a Vampire Rose", + 0x68: "a Desert Rose", + 0x69: "a Venus Fly", + 0x6A: "a Moray Vine", + 0x6B: "a Torrent", + 0x6C: "a Mad Ent", + 0x6D: "a Crow Kelp", + 0x6E: "a Red Plant", + 0x6F: "La Fleshia", + 0x70: "a Wheel Eel", + 0x71: "a Skeleton", + 0x72: "a Ghoul", + 0x73: "a Zombie", + 0x74: "a Specter", + 0x75: "a Dark Spirit", + 0x76: "a Snatcher", + 0x77: "a Jurahan", + 0x78: "a Demise", + 0x79: "a Leech", + 0x7A: "a Necromancer", + 0x7B: "a Hade Chariot", + 0x7C: "a Hades", + 0x7D: "a Dark Skull", + 0x7E: "a Hades Skull", + 0x7F: "a Mummy", + 0x80: "a Vampire", + 0x81: "a Nosferato", + 0x82: "a Ghost Ship", + 0x83: "a Deadly Sword", + 0x84: "a Deadly Armor", + 0x85: "a T Rex", + 0x86: "a Brokion", + 0x87: "a Pumpkin Head", + 0x88: "a Mad Head", + 0x89: "a Snow Gas", + 0x8A: "a Great Coca", + 0x8B: "a Gargoyle", + 0x8C: "a Rogue Shape", + 0x8D: "a Bone Gorem", + 0x8E: "a Nuborg", + 0x8F: "a Wood Gorem", + 0x90: "a Mad Gorem", + 0x91: "a Green Clay", + 0x92: "a Sand Gorem", + 0x93: "a Magma Gorem", + 0x94: "an Iron Gorem", + 0x95: "a Gold Gorem", + 0x96: "a Hidora", + 0x97: "a Sea Hidora", + 0x98: "a High Hidora", + 0x99: "a King Hidora", + 0x9A: "an Orky", + 0x9B: "a Waiban", + 0x9C: "a White Dragon", + 0x9D: "a Red Dragon", + 0x9E: "a Blue Dragon", + 0x9F: "a Green Dragon", + 0xA0: "a Black Dragon", + 0xA1: "a Copper Dragon", + 0xA2: "a Silver Dragon", + 0xA3: "a Gold Dragon", + 0xA4: "a Red Jelly", + 0xA5: "a Blue Jelly", + 0xA6: "a Bili Jelly", + 0xA7: "a Red Core", + 0xA8: "a Blue Core", + 0xA9: "a Green Core", + 0xAA: "a No Core", + 0xAB: "a Mimic", + 0xAC: "a Blue Mimic", + 0xAD: "an Ice Roge", + 0xAE: "a Mushroom", + 0xAF: "a Big Mushr'm", + 0xB0: "a Minataurus", + 0xB1: "a Gorgon", + 0xB2: "a Ninja", + 0xB3: "an Asashin", + 0xB4: "a Samurai", + 0xB5: "a Dark Warrior", + 0xB6: "an Ochi Warrior", + 0xB7: "a Sly Fox", + 0xB8: "a Tengu", + 0xB9: "a Warm Eye", + 0xBA: "a Wizard", + 0xBB: "a Dark Sum'ner", + 0xBC: "the Big Catfish", + 0xBD: "a Follower", + 0xBE: "the Tarantula", + 0xBF: "Pierre", + 0xC0: "Daniele", + 0xC1: "the Venge Ghost", + 0xC2: "the Fire Dragon", + 0xC3: "the Tank", + 0xC4: "Idura", + 0xC5: "Camu", + 0xC6: "Gades", + 0xC7: "Amon", + 0xC8: "Erim", + 0xC9: "Daos", + 0xCA: "a Lizard Man", + 0xCB: "a Goblin", + 0xCC: "a Skeleton", + 0xCD: "a Regal Goblin", + 0xCE: "a Goblin", + 0xCF: "a Goblin Mage", + 0xD0: "a Slave", + 0xD1: "a Follower", + 0xD2: "a Groupie", + 0xD3: "the Egg Dragon", + 0xD4: "a Mummy", + 0xD5: "a Troll", + 0xD6: "Gades", + 0xD7: "Idura", + 0xD8: "a Lion", + 0xD9: "the Rogue Flower", + 0xDA: "a Gargoyle", + 0xDB: "a Ghost Ship", + 0xDC: "Idura", + 0xDD: "a Soldier", + 0xDE: "Gades", + 0xDF: "the Master", +} + +enemy_name_to_sprite: Dict[str, int] = { + "Ammonite": 0x81, + "Antares": 0x8B, + "Archfiend": 0xBD, + "Armor Bee": 0x98, + "Armor goblin": 0x9D, + "Armour Dait": 0xEF, + "Armour Nail": 0xEB, + "Asashin": 0x82, + "Baby Frog": 0xBE, + "Basilisk": 0xB6, + "Bat": 0x8F, + "Beetle": 0x86, + "Behemoth": 0xB6, + "Big Bat": 0x8F, + "Big Mushr'm": 0xDB, + "Bili Jelly": 0xDE, + "Black Dragon": 0xC0, + "Blue Core": 0x95, + "Blue Dragon": 0xC0, + "Blue Jelly": 0xDD, + "Blue Mimic": 0xF0, + "Bone Gorem": 0xA0, + "Brinz Lizard": 0xEE, + "Brokion": 0xD3, + "Buffalo": 0x84, + "Cobalt": 0xA6, + "Cokatoris": 0xD2, + "Copper Dragon": 0xC0, + "Coridras": 0xEA, + "Crow": 0xB4, + "Crow Kelp": 0xBC, + "Cyclops": 0xB9, + "Dark Skull": 0xB5, + "Dark Spirit": 0xE7, + "Dark Sum'ner": 0xAB, + "Dark Warrior": 0xB0, + "Deadly Armor": 0x99, + "Deadly Sword": 0x90, + "Demise": 0xAD, + "Desert Rose": 0x96, + "Dragonian": 0xEF, + "Drill Shell": 0x81, + "Eagle": 0xB4, + "Earth Genie": 0xB9, + "Earth Viper": 0xB3, + "Evil Fish": 0x80, + "Fiend": 0xBD, + "Fighter ork": 0xA5, + "Flame genie": 0xB9, + "Garbost": 0xD8, + "Ghost Ship": 0xD1, + "Ghoul": 0xE1, + "Gnome": 0xA5, + "Goblin": 0x9D, + "Gold Dragon": 0xC0, + "Gold Gorem": 0xE2, + "Gorgon": 0xAA, + "Great Coca": 0xD2, + "Green Core": 0x95, + "Green Dragon": 0xC0, + "Grianos": 0xB6, + "Hade Chariot": 0xBA, + "Hades": 0xBA, + "Hades Skull": 0xB5, + "Hidora": 0xBF, + "High Hidora": 0xBF, + "Hound": 0x8A, + "Ice Roge": 0xBD, + "Imp": 0xAC, + "Iron Gorem": 0xA1, + "Jurahan": 0xD5, + "Leech": 0xAD, + "Lion": 0xB7, + "Lizard": 0x83, + "Lizardman": 0x9E, + "Lunar bear": 0x9B, + "Mad Ent": 0x8E, + "Mad Gorem": 0xA3, + "Mad Head": 0xAF, + "Mad horse": 0x85, + "Magma Gorem": 0xE3, + "Medusa": 0x9C, + "Mega Moth": 0xDC, + "Mega Cyclops": 0xB9, + "Mimic": 0xA4, + "Minataurus": 0xAA, + "Moray Vine": 0x9A, + "Mosquito": 0x92, + "Moth": 0x93, + "Mummy": 0xA8, + "Mushroom": 0x8C, + "Necromancer": 0xAB, + "Needle Lizard": 0xD6, + "Newt": 0x83, + "Ninja": 0x82, + "No Core": 0x95, + "Nosferato": 0x9F, + "Nuborg": 0xE5, + "Ochi Warrior": 0xB0, + "Ork": 0xA5, + "Orky": 0xBF, + "Poison Beetle": 0xD7, + "Pug": 0x8D, + "Pumpkin Head": 0xAF, + "Ramia": 0xAE, + "Red Bat": 0x8F, + "Red Core": 0x95, + "Red Dragon": 0xC0, + "Red Jelly": 0x94, + "Red Plant": 0xEC, + "Regal Goblin": 0x9D, + "Rogue Shape": 0xC4, + "Salamander": 0xC1, + "Samurai": 0xB0, + "Sand Gorem": 0xE4, + "Scorpion": 0x8B, + "Sea Hidora": 0xBF, + "Seirein": 0xAE, + "Sentopez": 0xDA, + "Serfaco": 0xE8, + "Shadow": 0xB2, + "Silver Dragon": 0xC0, + "Skeleton": 0xA0, + "Skull Lizard": 0x9E, + "Sly Fox": 0xED, + "Snow Gas": 0xD2, + "Specter": 0xE7, + "Sphinx": 0xB7, + "Spider": 0xD9, + "Spinner": 0xE9, + "Squid": 0x80, + "Stinger": 0x98, + "T Rex": 0xD3, + "Tartona": 0xB8, + "Tengu": 0xD4, + "Thunderbeast": 0x9B, + "Troll": 0xA9, + "Vampire": 0x9F, + "Vampire Rose": 0x96, + "Venus Fly": 0xE0, + "Waiban": 0xC3, + "Warm Eye": 0x88, + "Well Genie": 0xB9, + "Wheel Eel": 0x97, + "White Dragon": 0xC3, + "Wind Genie": 0xB9, + "Winger": 0xB1, + "Wispy": 0x91, + "Wizard": 0xAB, + "Wood Gorem": 0xA2, + "Zombie": 0xA7, +} diff --git a/worlds/lufia2ac/Options.py b/worlds/lufia2ac/Options.py index 47b183c8b1..df71ef44a9 100644 --- a/worlds/lufia2ac/Options.py +++ b/worlds/lufia2ac/Options.py @@ -1,10 +1,16 @@ from __future__ import annotations import random -from itertools import chain, combinations -from typing import Any, cast, Dict, List, Optional, Set, Tuple +from dataclasses import dataclass +from itertools import accumulate, chain, combinations +from typing import Any, cast, Dict, Iterator, List, Mapping, Optional, Set, Tuple, Type, TYPE_CHECKING, Union -from Options import AssembleOptions, Choice, DeathLink, Option, Range, SpecialRange, TextChoice, Toggle +from Options import AssembleOptions, Choice, DeathLink, ItemDict, Range, SpecialRange, TextChoice, Toggle +from .Enemies import enemy_name_to_sprite + +if TYPE_CHECKING: + from BaseClasses import PlandoOptions + from worlds.AutoWorld import World class AssembleCustomizableChoices(AssembleOptions): @@ -37,6 +43,22 @@ class RandomGroupsChoice(Choice, metaclass=AssembleCustomizableChoices): return super().from_text(text) +class EnemyChoice(TextChoice): + _valid_sprites: Dict[str, int] = {enemy_name.lower(): sprite for enemy_name, sprite in enemy_name_to_sprite.items()} + + def verify(self, world: Type[World], player_name: str, plando_options: PlandoOptions) -> None: + if isinstance(self.value, int): + return + if str(self.value).lower() in self._valid_sprites: + return + raise ValueError(f"Could not find option '{self.value}' for '{self.__class__.__name__}', known options are:\n" + f"{', '.join(self.options)}, {', '.join(enemy_name_to_sprite)}.") + + @property + def sprite(self) -> Optional[int]: + return self._valid_sprites.get(str(self.value).lower()) + + class LevelMixin: xp_coefficients: List[int] = sorted([191, 65, 50, 32, 18, 14, 6, 3, 3, 2, 2, 2, 2] * 8, reverse=True) @@ -61,8 +83,7 @@ class BlueChestChance(Range): """The chance of a chest being a blue chest. It is given in units of 1/256, i.e., a value of 25 corresponds to 25/256 ~ 9.77%. - If you increase the blue chest chance, then the chance of finding consumables is decreased in return. - The chance of finding red chest equipment or spells is unaffected. + If you increase the blue chest chance, then the red chest chance is decreased in return. Supported values: 5 â 75 Default value: 25 (five times as much as in an unmodified game) """ @@ -72,6 +93,14 @@ class BlueChestChance(Range): range_end = 75 default = 25 + @property + def chest_type_thresholds(self) -> bytes: + ratio: float = (256 - self.value) / (256 - 5) + # unmodified chances are: consumable (mostly non-restorative) = 36/256, consumable (restorative) = 58/256, + # blue chest = 5/256, spell = 30/256, gear = 45/256 (and the remaining part, weapon = 82/256) + chest_type_chances: List[float] = [36 * ratio, 58 * ratio, float(self.value), 30 * ratio, 45 * ratio] + return bytes(round(threshold) for threshold in reversed(tuple(accumulate(chest_type_chances)))) + class BlueChestCount(Range): """The number of blue chest items that will be in your item pool. @@ -152,7 +181,7 @@ class Boss(RandomGroupsChoice): "random-high": ["venge_ghost", "white_dragon_x3", "fire_dragon", "ghost_ship", "tank"], "random-sinistral": ["gades_c", "amon", "erim", "daos"], } - extra_options = frozenset(random_groups) + extra_options = set(random_groups) @property def flag(self) -> int: @@ -242,6 +271,34 @@ class CrowdedFloorChance(Range): default = 16 +class CustomItemPool(ItemDict, Mapping[str, int]): + """Customize your multiworld item pool. + + Using this option you can place any cave item in your multiworld item pool. (By default, the pool is filled with + blue chest items.) Here you can add any valid item from the Lufia II Ancient Cave section of the datapackage + (see https://archipelago.gg/datapackage). The value of this option has to be a mapping of item name to count, + e.g., to add two Deadly rods and one Dekar Blade: {Deadly rod: 2, Dekar blade: 1} + The maximum total amount of custom items you can place is limited by the chosen blue_chest_count; any remaining, + non-customized space in the pool will be occupied by random blue chest items. + """ + + display_name = "Custom item pool" + value: Dict[str, int] + + @property + def count(self) -> int: + return sum(self.values()) + + def __getitem__(self, key: str) -> int: + return self.value.__getitem__(key) + + def __iter__(self) -> Iterator[str]: + return self.value.__iter__() + + def __len__(self) -> int: + return self.value.__len__() + + class DefaultCapsule(Choice): """Preselect the active capsule monster. @@ -277,7 +334,8 @@ class DefaultParty(RandomGroupsChoice, TextChoice): """ display_name = "Default party lineup" - default = "M" + default: Union[str, int] = "M" + value: Union[str, int] random_groups = { "random-2p": ["M" + "".join(p) for p in combinations("ADGLST", 1)], @@ -288,7 +346,7 @@ class DefaultParty(RandomGroupsChoice, TextChoice): _valid_sorted_parties: List[List[str]] = [sorted(party) for party in ("M", *chain(*random_groups.values()))] _members_to_bytes: bytes = bytes.maketrans(b"MSGATDL", bytes(range(7))) - def verify(self, *args, **kwargs) -> None: + def verify(self, world: Type[World], player_name: str, plando_options: PlandoOptions) -> None: if str(self.value).lower() in self.random_groups: return if sorted(str(self.value).upper()) in self._valid_sorted_parties: @@ -317,6 +375,97 @@ class DefaultParty(RandomGroupsChoice, TextChoice): return len(str(self.value)) +class EnemyFloorNumbers(Choice): + """Change which enemy types are encountered at which floor numbers. + + Supported values: + vanilla + Ninja, e.g., is allowed to appear on the 3 floors B44-B46 + shuffle â The existing enemy types are redistributed among nearby floors. Shifts by up to 6 floors are possible. + Ninja, e.g., will be allowed to appear on exactly 3 consecutive floors somewhere from B38-B40 to B50-B52 + randomize â For each floor, new enemy types are chosen randomly from the set usually possible on floors [-6, +6]. + Ninja, e.g., is among the various possible selections for any enemy slot affecting the floors from B38 to B52 + Default value: vanilla (same as in an unmodified game) + """ + + display_name = "Enemy floor numbers" + option_vanilla = 0 + option_shuffle = 1 + option_randomize = 2 + default = option_vanilla + + +class EnemyMovementPatterns(EnemyChoice): + """Change the movement patterns of enemies. + + Supported values: + vanilla + shuffle_by_pattern â The existing movement patterns are redistributed among each other. + Sprites that usually share a movement pattern will still share movement patterns after shuffling + randomize_by_pattern â For each movement pattern, a new one is chosen randomly from the set of existing patterns. + Sprites that usually share a movement pattern will still share movement patterns after randomizing + shuffle_by_sprite â The existing movement patterns of sprites are redistributed among the enemy sprites. + Sprites that usually share a movement pattern can end up with different movement patterns after shuffling + randomize_by_sprite â For each sprite, a new movement is chosen randomly from the set of existing patterns. + Sprites that usually share a movement pattern can end up with different movement patterns after randomizing + singularity â All enemy sprites use the same, randomly selected movement pattern + Alternatively, you can directly specify an enemy name such as "Red Jelly" as the value of this option. + In that case, the movement pattern usually associated with this sprite will be used by all enemy sprites + Default value: vanilla (same as in an unmodified game) + """ + + display_name = "Enemy movement patterns" + option_vanilla = 0 + option_shuffle_by_pattern = 1 + option_randomize_by_pattern = 2 + option_shuffle_by_sprite = 3 + option_randomize_by_sprite = 4 + option_singularity = 5 + default = option_vanilla + + +class EnemySprites(EnemyChoice): + """Change the appearance of enemies. + + Supported values: + vanilla + shuffle â The existing sprites are redistributed among the enemy types. + This means that, after shuffling, exactly 1 enemy type will be dressing up as the "Red Jelly" sprite + randomize â For each enemy type, a new sprite is chosen randomly from the set of existing sprites. + This means that, after randomizing, any number of enemy types could end up using the "Red Jelly" sprite + singularity â All enemies use the same, randomly selected sprite + Alternatively, you can directly specify an enemy name such as "Red Jelly" as the value of this option. + In this case, the sprite usually associated with that enemy will be used by all enemies + Default value: vanilla (same as in an unmodified game) + """ + + display_name = "Enemy sprites" + option_vanilla = 0 + option_shuffle = 1 + option_randomize = 2 + option_singularity = 3 + default = option_vanilla + + +class ExpModifier(Range): + """Percentage modifier for EXP gained from enemies. + + Supported values: 100 â 500 + Default value: 100 (same as in an unmodified game) + """ + + display_name = "EXP modifier" + range_start = 100 + range_end = 500 + default = 100 + + def __call__(self, exp: bytes) -> bytes: + try: + return (int.from_bytes(exp, "little") * self.value // 100).to_bytes(2, "little") + except OverflowError: + return b"\xFF\xFF" + + class FinalFloor(Range): """The final floor, where the boss resides. @@ -424,28 +573,18 @@ class IrisTreasuresRequired(Range): default = 9 -class MasterHp(SpecialRange): +class MasterHp(Range): """The number of hit points of the Master - Supported values: - 1 â 9980, - scale â scales the HP depending on the value of final_floor + (Only has an effect if boss is set to master.) + Supported values: 1 â 9980 Default value: 9980 (same as in an unmodified game) """ display_name = "Master HP" - range_start = 0 + range_start = 1 range_end = 9980 default = 9980 - special_range_cutoff = 1 - special_range_names = { - "default": 9980, - "scale": 0, - } - - @staticmethod - def scale(final_floor: int) -> int: - return final_floor * 100 + 80 class PartyStartingLevel(LevelMixin, Range): @@ -503,7 +642,8 @@ class ShufflePartyMembers(Toggle): Supported values: false â all 6 optional party members are present in the cafe and can be recruited right away true â only Maxim is available from the start; 6 new "items" are added to your pool and shuffled into the - multiworld; when one of these items is found, the corresponding party member is unlocked for you to use + multiworld; when one of these items is found, the corresponding party member is unlocked for you to use. + While cave diving, you can add newly unlocked ones to your party by using the character items from the inventory Default value: false (same as in an unmodified game) """ @@ -514,27 +654,32 @@ class ShufflePartyMembers(Toggle): return 0b00000000 if self.value else 0b11111100 -l2ac_option_definitions: Dict[str, type(Option)] = { - "blue_chest_chance": BlueChestChance, - "blue_chest_count": BlueChestCount, - "boss": Boss, - "capsule_cravings_jp_style": CapsuleCravingsJPStyle, - "capsule_starting_form": CapsuleStartingForm, - "capsule_starting_level": CapsuleStartingLevel, - "crowded_floor_chance": CrowdedFloorChance, - "death_link": DeathLink, - "default_capsule": DefaultCapsule, - "default_party": DefaultParty, - "final_floor": FinalFloor, - "gear_variety_after_b9": GearVarietyAfterB9, - "goal": Goal, - "healing_floor_chance": HealingFloorChance, - "initial_floor": InitialFloor, - "iris_floor_chance": IrisFloorChance, - "iris_treasures_required": IrisTreasuresRequired, - "master_hp": MasterHp, - "party_starting_level": PartyStartingLevel, - "run_speed": RunSpeed, - "shuffle_capsule_monsters": ShuffleCapsuleMonsters, - "shuffle_party_members": ShufflePartyMembers, -} +@dataclass +class L2ACOptions: + blue_chest_chance: BlueChestChance + blue_chest_count: BlueChestCount + boss: Boss + capsule_cravings_jp_style: CapsuleCravingsJPStyle + capsule_starting_form: CapsuleStartingForm + capsule_starting_level: CapsuleStartingLevel + crowded_floor_chance: CrowdedFloorChance + custom_item_pool: CustomItemPool + death_link: DeathLink + default_capsule: DefaultCapsule + default_party: DefaultParty + enemy_floor_numbers: EnemyFloorNumbers + enemy_movement_patterns: EnemyMovementPatterns + enemy_sprites: EnemySprites + exp_modifier: ExpModifier + final_floor: FinalFloor + gear_variety_after_b9: GearVarietyAfterB9 + goal: Goal + healing_floor_chance: HealingFloorChance + initial_floor: InitialFloor + iris_floor_chance: IrisFloorChance + iris_treasures_required: IrisTreasuresRequired + master_hp: MasterHp + party_starting_level: PartyStartingLevel + run_speed: RunSpeed + shuffle_capsule_monsters: ShuffleCapsuleMonsters + shuffle_party_members: ShufflePartyMembers diff --git a/worlds/lufia2ac/Rom.py b/worlds/lufia2ac/Rom.py index ac168c8864..1da8d235a6 100644 --- a/worlds/lufia2ac/Rom.py +++ b/worlds/lufia2ac/Rom.py @@ -22,15 +22,15 @@ class L2ACDeltaPatch(APDeltaPatch): def get_base_rom_bytes(file_name: str = "") -> bytes: base_rom_bytes: Optional[bytes] = getattr(get_base_rom_bytes, "base_rom_bytes", None) if not base_rom_bytes: - file_name: str = get_base_rom_path(file_name) - base_rom_bytes = bytes(Utils.read_snes_rom(open(file_name, "rb"))) + file_path: str = get_base_rom_path(file_name) + base_rom_bytes = bytes(Utils.read_snes_rom(open(file_path, "rb"))) basemd5 = hashlib.md5() basemd5.update(base_rom_bytes) if L2USHASH != basemd5.hexdigest(): raise Exception("Supplied Base Rom does not match known MD5 for US release. " "Get the correct game and version, then dump it") - get_base_rom_bytes.base_rom_bytes = base_rom_bytes + setattr(get_base_rom_bytes, "base_rom_bytes", base_rom_bytes) return base_rom_bytes diff --git a/worlds/lufia2ac/Utils.py b/worlds/lufia2ac/Utils.py new file mode 100644 index 0000000000..6c2e28d137 --- /dev/null +++ b/worlds/lufia2ac/Utils.py @@ -0,0 +1,21 @@ +from random import Random +from typing import Dict, List, MutableSequence, Sequence, Set, Tuple + + +def constrained_choices(population: Sequence[int], d: int, *, k: int, random: Random) -> List[int]: + n: int = len(population) + constraints: Dict[int, Tuple[int, ...]] = { + i: tuple(dict.fromkeys(population[j] for j in range(max(0, i - d), min(i + d + 1, n)))) for i in range(n) + } + + return [random.choice(constraints[i]) for i in range(k)] + + +def constrained_shuffle(x: MutableSequence[int], d: int, random: Random) -> None: + n: int = len(x) + constraints: Dict[int, Set[int]] = {i: set(x[j] for j in range(max(0, i - d), min(i + d + 1, n))) for i in range(n)} + + for _ in range(d * n * n): + i, j = random.randrange(n), random.randrange(n) + if x[i] in constraints[j] and x[j] in constraints[i]: + x[i], x[j] = x[j], x[i] diff --git a/worlds/lufia2ac/__init__.py b/worlds/lufia2ac/__init__.py index e54f84283d..587792a58d 100644 --- a/worlds/lufia2ac/__init__.py +++ b/worlds/lufia2ac/__init__.py @@ -2,19 +2,21 @@ import base64 import itertools import os from enum import IntFlag -from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple +from random import Random +from typing import Any, ClassVar, Dict, get_type_hints, Iterator, List, Set, Tuple from BaseClasses import Entrance, Item, ItemClassification, MultiWorld, Region, Tutorial -from Main import __version__ from Options import AssembleOptions +from Utils import __version__ from worlds.AutoWorld import WebWorld, World from worlds.generic.Rules import add_rule, set_rule from .Client import L2ACSNIClient # noqa: F401 from .Items import ItemData, ItemType, l2ac_item_name_to_id, l2ac_item_table, L2ACItem, start_id as items_start_id from .Locations import l2ac_location_name_to_id, L2ACLocation -from .Options import Boss, CapsuleStartingForm, CapsuleStartingLevel, DefaultParty, Goal, l2ac_option_definitions, \ - MasterHp, PartyStartingLevel, ShuffleCapsuleMonsters, ShufflePartyMembers +from .Options import CapsuleStartingLevel, DefaultParty, EnemyFloorNumbers, EnemyMovementPatterns, EnemySprites, \ + ExpModifier, Goal, L2ACOptions from .Rom import get_base_rom_bytes, get_base_rom_path, L2ACDeltaPatch +from .Utils import constrained_choices, constrained_shuffle from .basepatch import apply_basepatch CHESTS_PER_SPHERE: int = 5 @@ -42,7 +44,7 @@ class L2ACWorld(World): game: ClassVar[str] = "Lufia II Ancient Cave" web: ClassVar[WebWorld] = L2ACWeb() - option_definitions: ClassVar[Dict[str, AssembleOptions]] = l2ac_option_definitions + option_definitions: ClassVar[Dict[str, AssembleOptions]] = get_type_hints(L2ACOptions) item_name_to_id: ClassVar[Dict[str, int]] = l2ac_item_name_to_id location_name_to_id: ClassVar[Dict[str, int]] = l2ac_location_name_to_id item_name_groups: ClassVar[Dict[str, Set[str]]] = { @@ -54,30 +56,8 @@ class L2ACWorld(World): required_client_version: Tuple[int, int, int] = (0, 3, 6) # L2ACWorld specific properties - rom_name: Optional[bytearray] - - blue_chest_chance: Optional[int] - blue_chest_count: Optional[int] - boss: Optional[Boss] - capsule_cravings_jp_style: Optional[int] - capsule_starting_form: Optional[CapsuleStartingForm] - capsule_starting_level: Optional[CapsuleStartingLevel] - crowded_floor_chance: Optional[int] - death_link: Optional[int] - default_capsule: Optional[int] - default_party: Optional[DefaultParty] - final_floor: Optional[int] - gear_variety_after_b9: Optional[int] - goal: Optional[int] - healing_floor_chance: Optional[int] - initial_floor: Optional[int] - iris_floor_chance: Optional[int] - iris_treasures_required: Optional[int] - master_hp: Optional[int] - party_starting_level: Optional[PartyStartingLevel] - run_speed: Optional[int] - shuffle_capsule_monsters: Optional[ShuffleCapsuleMonsters] - shuffle_party_members: Optional[ShufflePartyMembers] + rom_name: bytearray + o: L2ACOptions @classmethod def stage_assert_generate(cls, multiworld: MultiWorld) -> None: @@ -95,37 +75,17 @@ class L2ACWorld(World): bytearray(f"L2AC{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21] self.rom_name.extend([0] * (21 - len(self.rom_name))) - self.blue_chest_chance = self.multiworld.blue_chest_chance[self.player].value - self.blue_chest_count = self.multiworld.blue_chest_count[self.player].value - self.boss = self.multiworld.boss[self.player] - self.capsule_cravings_jp_style = self.multiworld.capsule_cravings_jp_style[self.player].value - self.capsule_starting_form = self.multiworld.capsule_starting_form[self.player] - self.capsule_starting_level = self.multiworld.capsule_starting_level[self.player] - self.crowded_floor_chance = self.multiworld.crowded_floor_chance[self.player].value - self.death_link = self.multiworld.death_link[self.player].value - self.default_capsule = self.multiworld.default_capsule[self.player].value - self.default_party = self.multiworld.default_party[self.player] - self.final_floor = self.multiworld.final_floor[self.player].value - self.gear_variety_after_b9 = self.multiworld.gear_variety_after_b9[self.player].value - self.goal = self.multiworld.goal[self.player].value - self.healing_floor_chance = self.multiworld.healing_floor_chance[self.player].value - self.initial_floor = self.multiworld.initial_floor[self.player].value - self.iris_floor_chance = self.multiworld.iris_floor_chance[self.player].value - self.iris_treasures_required = self.multiworld.iris_treasures_required[self.player].value - self.master_hp = self.multiworld.master_hp[self.player].value - self.party_starting_level = self.multiworld.party_starting_level[self.player] - self.run_speed = self.multiworld.run_speed[self.player].value - self.shuffle_capsule_monsters = self.multiworld.shuffle_capsule_monsters[self.player] - self.shuffle_party_members = self.multiworld.shuffle_party_members[self.player] + self.o = L2ACOptions(**{opt: getattr(self.multiworld, opt)[self.player] for opt in self.option_definitions}) - if self.capsule_starting_level.value == CapsuleStartingLevel.special_range_names["party_starting_level"]: - self.capsule_starting_level.value = self.party_starting_level.value - if self.initial_floor >= self.final_floor: - self.initial_floor = self.final_floor - 1 - if self.master_hp == MasterHp.special_range_names["scale"]: - self.master_hp = MasterHp.scale(self.final_floor) - if self.shuffle_party_members: - self.default_party.value = DefaultParty.default + if self.o.blue_chest_count < self.o.custom_item_pool.count: + raise ValueError(f"Number of items in custom_item_pool ({self.o.custom_item_pool.count}) is " + f"greater than blue_chest_count ({self.o.blue_chest_count}).") + if self.o.capsule_starting_level == CapsuleStartingLevel.special_range_names["party_starting_level"]: + self.o.capsule_starting_level.value = int(self.o.party_starting_level) + if self.o.initial_floor >= self.o.final_floor: + self.o.initial_floor.value = self.o.final_floor - 1 + if self.o.shuffle_party_members: + self.o.default_party.value = DefaultParty.default def create_regions(self) -> None: menu = Region("Menu", self.player, self.multiworld) @@ -134,10 +94,10 @@ class L2ACWorld(World): ancient_dungeon = Region("AncientDungeon", self.player, self.multiworld, "Ancient Dungeon") ancient_dungeon.exits.append(Entrance(self.player, "FinalFloorEntrance", ancient_dungeon)) - item_count: int = self.blue_chest_count - if self.shuffle_capsule_monsters: + item_count: int = int(self.o.blue_chest_count) + if self.o.shuffle_capsule_monsters: item_count += len(self.item_name_groups["Capsule monsters"]) - if self.shuffle_party_members: + if self.o.shuffle_party_members: item_count += len(self.item_name_groups["Party members"]) for location_name, location_id in itertools.islice(l2ac_location_name_to_id.items(), item_count): ancient_dungeon.locations.append(L2ACLocation(self.player, location_name, location_id, ancient_dungeon)) @@ -167,21 +127,23 @@ class L2ACWorld(World): .connect(self.multiworld.get_region("FinalFloor", self.player)) def create_items(self) -> None: - item_pool: List[str] = \ - self.multiworld.random.choices(sorted(self.item_name_groups["Blue chest items"]), k=self.blue_chest_count) - if self.shuffle_capsule_monsters: + item_pool: List[str] = self.multiworld.random.choices(sorted(self.item_name_groups["Blue chest items"]), + k=self.o.blue_chest_count - self.o.custom_item_pool.count) + item_pool += [item_name for item_name, count in self.o.custom_item_pool.items() for _ in range(count)] + + if self.o.shuffle_capsule_monsters: item_pool += self.item_name_groups["Capsule monsters"] - self.blue_chest_count += len(self.item_name_groups["Capsule monsters"]) - if self.shuffle_party_members: + self.o.blue_chest_count.value += len(self.item_name_groups["Capsule monsters"]) + if self.o.shuffle_party_members: item_pool += self.item_name_groups["Party members"] - self.blue_chest_count += len(self.item_name_groups["Party members"]) + self.o.blue_chest_count.value += len(self.item_name_groups["Party members"]) for item_name in item_pool: item_data: ItemData = l2ac_item_table[item_name] item_id: int = items_start_id + item_data.code self.multiworld.itempool.append(L2ACItem(item_name, item_data.classification, item_id, self.player)) def set_rules(self) -> None: - for i in range(1, self.blue_chest_count): + for i in range(1, self.o.blue_chest_count): if i % CHESTS_PER_SPHERE == 0: set_rule(self.multiworld.get_location(f"Blue chest {i + 1}", self.player), lambda state, j=i: state.has("Progressive chest access", self.player, j // CHESTS_PER_SPHERE)) @@ -192,27 +154,27 @@ class L2ACWorld(World): lambda state, j=i: state.can_reach(f"Blue chest {j}", "Location", self.player)) set_rule(self.multiworld.get_entrance("FinalFloorEntrance", self.player), - lambda state: state.can_reach(f"Blue chest {self.blue_chest_count}", "Location", self.player)) + lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player)) set_rule(self.multiworld.get_location("Iris Treasures", self.player), - lambda state: state.can_reach(f"Blue chest {self.blue_chest_count}", "Location", self.player)) + lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player)) set_rule(self.multiworld.get_location("Boss", self.player), - lambda state: state.can_reach(f"Blue chest {self.blue_chest_count}", "Location", self.player)) - if self.shuffle_capsule_monsters: + lambda state: state.can_reach(f"Blue chest {self.o.blue_chest_count}", "Location", self.player)) + if self.o.shuffle_capsule_monsters: add_rule(self.multiworld.get_location("Boss", self.player), lambda state: state.has("DARBI", self.player)) - if self.shuffle_party_members: + if self.o.shuffle_party_members: add_rule(self.multiworld.get_location("Boss", self.player), lambda state: state.has("Dekar", self.player) and state.has("Guy", self.player) and state.has("Arty", self.player)) - if self.goal == Goal.option_final_floor: + if self.o.goal == Goal.option_final_floor: self.multiworld.completion_condition[self.player] = \ lambda state: state.has("Final Floor access", self.player) - elif self.goal == Goal.option_iris_treasure_hunt: + elif self.o.goal == Goal.option_iris_treasure_hunt: self.multiworld.completion_condition[self.player] = \ lambda state: state.has("Treasures collected", self.player) - elif self.goal == Goal.option_boss: + elif self.o.goal == Goal.option_boss: self.multiworld.completion_condition[self.player] = \ lambda state: state.has("Boss victory", self.player) - elif self.goal == Goal.option_boss_iris_treasure_hunt: + elif self.o.goal == Goal.option_boss_iris_treasure_hunt: self.multiworld.completion_condition[self.player] = \ lambda state: state.has("Boss victory", self.player) and state.has("Treasures collected", self.player) @@ -223,39 +185,45 @@ class L2ACWorld(World): rom_bytearray = bytearray(apply_basepatch(get_base_rom_bytes())) # start and stop indices are offsets in the ROM file, not LoROM mapped SNES addresses rom_bytearray[0x007FC0:0x007FC0 + 21] = self.rom_name - rom_bytearray[0x014308:0x014308 + 1] = self.capsule_starting_level.value.to_bytes(1, "little") - rom_bytearray[0x01432F:0x01432F + 1] = self.capsule_starting_form.unlock.to_bytes(1, "little") - rom_bytearray[0x01433C:0x01433C + 1] = self.capsule_starting_form.value.to_bytes(1, "little") - rom_bytearray[0x0190D5:0x0190D5 + 1] = self.iris_floor_chance.to_bytes(1, "little") - rom_bytearray[0x019153:0x019153 + 1] = (0x63 - self.blue_chest_chance).to_bytes(1, "little") - rom_bytearray[0x019176] = 0x38 if self.gear_variety_after_b9 else 0x18 - rom_bytearray[0x019477:0x019477 + 1] = self.healing_floor_chance.to_bytes(1, "little") - rom_bytearray[0x0194A2:0x0194A2 + 1] = self.crowded_floor_chance.to_bytes(1, "little") - rom_bytearray[0x019E82:0x019E82 + 1] = self.final_floor.to_bytes(1, "little") - rom_bytearray[0x01FC75:0x01FC75 + 1] = self.run_speed.to_bytes(1, "little") - rom_bytearray[0x01FC81:0x01FC81 + 1] = self.run_speed.to_bytes(1, "little") - rom_bytearray[0x02B2A1:0x02B2A1 + 5] = self.default_party.roster + rom_bytearray[0x014308:0x014308 + 1] = self.o.capsule_starting_level.value.to_bytes(1, "little") + rom_bytearray[0x01432F:0x01432F + 1] = self.o.capsule_starting_form.unlock.to_bytes(1, "little") + rom_bytearray[0x01433C:0x01433C + 1] = self.o.capsule_starting_form.value.to_bytes(1, "little") + rom_bytearray[0x0190D5:0x0190D5 + 1] = self.o.iris_floor_chance.value.to_bytes(1, "little") + rom_bytearray[0x019147:0x019157 + 1:4] = self.o.blue_chest_chance.chest_type_thresholds + rom_bytearray[0x019176] = 0x38 if self.o.gear_variety_after_b9 else 0x18 + rom_bytearray[0x019477:0x019477 + 1] = self.o.healing_floor_chance.value.to_bytes(1, "little") + rom_bytearray[0x0194A2:0x0194A2 + 1] = self.o.crowded_floor_chance.value.to_bytes(1, "little") + rom_bytearray[0x019E82:0x019E82 + 1] = self.o.final_floor.value.to_bytes(1, "little") + rom_bytearray[0x01FC75:0x01FC75 + 1] = self.o.run_speed.value.to_bytes(1, "little") + rom_bytearray[0x01FC81:0x01FC81 + 1] = self.o.run_speed.value.to_bytes(1, "little") + rom_bytearray[0x02B2A1:0x02B2A1 + 5] = self.o.default_party.roster for offset in range(0x02B395, 0x02B452, 0x1B): - rom_bytearray[offset:offset + 1] = self.party_starting_level.value.to_bytes(1, "little") + rom_bytearray[offset:offset + 1] = self.o.party_starting_level.value.to_bytes(1, "little") for offset in range(0x02B39A, 0x02B457, 0x1B): - rom_bytearray[offset:offset + 3] = self.party_starting_level.xp.to_bytes(3, "little") + rom_bytearray[offset:offset + 3] = self.o.party_starting_level.xp.to_bytes(3, "little") rom_bytearray[0x05699E:0x05699E + 147] = self.get_goal_text_bytes() - rom_bytearray[0x056AA3:0x056AA3 + 24] = self.default_party.event_script - rom_bytearray[0x072742:0x072742 + 1] = self.boss.value.to_bytes(1, "little") - rom_bytearray[0x072748:0x072748 + 1] = self.boss.flag.to_bytes(1, "little") + rom_bytearray[0x056AA3:0x056AA3 + 24] = self.o.default_party.event_script + rom_bytearray[0x072742:0x072742 + 1] = self.o.boss.value.to_bytes(1, "little") + rom_bytearray[0x072748:0x072748 + 1] = self.o.boss.flag.to_bytes(1, "little") rom_bytearray[0x09D59B:0x09D59B + 256] = self.get_node_connection_table() - rom_bytearray[0x0B4F02:0x0B4F02 + 2] = self.master_hp.to_bytes(2, "little") - rom_bytearray[0x280010:0x280010 + 2] = self.blue_chest_count.to_bytes(2, "little") - rom_bytearray[0x280012:0x280012 + 3] = self.capsule_starting_level.xp.to_bytes(3, "little") - rom_bytearray[0x280015:0x280015 + 1] = self.initial_floor.to_bytes(1, "little") - rom_bytearray[0x280016:0x280016 + 1] = self.default_capsule.to_bytes(1, "little") - rom_bytearray[0x280017:0x280017 + 1] = self.iris_treasures_required.to_bytes(1, "little") - rom_bytearray[0x280018:0x280018 + 1] = self.shuffle_party_members.unlock.to_bytes(1, "little") - rom_bytearray[0x280019:0x280019 + 1] = self.shuffle_capsule_monsters.unlock.to_bytes(1, "little") - rom_bytearray[0x280030:0x280030 + 1] = self.goal.to_bytes(1, "little") - rom_bytearray[0x28003D:0x28003D + 1] = self.death_link.to_bytes(1, "little") + rom_bytearray[0x0B05C0:0x0B05C0 + 18843] = self.get_enemy_stats() + rom_bytearray[0x0B4F02:0x0B4F02 + 2] = self.o.master_hp.value.to_bytes(2, "little") + rom_bytearray[0x280010:0x280010 + 2] = self.o.blue_chest_count.value.to_bytes(2, "little") + rom_bytearray[0x280012:0x280012 + 3] = self.o.capsule_starting_level.xp.to_bytes(3, "little") + rom_bytearray[0x280015:0x280015 + 1] = self.o.initial_floor.value.to_bytes(1, "little") + rom_bytearray[0x280016:0x280016 + 1] = self.o.default_capsule.value.to_bytes(1, "little") + rom_bytearray[0x280017:0x280017 + 1] = self.o.iris_treasures_required.value.to_bytes(1, "little") + rom_bytearray[0x280018:0x280018 + 1] = self.o.shuffle_party_members.unlock.to_bytes(1, "little") + rom_bytearray[0x280019:0x280019 + 1] = self.o.shuffle_capsule_monsters.unlock.to_bytes(1, "little") + rom_bytearray[0x280030:0x280030 + 1] = self.o.goal.value.to_bytes(1, "little") + rom_bytearray[0x28003D:0x28003D + 1] = self.o.death_link.value.to_bytes(1, "little") rom_bytearray[0x281200:0x281200 + 470] = self.get_capsule_cravings_table() + (rom_bytearray[0x08A1D4:0x08A1D4 + 128], + rom_bytearray[0x0A595C:0x0A595C + 200], + rom_bytearray[0x0A5DF6:0x0A5DF6 + 192], + rom_bytearray[0x27F6B5:0x27F6B5 + 113]) = self.get_enemy_floors_sprites_and_movement_patterns() + with open(rom_path, "wb") as f: f.write(rom_bytearray) except Exception as e: @@ -276,13 +244,19 @@ class L2ACWorld(World): # end of ordered Main.py calls def create_item(self, name: str) -> Item: - item_data: ItemData = l2ac_item_table.get(name) + item_data: ItemData = l2ac_item_table[name] return L2ACItem(name, item_data.classification, items_start_id + item_data.code, self.player) + def get_filler_item_name(self) -> str: + return ["Potion", "Hi-Magic", "Miracle", "Hi-Potion", "Potion", "Ex-Potion", "Regain", "Ex-Magic", "Hi-Magic"][ + (self.multiworld.random.randrange(9) + self.multiworld.random.randrange(9)) // 2] + + # end of overridden AutoWorld.py methods + def get_capsule_cravings_table(self) -> bytes: rom: bytes = get_base_rom_bytes() - if self.capsule_cravings_jp_style: + if self.o.capsule_cravings_jp_style: number_of_items: int = 467 items_offset: int = 0x0B4F69 value_thresholds: List[int] = \ @@ -307,17 +281,92 @@ class L2ACWorld(World): else: return rom[0x0AFF16:0x0AFF16 + 470] + def get_enemy_floors_sprites_and_movement_patterns(self) -> Tuple[bytes, bytes, bytes, bytes]: + rom: bytes = get_base_rom_bytes() + + if self.o.enemy_floor_numbers == EnemyFloorNumbers.default \ + and self.o.enemy_sprites == EnemySprites.default \ + and self.o.enemy_movement_patterns == EnemyMovementPatterns.default: + return rom[0x08A1D4:0x08A1D4 + 128], rom[0x0A595C:0x0A595C + 200], \ + rom[0x0A5DF6:0x0A5DF6 + 192], rom[0x27F6B5:0x27F6B5 + 113] + + formations: bytes = rom[0x0A595C:0x0A595C + 200] + sprites: bytes = rom[0x0A5DF6:0x0A5DF6 + 192] + indices: bytes = rom[0x27F6B5:0x27F6B5 + 113] + pointers: List[bytes] = [rom[0x08A1D4 + 2 * index:0x08A1D4 + 2 * index + 2] for index in range(64)] + + used_formations: List[int] = list(formations) + formation_set: Set[int] = set(used_formations) + used_sprites: List[int] = [sprite for formation, sprite in enumerate(sprites) if formation in formation_set] + sprite_set: Set[int] = set(used_sprites) + used_indices: List[int] = [index for sprite, index in enumerate(indices, 128) if sprite in sprite_set] + index_set: Set[int] = set(used_indices) + used_pointers: List[bytes] = [pointer for index, pointer in enumerate(pointers) if index in index_set] + + slot_random: Random = self.multiworld.per_slot_randoms[self.player] + + d: int = 2 * 6 + if self.o.enemy_floor_numbers == EnemyFloorNumbers.option_shuffle: + constrained_shuffle(used_formations, d, random=slot_random) + elif self.o.enemy_floor_numbers == EnemyFloorNumbers.option_randomize: + used_formations = constrained_choices(used_formations, d, k=len(used_formations), random=slot_random) + + if self.o.enemy_sprites == EnemySprites.option_shuffle: + slot_random.shuffle(used_sprites) + elif self.o.enemy_sprites == EnemySprites.option_randomize: + used_sprites = slot_random.choices(tuple(dict.fromkeys(used_sprites)), k=len(used_sprites)) + elif self.o.enemy_sprites == EnemySprites.option_singularity: + used_sprites = [slot_random.choice(tuple(dict.fromkeys(used_sprites)))] * len(used_sprites) + elif self.o.enemy_sprites.sprite: + used_sprites = [self.o.enemy_sprites.sprite] * len(used_sprites) + + if self.o.enemy_movement_patterns == EnemyMovementPatterns.option_shuffle_by_pattern: + slot_random.shuffle(used_pointers) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_randomize_by_pattern: + used_pointers = slot_random.choices(tuple(dict.fromkeys(used_pointers)), k=len(used_pointers)) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_shuffle_by_sprite: + slot_random.shuffle(used_indices) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_randomize_by_sprite: + used_indices = slot_random.choices(tuple(dict.fromkeys(used_indices)), k=len(used_indices)) + elif self.o.enemy_movement_patterns == EnemyMovementPatterns.option_singularity: + used_indices = [slot_random.choice(tuple(dict.fromkeys(used_indices)))] * len(used_indices) + elif self.o.enemy_movement_patterns.sprite: + used_indices = [indices[self.o.enemy_movement_patterns.sprite - 128]] * len(used_indices) + + sprite_iter: Iterator[int] = iter(used_sprites) + index_iter: Iterator[int] = iter(used_indices) + pointer_iter: Iterator[bytes] = iter(used_pointers) + formations = bytes(used_formations) + sprites = bytes(next(sprite_iter) if form in formation_set else sprite for form, sprite in enumerate(sprites)) + indices = bytes(next(index_iter) if sprite in sprite_set else idx for sprite, idx in enumerate(indices, 128)) + pointers = [next(pointer_iter) if idx in index_set else pointer for idx, pointer in enumerate(pointers)] + return b"".join(pointers), formations, sprites, indices + + def get_enemy_stats(self) -> bytes: + rom: bytes = get_base_rom_bytes() + + if self.o.exp_modifier == ExpModifier.default: + return rom[0x0B05C0:0x0B05C0 + 18843] + + number_of_enemies: int = 224 + enemy_stats = bytearray(rom[0x0B05C0:0x0B05C0 + 18843]) + + for enemy_id in range(number_of_enemies): + pointer: int = int.from_bytes(enemy_stats[2 * enemy_id:2 * enemy_id + 2], "little") + enemy_stats[pointer + 29:pointer + 31] = self.o.exp_modifier(enemy_stats[pointer + 29:pointer + 31]) + return enemy_stats + def get_goal_text_bytes(self) -> bytes: goal_text: List[str] = [] - iris: str = f"{self.iris_treasures_required} Iris treasure{'s' if self.iris_treasures_required > 1 else ''}" - if self.goal == Goal.option_boss: - goal_text = ["You have to defeat", f"the boss on B{self.final_floor}."] - elif self.goal == Goal.option_iris_treasure_hunt: + iris: str = f"{self.o.iris_treasures_required} Iris treasure{'s' if self.o.iris_treasures_required > 1 else ''}" + if self.o.goal == Goal.option_boss: + goal_text = ["You have to defeat", f"the boss on B{self.o.final_floor}."] + elif self.o.goal == Goal.option_iris_treasure_hunt: goal_text = ["You have to find", f"{iris}."] - elif self.goal == Goal.option_boss_iris_treasure_hunt: - goal_text = ["You have to retrieve", f"{iris} and", f"defeat the boss on B{self.final_floor}."] - elif self.goal == Goal.option_final_floor: - goal_text = [f"You need to get to B{self.final_floor}."] + elif self.o.goal == Goal.option_boss_iris_treasure_hunt: + goal_text = ["You have to retrieve", f"{iris} and", f"defeat the boss on B{self.o.final_floor}."] + elif self.o.goal == Goal.option_final_floor: + goal_text = [f"You need to get to B{self.o.final_floor}."] assert len(goal_text) <= 4 and all(len(line) <= 28 for line in goal_text), goal_text goal_text_bytes = bytes((0x08, *b"\x03".join(line.encode("ascii") for line in goal_text), 0x00)) return goal_text_bytes + b"\x00" * (147 - len(goal_text_bytes)) diff --git a/worlds/lufia2ac/basepatch/basepatch.asm b/worlds/lufia2ac/basepatch/basepatch.asm index d263b3d4f0..a2ea539fd4 100644 --- a/worlds/lufia2ac/basepatch/basepatch.asm +++ b/worlds/lufia2ac/basepatch/basepatch.asm @@ -3,13 +3,13 @@ lorom org $DFFFFD ; expand ROM to 3MB DB "EOF" -org $80FFD8 ; expand SRAM to 16KB - DB $04 ; overwrites DB $03 +org $80FFD8 ; expand SRAM to 32KB + DB $05 ; overwrites DB $03 org $80809A ; patch copy protection - CMP $704000 ; overwrites CMP $702000 + CMP $710000 ; overwrites CMP $702000 org $8080A6 ; patch copy protection - CMP $704000 ; overwrites CMP $702000 + CMP $710000 ; overwrites CMP $702000 @@ -34,8 +34,8 @@ org $8AF681 ; skip gruberik lexis dialogue org $8EA349 ; skip ancient cave entrance dialogue DB $1C,$B0,$01 ; L2SASM JMP $8EA1AD+$01B0 -org $8EA384 ; skip ancient cave exit dialogue - DB $1C,$2B,$02 ; L2SASM JMP $8EA1AD+$022B +org $8EA384 ; reset architect mode, skip ancient cave exit dialogue + DB $1B,$E1,$1C,$2B,$02 ; clear flag $E1, L2SASM JMP $8EA1AD+$022B org $8EA565 ; skip ancient cave leaving dialogue DB $1C,$E9,$03 ; L2SASM JMP $8EA1AD+$03E9 @@ -108,11 +108,13 @@ Init: STX $4302 ; A-bus destination address $F02000 (SRAM) LDA.b #$F0 STA $4304 - STX $4305 ; transfer 8kB + LDX.w #$6000 + STX $4305 ; transfer 24kB LDA.b #$01 STA $420B ; start DMA channel 1 ; sign expanded SRAM PHB + TDC LDA.b #$3F LDX.w #$8000 LDY.w #$2000 @@ -213,6 +215,8 @@ RX: JSR SpecialItemGet SEP #$20 JSL $8EC1EF ; call chest opening routine (but without chest opening animation) + STZ $A7 ; cleanup + JSL $83AB4F ; cleanup +: SEP #$20 RTS @@ -268,11 +272,15 @@ SpecialItemUse: SBC.w #$01B1 ; party member items range from $01B2 to $01B7 BMI + ASL + TAX ASL ASL ADC.w #$FD2E STA $09B7 ; set pointer to L2SASM join script SEP #$20 + LDA $8ED8C7,X ; load predefined bitmask with a single bit set + BIT $077E ; check against EV flags $02 to $07 (party member flags) + BNE + ; abort if character already present LDA $07A9 ; load EV register $11 (party counter) CMP.b #$03 BPL + ; abort if party full @@ -593,18 +601,16 @@ FinalFloor: pushpc org $8488BB ; DB=$84, x=0, m=0 - SEC ; {carry clear = disable this feature, carry set = enable this feature} JSL Providence ; overwrites LDX.w #$1402 : STX $0A8D - NOP ; + NOP #2 pullpc Providence: LDX.w #$1402 ; (overwritten instruction) STX $0A8D ; (overwritten instruction) add Potion x10 - BCC + - LDX.w #$022D ; + LDX.w #$022D STX $0A8F ; add Providence -+: RTL + RTL @@ -646,6 +652,142 @@ StartInventory: +; architect mode +pushpc +org $8EA1E7 +base = $8EA1AD ; ancient cave entrance script base + DB $15,$E1 : DW .locked-base ; L2SASM JMP .locked if flag $E1 set + DB $08,"Did you like the layout",$03, \ + "of the last cave? I can",$03, \ + "lock it down and prevent",$03, \ + "the cave from changing.",$01 + DB $08,"Do you want to lock",$03, \ + "the cave layout?",$01 + DB $10,$02 : DW .cancel-base,.lock-base ; setup 2 choices: .cancel and .lock + DB $08,"Cancel",$0F,"LOCK IT DOWN!",$0B +.cancel: + DB $4C,$54,$00 ; play sound $54, END +.lock: + DB $5A,$05,$03,$7F,$37,$28,$56,$4C,$6B,$1A,$E1 ; shake, delay $28 f, stop shake, play sound $6B, set flag $E1 +.locked: + DB $08,"It's locked down.",$00 + warnpc $8EA344 +org $839018 + ; DB=$83, x=0, m=1 + JSL ArchitectMode ; overwrites LDA.b #$7E : PHA : PLB +pullpc + +ArchitectMode: +; check current mode + LDA $079A + BIT.b #$02 + BEQ + ; go to write mode if flag $E1 (i.e., bit $02 in $079A) not set +; read mode (replaying the locked down layout) + JSR ArchitectBlockAddress + LDA $F00000,X ; check if current block is marked as filled + BEQ + ; go to write mode if block unused + TDC + LDA.b #$36 + LDY.w #$0521 + INX + MVN $7E,$F0 ; restore 55 RNG values from $F00000,X to $7E0521 + INX + LDA $F00000,X + STA $0559 ; restore current RNG index from $F00000,X to $7E0559 + BRA ++ +; write mode (recording the layout) ++: JSR ArchitectClearBlocks + JSR ArchitectBlockAddress + LDA $7FE696 + STA $F00000,X ; mark block as used + TDC + LDA.b #$36 + LDX.w #$0521 + INY + MVN $F0,$7E ; backup 55 RNG values from $7E0521 to $F00000,Y + INY + LDA $7E0559 + STA $0000,Y ; backup current RNG index from $7E0559 to $F00000,Y + LDA.b #$7E ; (overwritten instruction) set DB=$7E + PHA ; (overwritten instruction) + PLB ; (overwritten instruction) +++: RTL + +ArchitectClearBlocks: + LDA $7FE696 ; read next floor number + CMP $D08015 ; compare initial floor number + BEQ + + BRL ++ ; skip if not initial floor ++: LDA.b #$F0 + PHA + PLB + !floor = 1 + while !floor < 99 ; mark all blocks as unused + STZ !floor*$40+$6000 + !floor #= !floor+1 + endwhile +++: RTS + +ArchitectBlockAddress: +; calculate target SRAM address + TDC + LDA $7FE696 ; read next floor number + REP #$20 + ASL #6 + ADC.w #$6000 ; target SRAM address = next_floor * $40 + $6000 + TAX + TAY + SEP #$20 + RTS + + + +; for architect mode: make red chest behavior for iris treasure replacements independent of current inventory +; by ensuring the same number of RNG calls, no matter if you have the iris item already or not +; (done by prefilling *all* chests first and potentially overwriting one of them with an iris item afterwards, +; instead of checking the iris item first and then potentially filling *one fewer* regular chest) +pushpc +org $8390C9 + ; DB=$96, x=0, m=1 + NOP ; overwrites LDY.w #$0000 + BRA + ; go to regular red chest generation +-: ; iris treasure handling happens below +org $839114 + ; DB=$7F, x=0, m=1 + NOP #36 ; overwrites all of providence handling + LDA.b #$83 ; (overwritten instruction from org $8391E9) set DB=$83 for floor layout generation + PHA ; (overwritten instruction from org $8391E9) + PLB ; (overwritten instruction from org $8391E9) + BRL ++ ; go to end ++: LDY.w #$0000 ; (overwritten instruction from org $8390C9) initialize chest index + ; red chests are filled below +org $8391E9 + ; DB=$7F, x=0, m=1 + NOP ; overwrites LDA.b #$83 : PHA : PLB + BRL - ; go to iris treasure handling +++: ; floor layout generation happens below +pullpc + + + +; for architect mode: make red chest behavior for spell replacements independent of currently learned spells +; by ensuring the same number of RNG calls, no matter if you have the spell already or not +pushpc +org $8391A6 + ; DB=$7F, x=0, m=1 + JSL SpellRNG ; overwrites LDA.b #$80 : STA $E747,Y + NOP +pullpc + +SpellRNG: + LDA.b #$80 ; (overwritten instruction) mark chest item as spell + STA $E747,Y ; (overwritten instruction) + JSL $8082C7 ; + JSL $8082C7 ; advance RNG twice + RTL + + + ; increase variety of red chest gear after B9 pushpc org $839176 @@ -891,3 +1033,4 @@ pullpc ; $F02800 2 received counter ; $F02802 2 processed counter ; $F02804 inf list of received items +; $F06000 inf architect mode RNG state backups diff --git a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 index e31ac74a52..7d622537c1 100644 Binary files a/worlds/lufia2ac/basepatch/basepatch.bsdiff4 and b/worlds/lufia2ac/basepatch/basepatch.bsdiff4 differ diff --git a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md index 375c673236..d1247a9e20 100644 --- a/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md +++ b/worlds/lufia2ac/docs/en_Lufia II Ancient Cave.md @@ -41,18 +41,26 @@ Your Party Leader will hold up the item they received when not in a fight or in - Choose a goal for your world. Possible goals are: 1) Reach the final floor; 2) Defeat the boss on the final floor; 3) Retrieve a (customizable) number of iris treasures from the cave; 4) Retrieve the iris treasures *and* defeat the boss - You can also randomize the goal; The blue-haired NPC in front of the cafe can tell you about the selected objective -- Customize (or randomize) the chances of encountering blue chests, healing tiles, iris treasures, etc. -- Customize (or randomize) the default party lineup and capsule monster -- Customize (or randomize) the party starting level as well as capsule monster level and form -- Customize (or randomize) the initial and final floor numbers -- Customize (or randomize) the boss that resides on the final floor +- Customize the chances of encountering blue chests, healing tiles, iris treasures, etc. +- Customize the default party lineup and capsule monster +- Customize the party starting level as well as capsule monster level and form +- Customize the initial and final floor numbers +- Customize the boss that resides on the final floor +- Customize the multiworld item pool. (By default, your pool is filled with random blue chest items, but you can place + any cave item you want instead) - Customize start inventory, i.e., begin every run with certain items or spells of your choice +- Adjust how much EXP is gained from enemies +- Randomize enemy movement patterns, enemy sprites, and which enemy types can appear at which floor numbers - Option to shuffle your party members and/or capsule monsters into the multiworld, meaning that someone will have to - find them in order to unlock them for you to use + find them in order to unlock them for you to use. While cave diving, you can add newly unlocked members to your party + by using the character items from your inventory ###### Quality of life: - Various streamlining tweaks (removed cutscenes, dialogue, transitions) +- You can elect to lock the cave layout for the next run, giving you exactly the same floors and red chest contents as + on your previous attempt. This functionality is accessed via the bald NPC behind the counter at the Ancient Cave + Entrance - Always start with Providence already in your inventory. (It is no longer obtained from red chests) - (optional) Run button that allows you to move at faster than normal speed diff --git a/worlds/lufia2ac/test/TestCustomItemPool.py b/worlds/lufia2ac/test/TestCustomItemPool.py new file mode 100644 index 0000000000..97d4cab2f2 --- /dev/null +++ b/worlds/lufia2ac/test/TestCustomItemPool.py @@ -0,0 +1,57 @@ +from argparse import Namespace + +from BaseClasses import PlandoOptions +from Generate import handle_option +from . import L2ACTestBase +from ..Options import CustomItemPool + + +class TestEmpty(L2ACTestBase): + options = { + "custom_item_pool": {}, + } + + def test_empty(self) -> None: + self.assertEqual(0, len(self.get_items_by_name("Dekar blade"))) + + +class TestINeedDekarBlade(L2ACTestBase): + options = { + "custom_item_pool": { + "Dekar blade": 2, + }, + } + + def test_i_need_dekar_blade(self) -> None: + self.assertEqual(2, len(self.get_items_by_name("Dekar blade"))) + + +class TestVerifyCount(L2ACTestBase): + auto_construct = False + options = { + "custom_item_pool": { + "Dekar blade": 26, + }, + } + + def test_verify_count(self) -> None: + self.assertRaisesRegex(ValueError, + "Number of items in custom_item_pool \\(26\\) is greater than blue_chest_count \\(25\\)", + lambda: self.world_setup()) + + +class TestVerifyItemName(L2ACTestBase): + auto_construct = False + options = { + "custom_item_pool": { + "The car blade": 2, + }, + } + + def test_verify_item_name(self) -> None: + self.assertRaisesRegex(Exception, + "Item The car blade from option CustomItemPool\\(The car blade: 2\\) is not a " + "valid item name from Lufia II Ancient Cave\\. Did you mean 'Dekar blade'", + lambda: handle_option(Namespace(game="Lufia II Ancient Cave", name="Player"), + self.options, "custom_item_pool", CustomItemPool, + PlandoOptions(0))) diff --git a/worlds/lufia2ac/test/TestGoal.py b/worlds/lufia2ac/test/TestGoal.py index 06393ff1ef..6dc78e66d2 100644 --- a/worlds/lufia2ac/test/TestGoal.py +++ b/worlds/lufia2ac/test/TestGoal.py @@ -2,13 +2,12 @@ from . import L2ACTestBase class TestDefault(L2ACTestBase): - options = {} - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testNothing(self): + def test_nothing(self) -> None: self.assertBeatable(True) @@ -17,15 +16,15 @@ class TestShuffleCapsuleMonsters(L2ACTestBase): "shuffle_capsule_monsters": True, } - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testBestParty(self): + def test_best_party(self) -> None: self.collect_by_name("DARBI") self.assertBeatable(True) - def testNoDarbi(self): + def test_no_darbi(self) -> None: self.collect_all_but(["Boss victory", "DARBI"]) self.assertBeatable(False) @@ -35,23 +34,23 @@ class TestShufflePartyMembers(L2ACTestBase): "shuffle_party_members": True, } - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testBestParty(self): + def test_best_party(self) -> None: self.collect_by_name(["Dekar", "Guy", "Arty"]) self.assertBeatable(True) - def testNoDekar(self): + def test_no_dekar(self) -> None: self.collect_all_but(["Boss victory", "Dekar"]) self.assertBeatable(False) - def testNoGuy(self): + def test_no_guy(self) -> None: self.collect_all_but(["Boss victory", "Guy"]) self.assertBeatable(False) - def testNoArty(self): + def test_no_arty(self) -> None: self.collect_all_but(["Boss victory", "Arty"]) self.assertBeatable(False) @@ -62,26 +61,26 @@ class TestShuffleBoth(L2ACTestBase): "shuffle_party_members": True, } - def testEverything(self): + def test_everything(self) -> None: self.collect_all_but(["Boss victory"]) self.assertBeatable(True) - def testBestParty(self): + def test_best_party(self) -> None: self.collect_by_name(["Dekar", "Guy", "Arty", "DARBI"]) self.assertBeatable(True) - def testNoDekar(self): + def test_no_dekar(self) -> None: self.collect_all_but(["Boss victory", "Dekar"]) self.assertBeatable(False) - def testNoGuy(self): + def test_no_guy(self) -> None: self.collect_all_but(["Boss victory", "Guy"]) self.assertBeatable(False) - def testNoArty(self): + def test_no_arty(self) -> None: self.collect_all_but(["Boss victory", "Arty"]) self.assertBeatable(False) - def testNoDarbi(self): + def test_no_darbi(self) -> None: self.collect_all_but(["Boss victory", "DARBI"]) self.assertBeatable(False) diff --git a/worlds/messenger/Constants.py b/worlds/messenger/Constants.py new file mode 100644 index 0000000000..f967fec8cb --- /dev/null +++ b/worlds/messenger/Constants.py @@ -0,0 +1,153 @@ +# items +# listing individual groups first for easy lookup +NOTES = [ + "Key of Hope", + "Key of Chaos", + "Key of Courage", + "Key of Love", + "Key of Strength", + "Key of Symbiosis", +] + +PROG_ITEMS = [ + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + # "Astral Seed", + # "Astral Tea Leaves", +] + +PHOBEKINS = [ + "Necro", + "Pyro", + "Claustro", + "Acro", +] + +USEFUL_ITEMS = [ + "Windmill Shuriken", +] + +# item_name_to_id needs to be deterministic and match upstream +ALL_ITEMS = [ + *NOTES, + "Windmill Shuriken", + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + # "Astral Seed", + # "Astral Tea Leaves", + "Candle", + "Seashell", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + *PHOBEKINS, + "Power Seal", + "Time Shard", # there's 45 separate instances of this in the client lookup, but hopefully we don't care? +] + +# locations +# the names of these don't actually matter, but using the upstream's names for now +# order must be exactly the same as upstream +ALWAYS_LOCATIONS = [ + # notes + "Key of Love", + "Key of Courage", + "Key of Chaos", + "Key of Symbiosis", + "Key of Strength", + "Key of Hope", + # upgrades + "Wingsuit", + "Rope Dart", + "Ninja Tabi", + "Climbing Claws", + # quest items + "Astral Seed", + "Astral Tea Leaves", + "Candle", + "Seashell", + "Power Thistle", + "Demon King Crown", + "Ruxxtin's Amulet", + "Fairy Bottle", + "Sun Crest", + "Moon Crest", + # phobekins + "Necro", + "Pyro", + "Claustro", + "Acro", +] + +SEALS = [ + "Ninja Village Seal - Tree House", + + "Autumn Hills Seal - Trip Saws", + "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", + + "Catacombs Seal - Triple Spike Crushers", + "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", + + "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", + "Bamboo Creek Seal - Spike Crushers and Doors v2", + + "Howling Grotto Seal - Windy Saws and Balls", + "Howling Grotto Seal - Crushing Pits", + "Howling Grotto Seal - Breezy Crushers", + + "Quillshroom Marsh Seal - Spikey Window", + "Quillshroom Marsh Seal - Sand Trap", + "Quillshroom Marsh Seal - Do the Spike Wave", + + "Searing Crags Seal - Triple Ball Spinner", + "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", + + "Glacial Peak Seal - Ice Climbers", + "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", + + "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + + "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", + "Cloud Ruins Seal - Money Farm Room", + + "Underworld Seal - Sharp and Windy Climb", + "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", + "Underworld Seal - Rising Fanta", + + "Forlorn Temple Seal - Rocket Maze", + "Forlorn Temple Seal - Rocket Sunset", + + "Sunken Shrine Seal - Ultra Lifeguard", + "Sunken Shrine Seal - Waterfall Paradise", + "Sunken Shrine Seal - Tabi Gauntlet", + + "Riviere Turquoise Seal - Bounces and Balls", + "Riviere Turquoise Seal - Launch of Faith", + "Riviere Turquoise Seal - Flower Power", + + "Elemental Skylands Seal - Air", + "Elemental Skylands Seal - Water", + "Elemental Skylands Seal - Fire", +] diff --git a/worlds/messenger/Options.py b/worlds/messenger/Options.py new file mode 100644 index 0000000000..47ebf66f28 --- /dev/null +++ b/worlds/messenger/Options.py @@ -0,0 +1,76 @@ +from Options import DefaultOnToggle, DeathLink, Range, Accessibility, Choice + + +class MessengerAccessibility(Accessibility): + default = Accessibility.option_locations + # defaulting to locations accessibility since items makes certain items self-locking + __doc__ = Accessibility.__doc__.replace(f"default {Accessibility.default}", f"default {default}") + + +class Logic(Choice): + """ + The level of logic to use when determining what locations in your world are accessible. + Normal can require damage boosts, but otherwise approachable for someone who has beaten the game. + Hard has some easier speedrunning tricks in logic. May need to leash. + Challenging contains more medium and hard difficulty speedrunning tricks. + OoB places everything with the minimum amount of rules possible. Expect to do OoB. Not guaranteed completable. + """ + display_name = "Logic Level" + option_normal = 0 + option_hard = 1 + option_challenging = 2 + option_oob = 3 + + +class PowerSeals(DefaultOnToggle): + """Whether power seal locations should be randomized.""" + display_name = "Shuffle Seals" + + +class Goal(Choice): + """Requirement to finish the game. Power Seal Hunt will force power seal locations to be shuffled.""" + display_name = "Goal" + option_open_music_box = 0 + option_power_seal_hunt = 1 + + +class MusicBox(DefaultOnToggle): + """Whether the music box gauntlet needs to be done.""" + display_name = "Music Box Gauntlet" + + +class NotesNeeded(Range): + """How many notes are needed to access the Music Box.""" + display_name = "Notes Needed" + range_start = 1 + range_end = 6 + default = range_end + + +class AmountSeals(Range): + """Number of power seals that exist in the item pool when power seal hunt is the goal.""" + display_name = "Total Power Seals" + range_start = 1 + range_end = 45 + default = range_end + + +class RequiredSeals(Range): + """Percentage of total seals required to open the shop chest.""" + display_name = "Percent Seals Required" + range_start = 10 + range_end = 100 + default = range_end + + +messenger_options = { + "accessibility": MessengerAccessibility, + "logic_level": Logic, + "shuffle_seals": PowerSeals, + "goal": Goal, + "music_box": MusicBox, + "notes_needed": NotesNeeded, + "total_seals": AmountSeals, + "percent_seals_required": RequiredSeals, + "death_link": DeathLink, +} diff --git a/worlds/messenger/Regions.py b/worlds/messenger/Regions.py new file mode 100644 index 0000000000..ab84f0b3ce --- /dev/null +++ b/worlds/messenger/Regions.py @@ -0,0 +1,52 @@ +from typing import Dict, Set, List + +REGIONS: Dict[str, List[str]] = { + "Menu": [], + "Tower HQ": [], + "The Shop": [], + "Tower of Time": [], + "Ninja Village": ["Candle", "Astral Seed"], + "Autumn Hills": ["Climbing Claws", "Key of Hope"], + "Forlorn Temple": ["Demon King Crown"], + "Catacombs": ["Necro", "Ruxxtin's Amulet"], + "Bamboo Creek": ["Claustro"], + "Howling Grotto": ["Wingsuit"], + "Quillshroom Marsh": ["Seashell"], + "Searing Crags": ["Rope Dart"], + "Searing Crags Upper": ["Power Thistle", "Key of Strength", "Astral Tea Leaves"], + "Glacial Peak": [], + "Cloud Ruins": ["Acro"], + "Underworld": ["Pyro", "Key of Chaos"], + "Dark Cave": [], + "Riviere Turquoise": ["Fairy Bottle"], + "Sunken Shrine": ["Ninja Tabi", "Sun Crest", "Moon Crest", "Key of Love"], + "Elemental Skylands": ["Key of Symbiosis"], + "Corrupted Future": ["Key of Courage"], + "Music Box": ["Rescue Phantom"], +} +"""seal locations have the region in their name and may not need to be created so skip them here""" + + +REGION_CONNECTIONS: Dict[str, Set[str]] = { + "Menu": {"Tower HQ"}, + "Tower HQ": {"Autumn Hills", "Howling Grotto", "Searing Crags", "Glacial Peak", "Tower of Time", "Riviere Turquoise", + "Sunken Shrine", "Corrupted Future", "The Shop", "Music Box"}, + "Tower of Time": set(), + "Ninja Village": set(), + "Autumn Hills": {"Ninja Village", "Forlorn Temple", "Catacombs"}, + "Forlorn Temple": {"Catacombs", "Bamboo Creek"}, + "Catacombs": {"Autumn Hills", "Bamboo Creek", "Dark Cave"}, + "Bamboo Creek": {"Catacombs", "Howling Grotto"}, + "Howling Grotto": {"Bamboo Creek", "Quillshroom Marsh", "Sunken Shrine"}, + "Quillshroom Marsh": {"Howling Grotto", "Searing Crags"}, + "Searing Crags": {"Searing Crags Upper", "Quillshroom Marsh", "Underworld"}, + "Searing Crags Upper": {"Searing Crags", "Glacial Peak"}, + "Glacial Peak": {"Searing Crags Upper", "Tower HQ", "Cloud Ruins", "Elemental Skylands"}, + "Cloud Ruins": {"Underworld"}, + "Underworld": set(), + "Dark Cave": {"Catacombs", "Riviere Turquoise"}, + "Riviere Turquoise": set(), + "Sunken Shrine": {"Howling Grotto"}, + "Elemental Skylands": set(), +} +"""Vanilla layout mapping with all Tower HQ portals open. from -> to""" diff --git a/worlds/messenger/Rules.py b/worlds/messenger/Rules.py new file mode 100644 index 0000000000..a459fdb7d0 --- /dev/null +++ b/worlds/messenger/Rules.py @@ -0,0 +1,225 @@ +from typing import Dict, Callable, TYPE_CHECKING + +from BaseClasses import CollectionState, MultiWorld +from worlds.generic.Rules import set_rule, allow_self_locking_items, add_rule +from .Options import MessengerAccessibility, Goal +from .Constants import NOTES, PHOBEKINS + +if TYPE_CHECKING: + from . import MessengerWorld +else: + MessengerWorld = object + + +class MessengerRules: + player: int + world: MessengerWorld + region_rules: Dict[str, Callable[[CollectionState], bool]] + location_rules: Dict[str, Callable[[CollectionState], bool]] + + def __init__(self, world: MessengerWorld) -> None: + self.player = world.player + self.world = world + + self.region_rules = { + "Ninja Village": self.has_wingsuit, + "Autumn Hills": self.has_wingsuit, + "Catacombs": self.has_wingsuit, + "Bamboo Creek": self.has_wingsuit, + "Searing Crags Upper": self.has_vertical, + "Cloud Ruins": lambda state: self.has_wingsuit(state) and state.has("Ruxxtin's Amulet", self.player), + "Underworld": self.has_tabi, + "Forlorn Temple": lambda state: state.has_all({"Wingsuit", *PHOBEKINS}, self.player), + "Glacial Peak": self.has_vertical, + "Elemental Skylands": lambda state: state.has("Fairy Bottle", self.player), + "Music Box": lambda state: state.has_all(set(NOTES), self.player) and self.has_vertical(state), + } + + self.location_rules = { + # ninja village + "Ninja Village Seal - Tree House": self.has_dart, + # autumn hills + "Key of Hope": self.has_dart, + # howling grotto + "Howling Grotto Seal - Windy Saws and Balls": self.has_wingsuit, + "Howling Grotto Seal - Crushing Pits": lambda state: self.has_wingsuit(state) and self.has_dart(state), + # searing crags + "Astral Tea Leaves": lambda state: state.can_reach("Astral Seed", "Location", self.player), + "Key of Strength": lambda state: state.has("Power Thistle", self.player), + # glacial peak + "Glacial Peak Seal - Ice Climbers": self.has_dart, + "Glacial Peak Seal - Projectile Spike Pit": self.has_vertical, + "Glacial Peak Seal - Glacial Air Swag": self.has_vertical, + # tower of time + "Tower of Time Seal - Time Waster Seal": self.has_dart, + "Tower of Time Seal - Lantern Climb": self.has_wingsuit, + "Tower of Time Seal - Arcane Orbs": lambda state: self.has_wingsuit(state) and self.has_dart(state), + # underworld + "Underworld Seal - Sharp and Windy Climb": self.has_wingsuit, + "Underworld Seal - Fireball Wave": self.has_wingsuit, + "Underworld Seal - Rising Fanta": self.has_dart, + # sunken shrine + "Sun Crest": self.has_tabi, + "Moon Crest": self.has_tabi, + "Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), + "Sunken Shrine Seal - Waterfall Paradise": self.has_tabi, + "Sunken Shrine Seal - Tabi Gauntlet": self.has_tabi, + # riviere turquoise + "Fairy Bottle": self.has_vertical, + "Riviere Turquoise Seal - Flower Power": self.has_vertical, + # elemental skylands + "Key of Symbiosis": self.has_dart, + "Elemental Skylands Seal - Air": self.has_wingsuit, + "Elemental Skylands Seal - Water": self.has_dart, + "Elemental Skylands Seal - Fire": self.has_dart, + # corrupted future + "Key of Courage": lambda state: state.has_all({"Demon King Crown", "Fairy Bottle"}, self.player), + # the shop + "Shop Chest": self.has_enough_seals, + } + + def has_wingsuit(self, state: CollectionState) -> bool: + return state.has("Wingsuit", self.player) + + def has_dart(self, state: CollectionState) -> bool: + return state.has("Rope Dart", self.player) + + def has_tabi(self, state: CollectionState) -> bool: + return state.has("Ninja Tabi", self.player) + + def has_vertical(self, state: CollectionState) -> bool: + return self.has_wingsuit(state) or self.has_dart(state) + + def has_enough_seals(self, state: CollectionState) -> bool: + return not self.world.required_seals or state.has("Power Seal", self.player, self.world.required_seals) + + def true(self, state: CollectionState) -> bool: + """I know this is stupid, but it's easier to read in the dicts.""" + return True + + def set_messenger_rules(self) -> None: + multiworld = self.world.multiworld + + for region in multiworld.get_regions(self.player): + if region.name in self.region_rules: + for entrance in region.entrances: + entrance.access_rule = self.region_rules[region.name] + for loc in region.locations: + if loc.name in self.location_rules: + loc.access_rule = self.location_rules[loc.name] + if multiworld.goal[self.player] == Goal.option_power_seal_hunt: + set_rule(multiworld.get_entrance("Tower HQ -> Music Box", self.player), + lambda state: state.has("Shop Chest", self.player)) + + multiworld.completion_condition[self.player] = lambda state: state.has("Rescue Phantom", self.player) + if multiworld.accessibility[self.player] > MessengerAccessibility.option_locations: + set_self_locking_items(multiworld, self.player) + + +class MessengerHardRules(MessengerRules): + extra_rules: Dict[str, Callable[[CollectionState], bool]] + + def __init__(self, world: MessengerWorld) -> None: + super().__init__(world) + + self.region_rules.update({ + "Ninja Village": self.has_vertical, + "Autumn Hills": self.has_vertical, + "Catacombs": self.has_vertical, + "Bamboo Creek": self.has_vertical, + "Forlorn Temple": lambda state: self.has_vertical(state) and state.has_all(set(PHOBEKINS), self.player), + "Searing Crags Upper": self.true, + "Glacial Peak": self.true, + }) + + self.location_rules.update({ + "Howling Grotto Seal - Windy Saws and Balls": self.true, + "Glacial Peak Seal - Projectile Spike Pit": self.true, + "Claustro": self.has_wingsuit, + }) + + self.extra_rules = { + "Key of Strength": lambda state: self.has_dart(state) or self.has_windmill(state), + "Key of Symbiosis": self.has_windmill, + "Autumn Hills Seal - Spike Ball Darts": lambda state: (self.has_dart(state) and self.has_windmill(state)) + or self.has_wingsuit(state), + "Glacial Peak Seal - Glacial Air Swag": self.has_windmill, + "Underworld Seal - Fireball Wave": lambda state: state.has_all({"Ninja Tabi", "Windmill Shuriken"}, + self.player), + } + + def has_windmill(self, state: CollectionState) -> bool: + return state.has("Windmill Shuriken", self.player) + + def set_messenger_rules(self) -> None: + super().set_messenger_rules() + for loc, rule in self.extra_rules.items(): + if not self.world.multiworld.shuffle_seals[self.player] and "Seal" in loc: + continue + add_rule(self.world.multiworld.get_location(loc, self.player), rule, "or") + + +class MessengerChallengeRules(MessengerHardRules): + def __init__(self, world: MessengerWorld) -> None: + super().__init__(world) + + self.region_rules.update({ + "Forlorn Temple": lambda state: (self.has_vertical(state) and state.has_all(set(PHOBEKINS), self.player)) + or state.has_all({"Wingsuit", "Windmill Shuriken"}, self.player), + "Elemental Skylands": lambda state: self.has_wingsuit(state) or state.has("Fairy Bottle", self.player), + }) + + self.location_rules.update({ + "Fairy Bottle": self.true, + "Howling Grotto Seal - Crushing Pits": self.true, + "Underworld Seal - Sharp and Windy Climb": self.true, + "Riviere Turquoise Seal - Flower Power": self.true, + }) + + self.extra_rules.update({ + "Key of Hope": self.has_vertical, + "Key of Symbiosis": lambda state: self.has_vertical(state) or self.has_windmill(state), + }) + + +class MessengerOOBRules(MessengerRules): + def __init__(self, world: MessengerWorld) -> None: + self.world = world + self.player = world.player + + self.region_rules = { + "Elemental Skylands": lambda state: state.has_any({"Wingsuit", "Rope Dart", "Fairy Bottle"}, self.player), + "Music Box": lambda state: state.has_all(set(NOTES), self.player), + } + + self.location_rules = { + "Claustro": self.has_wingsuit, + "Key of Strength": lambda state: self.has_vertical(state) or state.has("Power Thistle", self.player), + "Key of Love": lambda state: state.has_all({"Sun Crest", "Moon Crest"}, self.player), + "Pyro": self.has_tabi, + "Key of Chaos": self.has_tabi, + "Key of Courage": lambda state: state.has_all({"Demon King Crown", "Fairy Bottle"}, self.player), + "Autumn Hills Seal - Spike Ball Darts": self.has_dart, + "Ninja Village Seal - Tree House": self.has_dart, + "Underworld Seal - Fireball Wave": lambda state: state.has_any({"Wingsuit", "Windmill Shuriken"}, + self.player), + "Tower of Time Seal - Time Waster Seal": self.has_dart, + "Shop Chest": self.has_enough_seals, + } + + def set_messenger_rules(self) -> None: + super().set_messenger_rules() + self.world.multiworld.completion_condition[self.player] = lambda state: True + self.world.multiworld.accessibility[self.player].value = MessengerAccessibility.option_minimal + + +def set_self_locking_items(multiworld: MultiWorld, player: int) -> None: + # do the ones for seal shuffle on and off first + allow_self_locking_items(multiworld.get_location("Key of Strength", player), "Power Thistle") + allow_self_locking_items(multiworld.get_location("Key of Love", player), "Sun Crest", "Moon Crest") + allow_self_locking_items(multiworld.get_location("Key of Courage", player), "Demon King Crown") + + # add these locations when seals aren't shuffled + if not multiworld.shuffle_seals[player]: + allow_self_locking_items(multiworld.get_region("Cloud Ruins", player), "Ruxxtin's Amulet") + allow_self_locking_items(multiworld.get_region("Forlorn Temple", player), *PHOBEKINS) diff --git a/worlds/messenger/SubClasses.py b/worlds/messenger/SubClasses.py new file mode 100644 index 0000000000..1f26a4265b --- /dev/null +++ b/worlds/messenger/SubClasses.py @@ -0,0 +1,58 @@ +from typing import Set, TYPE_CHECKING, Optional, Dict + +from BaseClasses import Region, Location, Item, ItemClassification, Entrance +from .Constants import SEALS, NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS +from .Options import Goal +from .Regions import REGIONS + +if TYPE_CHECKING: + from . import MessengerWorld +else: + MessengerWorld = object + + +class MessengerRegion(Region): + def __init__(self, name: str, world: MessengerWorld) -> None: + super().__init__(name, world.player, world.multiworld) + self.add_locations(self.multiworld.worlds[self.player].location_name_to_id) + world.multiworld.regions.append(self) + + def add_locations(self, name_to_id: Dict[str, int]) -> None: + for loc in REGIONS[self.name]: + self.locations.append(MessengerLocation(loc, self, name_to_id.get(loc, None))) + if self.name == "The Shop" and self.multiworld.goal[self.player] > Goal.option_open_music_box: + self.locations.append(MessengerLocation("Shop Chest", self, name_to_id.get("Shop Chest", None))) + # putting some dumb special case for searing crags and ToT so i can split them into 2 regions + if self.multiworld.shuffle_seals[self.player] and self.name not in {"Searing Crags", "Tower HQ"}: + for seal_loc in SEALS: + if seal_loc.startswith(self.name.split(" ")[0]): + self.locations.append(MessengerLocation(seal_loc, self, name_to_id.get(seal_loc, None))) + + def add_exits(self, exits: Set[str]) -> None: + for exit in exits: + ret = Entrance(self.player, f"{self.name} -> {exit}", self) + self.exits.append(ret) + ret.connect(self.multiworld.get_region(exit, self.player)) + + +class MessengerLocation(Location): + game = "The Messenger" + + def __init__(self, name: str, parent: MessengerRegion, loc_id: Optional[int]) -> None: + super().__init__(parent.player, name, loc_id, parent) + if loc_id is None: + self.place_locked_item(MessengerItem(name, parent.player, None)) + + +class MessengerItem(Item): + game = "The Messenger" + + def __init__(self, name: str, player: int, item_id: Optional[int] = None, override_progression: bool = False) -> None: + if name in {*NOTES, *PROG_ITEMS, *PHOBEKINS} or item_id is None or override_progression: + item_class = ItemClassification.progression + elif name in USEFUL_ITEMS: + item_class = ItemClassification.useful + else: + item_class = ItemClassification.filler + super().__init__(name, item_class, item_id, player) + diff --git a/worlds/messenger/__init__.py b/worlds/messenger/__init__.py new file mode 100644 index 0000000000..d23f4da34f --- /dev/null +++ b/worlds/messenger/__init__.py @@ -0,0 +1,136 @@ +from typing import Dict, Any, List, Optional + +from BaseClasses import Tutorial, ItemClassification +from worlds.AutoWorld import World, WebWorld +from .Constants import NOTES, PROG_ITEMS, PHOBEKINS, USEFUL_ITEMS, ALWAYS_LOCATIONS, SEALS, ALL_ITEMS +from .Options import messenger_options, NotesNeeded, Goal, PowerSeals, Logic +from .Regions import REGIONS, REGION_CONNECTIONS +from .SubClasses import MessengerRegion, MessengerItem +from . import Rules + + +class MessengerWeb(WebWorld): + theme = "ocean" + + bug_report_page = "https://github.com/alwaysintreble/TheMessengerRandomizerModAP/issues" + + tut_en = Tutorial( + "Multiworld Setup Tutorial", + "A guide to setting up The Messenger randomizer on your computer.", + "English", + "setup_en.md", + "setup/en", + ["alwaysintreble"], + ) + + tutorials = [tut_en] + + +class MessengerWorld(World): + """ + As a demon army besieges his village, a young ninja ventures through a cursed world, to deliver a scroll paramount + to his clanâs survival. What begins as a classic action platformer soon unravels into an expansive time-traveling + adventure full of thrills, surprises, and humor. + """ + game = "The Messenger" + + item_name_groups = { + "Notes": set(NOTES), + "Keys": set(NOTES), + "Crest": {"Sun Crest", "Moon Crest"}, + "Phobe": set(PHOBEKINS), + "Phobekin": set(PHOBEKINS), + "Shuriken": {"Windmill Shuriken"}, + } + + option_definitions = messenger_options + + base_offset = 0xADD_000 + item_name_to_id = {item: item_id + for item_id, item in enumerate(ALL_ITEMS, base_offset)} + location_name_to_id = {location: location_id + for location_id, location in enumerate([*ALWAYS_LOCATIONS, *SEALS], base_offset)} + + data_version = 1 + + web = MessengerWeb() + + total_seals: Optional[int] = None + required_seals: Optional[int] = None + + def generate_early(self) -> None: + if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + self.multiworld.shuffle_seals[self.player].value = PowerSeals.option_true + self.total_seals = self.multiworld.total_seals[self.player].value + self.required_seals = int(self.multiworld.percent_seals_required[self.player].value / 100 * self.total_seals) + + def create_regions(self) -> None: + for region in [MessengerRegion(reg_name, self) for reg_name in REGIONS]: + if region.name in REGION_CONNECTIONS: + region.add_exits(REGION_CONNECTIONS[region.name]) + + def create_items(self) -> None: + itempool: List[MessengerItem] = [] + if self.multiworld.goal[self.player] == Goal.option_power_seal_hunt: + seals = [self.create_item("Power Seal") for _ in range(self.total_seals)] + for i in range(self.required_seals): + seals[i].classification = ItemClassification.progression_skip_balancing + itempool += seals + else: + notes = self.multiworld.random.sample(NOTES, k=len(NOTES)) + precollected_notes_amount = NotesNeeded.range_end - self.multiworld.notes_needed[self.player] + if precollected_notes_amount: + for note in notes[:precollected_notes_amount]: + self.multiworld.push_precollected(self.create_item(note)) + itempool += [self.create_item(note) for note in notes[precollected_notes_amount:]] + + itempool += [self.create_item(item) + for item in self.item_name_to_id + if item not in + { + "Power Seal", "Time Shard", *NOTES, + *{collected_item.name for collected_item in self.multiworld.precollected_items[self.player]}, + # this is a set and currently won't create items for anything that appears in here at all + # if we get in a position where this can have duplicates of items that aren't Power Seals + # or Time shards, this will need to be redone. + }] + itempool += [self.create_filler() + for _ in range(len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool))] + + self.multiworld.itempool += itempool + + def set_rules(self) -> None: + logic = self.multiworld.logic_level[self.player] + if logic == Logic.option_normal: + Rules.MessengerRules(self).set_messenger_rules() + elif logic == Logic.option_hard: + Rules.MessengerHardRules(self).set_messenger_rules() + elif logic == Logic.option_challenging: + Rules.MessengerChallengeRules(self).set_messenger_rules() + else: + Rules.MessengerOOBRules(self).set_messenger_rules() + + def fill_slot_data(self) -> Dict[str, Any]: + locations: Dict[int, List[str]] = {} + for loc in self.multiworld.get_filled_locations(self.player): + if loc.item.code: + locations[loc.address] = [loc.item.name, self.multiworld.player_name[loc.item.player]] + + return { + "deathlink": self.multiworld.death_link[self.player].value, + "goal": self.multiworld.goal[self.player].current_key, + "music_box": self.multiworld.music_box[self.player].value, + "required_seals": self.required_seals, + "locations": locations, + "settings": {"Difficulty": "Basic" if not self.multiworld.shuffle_seals[self.player] else "Advanced"}, + "logic": self.multiworld.logic_level[self.player].current_key, + } + + def get_filler_item_name(self) -> str: + return "Time Shard" + + def create_item(self, name: str) -> MessengerItem: + item_id: Optional[int] = self.item_name_to_id.get(name, None) + override_prog = name in {"Windmill Shuriken"} and getattr(self, "multiworld") is not None \ + and self.multiworld.logic_level[self.player] > Logic.option_normal + return MessengerItem(name, self.player, item_id, override_prog) diff --git a/worlds/messenger/docs/en_The Messenger.md b/worlds/messenger/docs/en_The Messenger.md new file mode 100644 index 0000000000..e25be4b907 --- /dev/null +++ b/worlds/messenger/docs/en_The Messenger.md @@ -0,0 +1,77 @@ +# The Messenger + +## Quick Links +- [Setup](../../../../tutorial/The%20Messenger/setup/en) +- [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Courier Github](https://github.com/Brokemia/Courier) +- [The Messenger Randomizer Github](https://github.com/minous27/TheMessengerRandomizerMod) +- [The Messenger Randomizer AP Github](https://github.com/alwaysintreble/TheMessengerRandomizerModAP) +- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) +- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack) + +## What does randomization do in this game? + +All items and upgrades that can be picked up by the player in the game are randomized. The player starts in the Tower of +Time HQ with the past section finished, all area portals open, and with the cloud step, and climbing claws already +obtained. You'll be forced to do sections of the game in different ways with your current abilities. Currently, logic +assumes you already have all shop upgrades. + +## What items can appear in other players' worlds? + +* The player's movement items +* Quest and pedestal items +* Music Box notes +* The Phobekins +* Time shards +* Power Seals + +## Where can I find items? + +You can find items wherever items can be picked up in the original game. This includes: +* Shopkeeper dialog where the player originally gains movement items +* Quest Item pickups +* Music Box notes +* Phobekins +* Power seals + +## What are the item name groups? + +When you attempt to hint for items in Archipelago you can use either the name for the specific item, or the name of a +group of items. Hinting for a group will choose a random item from the group that you do not currently have and hint +for it. The groups you can use for The Messenger are: +* Notes - This covers the music notes +* Keys - An alternative name for the music notes +* Crest - The Sun and Moon Crests +* Phobekin - Any of the Phobekins +* Phobe - An alternative name for the Phobekins +* Shuriken - The windmill shuriken + +## Other changes + +* The player can return to the Tower of Time HQ at any point by selecting the button from the options menu + * This can cause issues if used at specific times. Current known: + * During Boss fights + * After Courage Note collection (Corrupted Future chase) + * This is currently an expected action in logic. If you do need to teleport during this chase sequence, it + is recommended to quit to title and reload the save +* After reaching ninja village a teleport option is added to the menu to reach it quickly +* Toggle Windmill Shuriken button is added to option menu once the item is received +* The mod option menu will also have a hint item button, as well as a release and collect button that are all placed when + the player fulfills the necessary conditions. + +## Currently known issues +* Necro cutscene will sometimes not play correctly, but will still reward the item +* Ruxxtin Coffin cutscene will sometimes not play correctly, but will still reward the item +* If you receive the Fairy Bottle while in Quillshroom Marsh, The De-curse Queen cutscene will not play. You can exit + to Searing Crags and re-enter to get it to play correctly. +* If you defeat Barma'thazÃĢl, the cutscene afterward will not play correctly since that is what normally transitions + you to 2nd quest. The game will not kill you if you fall here, so you can teleport to HQ at any point after defeating him. +* Sometimes upon teleporting back to HQ, Ninja will run left and enter a different portal than the one entered by the + player. This may also cause a softlock. +* Text entry menus don't accept controller input +* Opening the shop chest in power seal hunt mode from the tower of time HQ will softlock the game. + +## What do I do if I have a problem? + +If you believe something happened that isn't intended, please get the `log.txt`from the folder of your game installation +and send a bug report either on GitHub or the [Archipelago Discord Server](http://archipelago.gg/discord) diff --git a/worlds/messenger/docs/setup_en.md b/worlds/messenger/docs/setup_en.md new file mode 100644 index 0000000000..0a57c2b83f --- /dev/null +++ b/worlds/messenger/docs/setup_en.md @@ -0,0 +1,65 @@ +# The Messenger Randomizer Setup Guide + +## Quick Links +- [Game Info](../../../../games/The%20Messenger/info/en) +- [Settings Page](../../../../games/The%20Messenger/player-settings) +- [Courier Github](https://github.com/Brokemia/Courier) +- [The Messenger Randomizer AP Github](https://github.com/alwaysintreble/TheMessengerRandomizerModAP) +- [Jacksonbird8237's Item Tracker](https://github.com/Jacksonbird8237/TheMessengerItemTracker) +- [PopTracker Pack](https://github.com/alwaysintreble/TheMessengerTrackPack) + +## Installation + +1. Read the [Game Info Page](../../../../games/The%20Messenger/info/en) for how the game works, caveats and known issues +2. Download and install Courier Mod Loader using the instructions on the release page + * [Latest release is currently 0.7.1](https://github.com/Brokemia/Courier/releases) +3. Download and install the randomizer mod + 1. Download the latest TheMessengerRandomizerAP.zip from + [The Messenger Randomizer Mod AP releases page](https://github.com/alwaysintreble/TheMessengerRandomizerModAP/releases) + 2. Extract the zip file to `TheMessenger/Mods/` of your game's install location + * You cannot have both the non-AP randomizer and the AP randomizer installed at the same time. The AP randomizer + is backwards compatible, so the non-AP mod can be safely removed, and you can still play seeds generated from the + non-AP randomizer. + 3. Optionally, Backup your save game + * On Windows + 1. Press `Windows Key + R` to open run + 2. Type `%appdata%` to access AppData + 3. Navigate to `AppData/locallow/SabotageStudios/The Messenger` + 4. Rename `SaveGame.txt` to any name of your choice + * On Linux + 1. Navigate to `steamapps/compatdata/764790/pfx/drive_c/users/steamuser/AppData/LocalLow/Sabotage Studio/The Messenger` + 2. Rename `SaveGame.txt` to any name of your choice + +## Joining a MultiWorld Game + +1. Launch the game +2. Navigate to `Options > Third Party Mod Options` +3. Select `Reset Randomizer File Slots` + * This will set up all of your save slots with new randomizer save files. You can have up to 3 randomizer files at a + time, but must do this step again to start new runs afterward. +4. Enter connection info using the relevant option buttons + * **The game is limited to alphanumerical characters and `-` so when entering the host name replace `.` with ` `. + Ensure that your player name when generating a settings file follows these constrictions** + * This defaults to `archipelago.gg` and does not need to be manually changed if connecting to a game hosted on the + website. +5. Select the `Connect to Archipelago` button +6. Navigate to save file selection +7. Select a new valid randomizer save + +## Continuing a MultiWorld Game + +At any point while playing, it is completely safe to quit. Returning to the title screen or closing the game will +disconnect you from the server. To reconnect to an in progress MultiWorld, simply load the correct save file for that +MultiWorld. + +If the reconnection fails, the message on screen will state you are disconnected. If this happens, you can return to the +main menu and connect to the server as in [Joining a Multiworld Game](#joining-a-multiworld-game), then load the correct +save file. + +## Troubleshooting + +If you launch the game, and it hangs on the splash screen for more than 30 seconds try these steps: +1. Close the game and remove `TheMessengerRandomizerAP` from the `Mods` folder. +2. Launch The Messenger +3. Delete any save slot +4. Reinstall the randomizer mod following step 2 of the installation. \ No newline at end of file diff --git a/worlds/messenger/test/TestAccess.py b/worlds/messenger/test/TestAccess.py new file mode 100644 index 0000000000..84b29406c2 --- /dev/null +++ b/worlds/messenger/test/TestAccess.py @@ -0,0 +1,137 @@ +from . import MessengerTestBase +from ..Constants import NOTES, PHOBEKINS + + +class AccessTest(MessengerTestBase): + + def testTabi(self) -> None: + """locations that hard require the Ninja Tabi""" + locations = ["Pyro", "Key of Chaos", "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Spike Wall", + "Underworld Seal - Fireball Wave", "Underworld Seal - Rising Fanta", "Sun Crest", "Moon Crest", + "Sunken Shrine Seal - Waterfall Paradise", "Sunken Shrine Seal - Tabi Gauntlet"] + items = [["Ninja Tabi"]] + self.assertAccessDependency(locations, items) + + def testDart(self) -> None: + """locations that hard require the Rope Dart""" + locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal", + "Tower of Time Seal - Arcane Orbs", "Underworld Seal - Rising Fanta", "Key of Symbiosis", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire"] + items = [["Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testWingsuit(self) -> None: + """locations that hard require the Wingsuit""" + locations = ["Candle", "Ninja Village Seal - Tree House", "Climbing Claws", "Key of Hope", + "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", "Necro", + "Ruxxtin's Amulet", "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls", + "Tower of Time Seal - Lantern Climb", "Demon King Crown", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", + "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave", + "Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", + "Forlorn Temple Seal - Rocket Sunset", "Astral Seed", "Astral Tea Leaves"] + items = [["Wingsuit"]] + self.assertAccessDependency(locations, items) + + def testVertical(self) -> None: + """locations that require either the Rope Dart or the Wingsuit""" + locations = ["Ninja Village Seal - Tree House", "Key of Hope", "Howling Grotto Seal - Crushing Pits", + "Glacial Peak Seal - Ice Climbers", "Tower of Time Seal - Time Waster Seal", + "Underworld Seal - Rising Fanta", "Key of Symbiosis", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire", "Candle", + "Climbing Claws", "Key of Hope", "Autumn Hills Seal - Trip Saws", + "Autumn Hills Seal - Double Swing Saws", "Autumn Hills Seal - Spike Ball Swing", + "Autumn Hills Seal - Spike Ball Darts", "Necro", "Ruxxtin's Amulet", + "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", + "Catacombs Seal - Dirty Pond", "Claustro", "Acro", "Bamboo Creek Seal - Spike Crushers and Doors", + "Bamboo Creek Seal - Spike Ball Pits", "Bamboo Creek Seal - Spike Crushers and Doors v2", + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Windy Saws and Balls", + "Demon King Crown", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + "Tower of Time Seal - Lantern Climb", "Tower of Time Seal - Arcane Orbs", + "Underworld Seal - Sharp and Windy Climb", "Underworld Seal - Fireball Wave", + "Elemental Skylands Seal - Air", "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + "Power Thistle", "Key of Strength", "Glacial Peak Seal - Projectile Spike Pit", + "Glacial Peak Seal - Glacial Air Swag", "Fairy Bottle", "Riviere Turquoise Seal - Flower Power", + "Searing Crags Seal - Triple Ball Spinner", "Searing Crags Seal - Raining Rocks", + "Searing Crags Seal - Rhythm Rocks", "Astral Seed", "Astral Tea Leaves", "Rescue Phantom"] + items = [["Wingsuit", "Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testAmulet(self) -> None: + """Locations that require Ruxxtin's Amulet""" + locations = ["Acro", "Cloud Ruins Seal - Ghost Pit", "Cloud Ruins Seal - Toothbrush Alley", + "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room"] + # Cloud Ruins requires Ruxxtin's Amulet + items = [["Ruxxtin's Amulet"]] + self.assertAccessDependency(locations, items) + + def testBottle(self) -> None: + """Elemental Skylands and Corrupted Future require the Fairy Bottle""" + locations = ["Key of Symbiosis", "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Fire", + "Elemental Skylands Seal - Water", "Key of Courage"] + items = [["Fairy Bottle"]] + self.assertAccessDependency(locations, items) + + def testCrests(self) -> None: + """Test Key of Love nonsense""" + locations = ["Key of Love"] + items = [["Sun Crest", "Moon Crest"]] + self.assertAccessDependency(locations, items) + self.collect_all_but("Sun Crest") + self.assertEqual(self.can_reach_location("Key of Love"), False) + self.remove(self.get_item_by_name("Moon Crest")) + self.collect_by_name("Sun Crest") + self.assertEqual(self.can_reach_location("Key of Love"), False) + + def testThistle(self) -> None: + """I'm a chuckster!""" + locations = ["Key of Strength"] + items = [["Power Thistle"]] + self.assertAccessDependency(locations, items) + + def testCrown(self) -> None: + """Crocomire but not""" + locations = ["Key of Courage"] + items = [["Demon King Crown"]] + self.assertAccessDependency(locations, items) + + def testGoal(self) -> None: + """Test some different states to verify goal requires the correct items""" + self.collect_all_but([*NOTES, "Rescue Phantom"]) + self.assertEqual(self.can_reach_location("Rescue Phantom"), False) + self.collect_all_but(["Key of Love", "Rescue Phantom"]) + self.assertBeatable(False) + self.collect_by_name(["Key of Love"]) + self.assertEqual(self.can_reach_location("Rescue Phantom"), True) + self.assertBeatable(True) + + +class ItemsAccessTest(MessengerTestBase): + options = { + "shuffle_seals": "false", + "accessibility": "items", + } + + def testSelfLockingItems(self) -> None: + """Force items that can be self locked to ensure it's valid placement.""" + location_lock_pairs = { + "Key of Strength": ["Power Thistle"], + "Key of Love": ["Sun Crest", "Moon Crest"], + "Key of Courage": ["Demon King Crown"], + "Acro": ["Ruxxtin's Amulet"], + "Demon King Crown": PHOBEKINS + } + + for loc in location_lock_pairs: + for item_name in location_lock_pairs[loc]: + item = self.get_item_by_name(item_name) + with self.subTest("Fulfills Accessibility", location=loc, item=item_name): + self.assertTrue(self.multiworld.get_location(loc, self.player).can_fill(self.multiworld.state, item, True)) + diff --git a/worlds/messenger/test/TestLogic.py b/worlds/messenger/test/TestLogic.py new file mode 100644 index 0000000000..2fd8111030 --- /dev/null +++ b/worlds/messenger/test/TestLogic.py @@ -0,0 +1,108 @@ +from BaseClasses import ItemClassification +from . import MessengerTestBase + + +class HardLogicTest(MessengerTestBase): + options = { + "logic_level": "hard", + } + + def testVertical(self) -> None: + """Test the locations that still require wingsuit or rope dart.""" + locations = [ + # tower of time + "Tower of Time Seal - Time Waster Seal", "Tower of Time Seal - Lantern Climb", + "Tower of Time Seal - Arcane Orbs", + # ninja village + "Candle", "Astral Seed", "Ninja Village Seal - Tree House", "Astral Tea Leaves", + # autumn hills + "Climbing Claws", "Key of Hope", + "Autumn Hills Seal - Trip Saws", "Autumn Hills Seal - Double Swing Saws", + "Autumn Hills Seal - Spike Ball Swing", "Autumn Hills Seal - Spike Ball Darts", + # forlorn temple + "Demon King Crown", + "Forlorn Temple Seal - Rocket Maze", "Forlorn Temple Seal - Rocket Sunset", + # catacombs + "Necro", "Ruxxtin's Amulet", + "Catacombs Seal - Triple Spike Crushers", "Catacombs Seal - Crusher Gauntlet", "Catacombs Seal - Dirty Pond", + # bamboo creek + "Claustro", + "Bamboo Creek Seal - Spike Crushers and Doors", "Bamboo Creek Seal - Spike Ball Pits", + "Bamboo Creek Seal - Spike Crushers and Doors v2", + # howling grotto + "Howling Grotto Seal - Crushing Pits", "Howling Grotto Seal - Crushing Pits", + # glacial peak + "Glacial Peak Seal - Ice Climbers", + # cloud ruins + "Acro", "Cloud Ruins Seal - Ghost Pit", + "Cloud Ruins Seal - Toothbrush Alley", "Cloud Ruins Seal - Saw Pit", "Cloud Ruins Seal - Money Farm Room", + # underworld + "Underworld Seal - Rising Fanta", "Underworld Seal - Sharp and Windy Climb", + # riviere turquoise + "Fairy Bottle", "Riviere Turquoise Seal - Flower Power", + # elemental skylands + "Elemental Skylands Seal - Air", "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire", + # phantom + "Rescue Phantom", + ] + items = [["Wingsuit", "Rope Dart"]] + self.assertAccessDependency(locations, items) + + def testWindmill(self) -> None: + """Windmill Shuriken isn't progression on normal difficulty, so test it's marked correctly and required.""" + self.assertEqual(ItemClassification.progression, self.get_item_by_name("Windmill Shuriken").classification) + windmill_locs = [ + "Key of Strength", + "Key of Symbiosis", + "Underworld Seal - Fireball Wave", + ] + for loc in windmill_locs: + with self.subTest("can't reach location with nothing", location=loc): + self.assertFalse(self.can_reach_location(loc)) + + items = self.get_items_by_name(["Windmill Shuriken", "Ninja Tabi", "Fairy Bottle"]) + self.collect(items) + for loc in windmill_locs: + with self.subTest("can reach with Windmill", location=loc): + self.assertTrue(self.can_reach_location(loc)) + + special_loc = "Autumn Hills Seal - Spike Ball Darts" + item = self.get_item_by_name("Wingsuit") + self.collect(item) + self.assertTrue(self.can_reach_location(special_loc)) + self.remove(item) + + item = self.get_item_by_name("Rope Dart") + self.collect(item) + self.assertTrue(self.can_reach_location(special_loc)) + + +class ChallengingLogicTest(MessengerTestBase): + options = { + "shuffle_seals": "false", + "logic_level": "challenging", + } + + +class NoLogicTest(MessengerTestBase): + options = { + "logic_level": "oob", + } + + def testAccess(self) -> None: + """Test the locations with rules still require things.""" + all_locations = [ + "Claustro", "Key of Strength", "Key of Symbiosis", "Key of Love", "Pyro", "Key of Chaos", "Key of Courage", + "Autumn Hills Seal - Spike Ball Darts", "Ninja Village Seal - Tree House", "Underworld Seal - Fireball Wave", + "Tower of Time Seal - Time Waster Seal", "Rescue Phantom", "Elemental Skylands Seal - Air", + "Elemental Skylands Seal - Water", "Elemental Skylands Seal - Fire", + ] + for loc in all_locations: + with self.subTest("Default unreachables", location=loc): + self.assertFalse(self.can_reach_location(loc)) + + def testNoLogic(self) -> None: + """Test some funny locations to make sure they aren't reachable, but we can still win""" + self.assertEqual(self.can_reach_location("Pyro"), False) + self.assertEqual(self.can_reach_location("Rescue Phantom"), False) + self.assertBeatable(True) diff --git a/worlds/messenger/test/TestNotes.py b/worlds/messenger/test/TestNotes.py new file mode 100644 index 0000000000..c4292e4900 --- /dev/null +++ b/worlds/messenger/test/TestNotes.py @@ -0,0 +1,35 @@ +from . import MessengerTestBase +from ..Constants import NOTES + + +class TwoNoteGoalTest(MessengerTestBase): + options = { + "notes_needed": 2, + } + + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 4) + + +class FourNoteGoalTest(MessengerTestBase): + options = { + "notes_needed": 4, + } + + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 2) + + +class DefaultGoalTest(MessengerTestBase): + def testPrecollectedNotes(self) -> None: + self.assertEqual(self.multiworld.state.count_group("Notes", self.player), 0) + + def testGoal(self) -> None: + self.assertBeatable(False) + self.collect_by_name(NOTES) + rope_dart = self.get_item_by_name("Rope Dart") + self.collect(rope_dart) + self.assertBeatable(True) + self.remove(rope_dart) + self.collect_by_name("Wingsuit") + self.assertBeatable(True) diff --git a/worlds/messenger/test/TestShopChest.py b/worlds/messenger/test/TestShopChest.py new file mode 100644 index 0000000000..9289ec9970 --- /dev/null +++ b/worlds/messenger/test/TestShopChest.py @@ -0,0 +1,79 @@ +from BaseClasses import ItemClassification, CollectionState +from . import MessengerTestBase + + +class NoLogicTest(MessengerTestBase): + options = { + "logic_level": "oob", + "goal": "power_seal_hunt", + } + + def testChestAccess(self) -> None: + """Test to make sure we can win even though we can't reach the chest.""" + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(True) + + +class AllSealsRequired(MessengerTestBase): + options = { + "shuffle_seals": "false", + "goal": "power_seal_hunt", + } + + def testSealsShuffled(self) -> None: + """Shuffle seals should be forced on when shop chest is the goal so test it.""" + self.assertTrue(self.multiworld.shuffle_seals[self.player]) + + def testChestAccess(self) -> None: + """Defaults to a total of 45 power seals in the pool and required.""" + with self.subTest("Access Dependency"): + self.assertEqual(len([seal for seal in self.multiworld.itempool if seal.name == "Power Seal"]), + self.multiworld.total_seals[self.player]) + locations = ["Shop Chest"] + items = [["Power Seal"]] + self.assertAccessDependency(locations, items) + self.multiworld.state = CollectionState(self.multiworld) + + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(False) + self.collect_all_but(["Power Seal", "Shop Chest", "Rescue Phantom"]) + self.assertEqual(self.can_reach_location("Shop Chest"), False) + self.assertBeatable(False) + self.collect_by_name("Power Seal") + self.assertEqual(self.can_reach_location("Shop Chest"), True) + self.assertBeatable(True) + + +class HalfSealsRequired(MessengerTestBase): + options = { + "goal": "power_seal_hunt", + "percent_seals_required": 50, + } + + def testSealsAmount(self) -> None: + """Should have 45 power seals in the item pool and half that required""" + self.assertEqual(self.multiworld.total_seals[self.player], 45) + self.assertEqual(self.multiworld.worlds[self.player].total_seals, 45) + self.assertEqual(self.multiworld.worlds[self.player].required_seals, 22) + total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] + required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + self.assertEqual(len(total_seals), 45) + self.assertEqual(len(required_seals), 22) + + +class ThirtyThirtySeals(MessengerTestBase): + options = { + "goal": "power_seal_hunt", + "total_seals": 30, + "percent_seals_required": 34, + } + + def testSealsAmount(self) -> None: + """Should have 30 power seals in the pool and 33 percent of that required.""" + self.assertEqual(self.multiworld.total_seals[self.player], 30) + self.assertEqual(self.multiworld.worlds[self.player].total_seals, 30) + self.assertEqual(self.multiworld.worlds[self.player].required_seals, 10) + total_seals = [seal for seal in self.multiworld.itempool if seal.name == "Power Seal"] + required_seals = [seal for seal in total_seals if seal.classification == ItemClassification.progression_skip_balancing] + self.assertEqual(len(total_seals), 30) + self.assertEqual(len(required_seals), 10) diff --git a/worlds/messenger/test/__init__.py b/worlds/messenger/test/__init__.py new file mode 100644 index 0000000000..7ab1e11781 --- /dev/null +++ b/worlds/messenger/test/__init__.py @@ -0,0 +1,6 @@ +from test.TestBase import WorldTestBase + + +class MessengerTestBase(WorldTestBase): + game = "The Messenger" + player: int = 1 diff --git a/worlds/minecraft/Constants.py b/worlds/minecraft/Constants.py new file mode 100644 index 0000000000..0d1101e802 --- /dev/null +++ b/worlds/minecraft/Constants.py @@ -0,0 +1,26 @@ +import os +import json +import pkgutil + +def load_data_file(*args) -> dict: + fname = os.path.join("data", *args) + return json.loads(pkgutil.get_data(__name__, fname).decode()) + +# For historical reasons, these values are different. +# They remain different to ensure datapackage consistency. +# Do not separate other games' location and item IDs like this. +item_id_offset: int = 45000 +location_id_offset: int = 42000 + +item_info = load_data_file("items.json") +item_name_to_id = {name: item_id_offset + index \ + for index, name in enumerate(item_info["all_items"])} +item_name_to_id["Bee Trap"] = item_id_offset + 100 # historical reasons + +location_info = load_data_file("locations.json") +location_name_to_id = {name: location_id_offset + index \ + for index, name in enumerate(location_info["all_locations"])} + +exclusion_info = load_data_file("excluded_locations.json") + +region_info = load_data_file("regions.json") diff --git a/worlds/minecraft/ItemPool.py b/worlds/minecraft/ItemPool.py new file mode 100644 index 0000000000..78eeffca80 --- /dev/null +++ b/worlds/minecraft/ItemPool.py @@ -0,0 +1,52 @@ +from math import ceil +from typing import List + +from BaseClasses import MultiWorld, Item +from worlds.AutoWorld import World + +from . import Constants + +def get_junk_item_names(rand, k: int) -> str: + junk_weights = Constants.item_info["junk_weights"] + junk = rand.choices( + list(junk_weights.keys()), + weights=list(junk_weights.values()), + k=k) + return junk + +def build_item_pool(mc_world: World) -> List[Item]: + multiworld = mc_world.multiworld + player = mc_world.player + + itempool = [] + total_location_count = len(multiworld.get_unfilled_locations(player)) + + required_pool = Constants.item_info["required_pool"] + junk_weights = Constants.item_info["junk_weights"] + + # Add required progression items + for item_name, num in required_pool.items(): + itempool += [mc_world.create_item(item_name) for _ in range(num)] + + # Add structure compasses + if multiworld.structure_compasses[player]: + compasses = [name for name in mc_world.item_name_to_id if "Structure Compass" in name] + for item_name in compasses: + itempool.append(mc_world.create_item(item_name)) + + # Dragon egg shards + if multiworld.egg_shards_required[player] > 0: + num = multiworld.egg_shards_available[player] + itempool += [mc_world.create_item("Dragon Egg Shard") for _ in range(num)] + + # Bee traps + bee_trap_percentage = multiworld.bee_traps[player] * 0.01 + if bee_trap_percentage > 0: + bee_trap_qty = ceil(bee_trap_percentage * (total_location_count - len(itempool))) + itempool += [mc_world.create_item("Bee Trap") for _ in range(bee_trap_qty)] + + # Fill remaining itempool with randomly generated junk + junk = get_junk_item_names(multiworld.random, total_location_count - len(itempool)) + itempool += [mc_world.create_item(name) for name in junk] + + return itempool diff --git a/worlds/minecraft/Items.py b/worlds/minecraft/Items.py deleted file mode 100644 index 6cf8447c8f..0000000000 --- a/worlds/minecraft/Items.py +++ /dev/null @@ -1,108 +0,0 @@ -from BaseClasses import Item -import typing - - -class ItemData(typing.NamedTuple): - code: typing.Optional[int] - progression: bool - - -class MinecraftItem(Item): - game: str = "Minecraft" - - -item_table = { - "Archery": ItemData(45000, True), - "Progressive Resource Crafting": ItemData(45001, True), - # "Resource Blocks": ItemData(45002, True), - "Brewing": ItemData(45003, True), - "Enchanting": ItemData(45004, True), - "Bucket": ItemData(45005, True), - "Flint and Steel": ItemData(45006, True), - "Bed": ItemData(45007, True), - "Bottles": ItemData(45008, True), - "Shield": ItemData(45009, True), - "Fishing Rod": ItemData(45010, True), - "Campfire": ItemData(45011, True), - "Progressive Weapons": ItemData(45012, True), - "Progressive Tools": ItemData(45013, True), - "Progressive Armor": ItemData(45014, True), - "8 Netherite Scrap": ItemData(45015, True), - "8 Emeralds": ItemData(45016, False), - "4 Emeralds": ItemData(45017, False), - "Channeling Book": ItemData(45018, True), - "Silk Touch Book": ItemData(45019, True), - "Sharpness III Book": ItemData(45020, False), - "Piercing IV Book": ItemData(45021, True), - "Looting III Book": ItemData(45022, False), - "Infinity Book": ItemData(45023, False), - "4 Diamond Ore": ItemData(45024, False), - "16 Iron Ore": ItemData(45025, False), - "500 XP": ItemData(45026, False), - "100 XP": ItemData(45027, False), - "50 XP": ItemData(45028, False), - "3 Ender Pearls": ItemData(45029, True), - "4 Lapis Lazuli": ItemData(45030, False), - "16 Porkchops": ItemData(45031, False), - "8 Gold Ore": ItemData(45032, False), - "Rotten Flesh": ItemData(45033, False), - "Single Arrow": ItemData(45034, False), - "32 Arrows": ItemData(45035, False), - "Saddle": ItemData(45036, True), - "Structure Compass (Village)": ItemData(45037, True), - "Structure Compass (Pillager Outpost)": ItemData(45038, True), - "Structure Compass (Nether Fortress)": ItemData(45039, True), - "Structure Compass (Bastion Remnant)": ItemData(45040, True), - "Structure Compass (End City)": ItemData(45041, True), - "Shulker Box": ItemData(45042, False), - "Dragon Egg Shard": ItemData(45043, True), - "Spyglass": ItemData(45044, True), - "Lead": ItemData(45045, True), - - "Bee Trap": ItemData(45100, False), - "Blaze Rods": ItemData(None, True), - "Defeat Ender Dragon": ItemData(None, True), - "Defeat Wither": ItemData(None, True), -} - -# 33 required items -required_items = { - "Archery": 1, - "Progressive Resource Crafting": 2, - "Brewing": 1, - "Enchanting": 1, - "Bucket": 1, - "Flint and Steel": 1, - "Bed": 1, - "Bottles": 1, - "Shield": 1, - "Fishing Rod": 1, - "Campfire": 1, - "Progressive Weapons": 3, - "Progressive Tools": 3, - "Progressive Armor": 2, - "8 Netherite Scrap": 2, - "Channeling Book": 1, - "Silk Touch Book": 1, - "Sharpness III Book": 1, - "Piercing IV Book": 1, - "Looting III Book": 1, - "Infinity Book": 1, - "3 Ender Pearls": 4, - "Saddle": 1, - "Spyglass": 1, - "Lead": 1, -} - -junk_weights = { - "4 Emeralds": 2, - "4 Diamond Ore": 1, - "16 Iron Ore": 1, - "50 XP": 4, - "16 Porkchops": 2, - "8 Gold Ore": 1, - "Rotten Flesh": 1, - "32 Arrows": 1, -} - -lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in item_table.items() if data.code} diff --git a/worlds/minecraft/Locations.py b/worlds/minecraft/Locations.py deleted file mode 100644 index 46398ab11e..0000000000 --- a/worlds/minecraft/Locations.py +++ /dev/null @@ -1,192 +0,0 @@ -from BaseClasses import Location -import typing - - -class AdvData(typing.NamedTuple): - id: typing.Optional[int] - region: str - - -class MinecraftAdvancement(Location): - game: str = "Minecraft" - - def __init__(self, player: int, name: str, address: typing.Optional[int], parent): - super().__init__(player, name, address, parent) - self.event = not address - - -advancement_table = { - "Who is Cutting Onions?": AdvData(42000, 'Overworld'), - "Oh Shiny": AdvData(42001, 'Overworld'), - "Suit Up": AdvData(42002, 'Overworld'), - "Very Very Frightening": AdvData(42003, 'Overworld'), - "Hot Stuff": AdvData(42004, 'Overworld'), - "Free the End": AdvData(42005, 'The End'), - "A Furious Cocktail": AdvData(42006, 'Nether Fortress'), - "Best Friends Forever": AdvData(42007, 'Overworld'), - "Bring Home the Beacon": AdvData(42008, 'Nether Fortress'), - "Not Today, Thank You": AdvData(42009, 'Overworld'), - "Isn't It Iron Pick": AdvData(42010, 'Overworld'), - "Local Brewery": AdvData(42011, 'Nether Fortress'), - "The Next Generation": AdvData(42012, 'The End'), - "Fishy Business": AdvData(42013, 'Overworld'), - "Hot Tourist Destinations": AdvData(42014, 'The Nether'), - "This Boat Has Legs": AdvData(42015, 'The Nether'), - "Sniper Duel": AdvData(42016, 'Overworld'), - "Nether": AdvData(42017, 'The Nether'), - "Great View From Up Here": AdvData(42018, 'End City'), - "How Did We Get Here?": AdvData(42019, 'Nether Fortress'), - "Bullseye": AdvData(42020, 'Overworld'), - "Spooky Scary Skeleton": AdvData(42021, 'Nether Fortress'), - "Two by Two": AdvData(42022, 'The Nether'), - "Stone Age": AdvData(42023, 'Overworld'), - "Two Birds, One Arrow": AdvData(42024, 'Overworld'), - "We Need to Go Deeper": AdvData(42025, 'The Nether'), - "Who's the Pillager Now?": AdvData(42026, 'Pillager Outpost'), - "Getting an Upgrade": AdvData(42027, 'Overworld'), - "Tactical Fishing": AdvData(42028, 'Overworld'), - "Zombie Doctor": AdvData(42029, 'Overworld'), - "The City at the End of the Game": AdvData(42030, 'End City'), - "Ice Bucket Challenge": AdvData(42031, 'Overworld'), - "Remote Getaway": AdvData(42032, 'The End'), - "Into Fire": AdvData(42033, 'Nether Fortress'), - "War Pigs": AdvData(42034, 'Bastion Remnant'), - "Take Aim": AdvData(42035, 'Overworld'), - "Total Beelocation": AdvData(42036, 'Overworld'), - "Arbalistic": AdvData(42037, 'Overworld'), - "The End... Again...": AdvData(42038, 'The End'), - "Acquire Hardware": AdvData(42039, 'Overworld'), - "Not Quite \"Nine\" Lives": AdvData(42040, 'The Nether'), - "Cover Me With Diamonds": AdvData(42041, 'Overworld'), - "Sky's the Limit": AdvData(42042, 'End City'), - "Hired Help": AdvData(42043, 'Overworld'), - "Return to Sender": AdvData(42044, 'The Nether'), - "Sweet Dreams": AdvData(42045, 'Overworld'), - "You Need a Mint": AdvData(42046, 'The End'), - "Adventure": AdvData(42047, 'Overworld'), - "Monsters Hunted": AdvData(42048, 'Overworld'), - "Enchanter": AdvData(42049, 'Overworld'), - "Voluntary Exile": AdvData(42050, 'Pillager Outpost'), - "Eye Spy": AdvData(42051, 'Overworld'), - "The End": AdvData(42052, 'The End'), - "Serious Dedication": AdvData(42053, 'The Nether'), - "Postmortal": AdvData(42054, 'Village'), - "Monster Hunter": AdvData(42055, 'Overworld'), - "Adventuring Time": AdvData(42056, 'Overworld'), - "A Seedy Place": AdvData(42057, 'Overworld'), - "Those Were the Days": AdvData(42058, 'Bastion Remnant'), - "Hero of the Village": AdvData(42059, 'Village'), - "Hidden in the Depths": AdvData(42060, 'The Nether'), - "Beaconator": AdvData(42061, 'Nether Fortress'), - "Withering Heights": AdvData(42062, 'Nether Fortress'), - "A Balanced Diet": AdvData(42063, 'Village'), - "Subspace Bubble": AdvData(42064, 'The Nether'), - "Husbandry": AdvData(42065, 'Overworld'), - "Country Lode, Take Me Home": AdvData(42066, 'The Nether'), - "Bee Our Guest": AdvData(42067, 'Overworld'), - "What a Deal!": AdvData(42068, 'Village'), - "Uneasy Alliance": AdvData(42069, 'The Nether'), - "Diamonds!": AdvData(42070, 'Overworld'), - "A Terrible Fortress": AdvData(42071, 'Nether Fortress'), - "A Throwaway Joke": AdvData(42072, 'Overworld'), - "Minecraft": AdvData(42073, 'Overworld'), - "Sticky Situation": AdvData(42074, 'Overworld'), - "Ol' Betsy": AdvData(42075, 'Overworld'), - "Cover Me in Debris": AdvData(42076, 'The Nether'), - "The End?": AdvData(42077, 'The End'), - "The Parrots and the Bats": AdvData(42078, 'Overworld'), - "A Complete Catalogue": AdvData(42079, 'Village'), - "Getting Wood": AdvData(42080, 'Overworld'), - "Time to Mine!": AdvData(42081, 'Overworld'), - "Hot Topic": AdvData(42082, 'Overworld'), - "Bake Bread": AdvData(42083, 'Overworld'), - "The Lie": AdvData(42084, 'Overworld'), - "On a Rail": AdvData(42085, 'Overworld'), - "Time to Strike!": AdvData(42086, 'Overworld'), - "Cow Tipper": AdvData(42087, 'Overworld'), - "When Pigs Fly": AdvData(42088, 'Overworld'), - "Overkill": AdvData(42089, 'Nether Fortress'), - "Librarian": AdvData(42090, 'Overworld'), - "Overpowered": AdvData(42091, 'Bastion Remnant'), - "Wax On": AdvData(42092, 'Overworld'), - "Wax Off": AdvData(42093, 'Overworld'), - "The Cutest Predator": AdvData(42094, 'Overworld'), - "The Healing Power of Friendship": AdvData(42095, 'Overworld'), - "Is It a Bird?": AdvData(42096, 'Overworld'), - "Is It a Balloon?": AdvData(42097, 'The Nether'), - "Is It a Plane?": AdvData(42098, 'The End'), - "Surge Protector": AdvData(42099, 'Overworld'), - "Light as a Rabbit": AdvData(42100, 'Overworld'), - "Glow and Behold!": AdvData(42101, 'Overworld'), - "Whatever Floats Your Goat!": AdvData(42102, 'Overworld'), - "Caves & Cliffs": AdvData(42103, 'Overworld'), - "Feels like home": AdvData(42104, 'The Nether'), - "Sound of Music": AdvData(42105, 'Overworld'), - "Star Trader": AdvData(42106, 'Village'), - - # 1.19 advancements - "Birthday Song": AdvData(42107, 'Pillager Outpost'), - "Bukkit Bukkit": AdvData(42108, 'Overworld'), - "It Spreads": AdvData(42109, 'Overworld'), - "Sneak 100": AdvData(42110, 'Overworld'), - "When the Squad Hops into Town": AdvData(42111, 'Overworld'), - "With Our Powers Combined!": AdvData(42112, 'The Nether'), - "You've Got a Friend in Me": AdvData(42113, 'Pillager Outpost'), - - "Blaze Spawner": AdvData(None, 'Nether Fortress'), - "Ender Dragon": AdvData(None, 'The End'), - "Wither": AdvData(None, 'Nether Fortress'), -} - -exclusion_table = { - "hard": { - "Very Very Frightening", - "A Furious Cocktail", - "Two by Two", - "Two Birds, One Arrow", - "Arbalistic", - "Monsters Hunted", - "Beaconator", - "A Balanced Diet", - "Uneasy Alliance", - "Cover Me in Debris", - "A Complete Catalogue", - "Surge Protector", - "Sound of Music", - "Star Trader", - "When the Squad Hops into Town", - "With Our Powers Combined!", - }, - "unreasonable": { - "How Did We Get Here?", - "Adventuring Time", - }, -} - -def get_postgame_advancements(required_bosses): - - postgame_advancements = { - "ender_dragon": { - "Free the End", - "The Next Generation", - "The End... Again...", - "You Need a Mint", - "Monsters Hunted", - "Is It a Plane?", - }, - "wither": { - "Withering Heights", - "Bring Home the Beacon", - "Beaconator", - "A Furious Cocktail", - "How Did We Get Here?", - "Monsters Hunted", - } - } - - advancements = set() - if required_bosses in {"ender_dragon", "both"}: - advancements.update(postgame_advancements["ender_dragon"]) - if required_bosses in {"wither", "both"}: - advancements.update(postgame_advancements["wither"]) - return advancements diff --git a/worlds/minecraft/Options.py b/worlds/minecraft/Options.py index 161d44d9b8..084a611e44 100644 --- a/worlds/minecraft/Options.py +++ b/worlds/minecraft/Options.py @@ -6,7 +6,7 @@ class AdvancementGoal(Range): """Number of advancements required to spawn bosses.""" display_name = "Advancement Goal" range_start = 0 - range_end = 95 + range_end = 114 default = 40 @@ -14,7 +14,7 @@ class EggShardsRequired(Range): """Number of dragon egg shards to collect to spawn bosses.""" display_name = "Egg Shards Required" range_start = 0 - range_end = 40 + range_end = 74 default = 0 @@ -22,7 +22,7 @@ class EggShardsAvailable(Range): """Number of dragon egg shards available to collect.""" display_name = "Egg Shards Available" range_start = 0 - range_end = 40 + range_end = 74 default = 0 @@ -35,6 +35,14 @@ class BossGoal(Choice): option_both = 3 default = 1 + @property + def dragon(self): + return self.value % 2 == 1 + + @property + def wither(self): + return self.value > 1 + class ShuffleStructures(DefaultOnToggle): """Enables shuffling of villages, outposts, fortresses, bastions, and end cities.""" @@ -94,14 +102,16 @@ minecraft_options: typing.Dict[str, type(Option)] = { "egg_shards_required": EggShardsRequired, "egg_shards_available": EggShardsAvailable, "required_bosses": BossGoal, + "shuffle_structures": ShuffleStructures, "structure_compasses": StructureCompasses, - "bee_traps": BeeTraps, + "combat_difficulty": CombatDifficulty, "include_hard_advancements": HardAdvancements, "include_unreasonable_advancements": UnreasonableAdvancements, "include_postgame_advancements": PostgameAdvancements, + "bee_traps": BeeTraps, "send_defeated_mobs": SendDefeatedMobs, - "starting_items": StartingItems, "death_link": DeathLink, + "starting_items": StartingItems, } diff --git a/worlds/minecraft/Regions.py b/worlds/minecraft/Regions.py deleted file mode 100644 index d9f3f1b59e..0000000000 --- a/worlds/minecraft/Regions.py +++ /dev/null @@ -1,93 +0,0 @@ - -def link_minecraft_structures(world, player): - - # Link mandatory connections first - for (exit, region) in mandatory_connections: - world.get_entrance(exit, player).connect(world.get_region(region, player)) - - # Get all unpaired exits and all regions without entrances (except the Menu) - # This function is destructive on these lists. - exits = [exit.name for r in world.regions if r.player == player for exit in r.exits if exit.connected_region == None] - structs = [r.name for r in world.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] - exits_spoiler = exits[:] # copy the original order for the spoiler log - try: - assert len(exits) == len(structs) - except AssertionError as e: # this should never happen - raise Exception(f"Could not obtain equal numbers of Minecraft exits and structures for player {player} ({world.player_name[player]})") - - pairs = {} - - def set_pair(exit, struct): - if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])): - pairs[exit] = struct - exits.remove(exit) - structs.remove(struct) - else: - raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({world.player_name[player]})") - - # Connect plando structures first - if world.plando_connections[player]: - for conn in world.plando_connections[player]: - set_pair(conn.entrance, conn.exit) - - # The algorithm tries to place the most restrictive structures first. This algorithm always works on the - # relatively small set of restrictions here, but does not work on all possible inputs with valid configurations. - if world.shuffle_structures[player]: - structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, []))) - for struct in structs[:]: - try: - exit = world.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) - except IndexError: - raise Exception(f"No valid structure placements remaining for player {player} ({world.player_name[player]})") - set_pair(exit, struct) - else: # write remaining default connections - for (exit, struct) in default_connections: - if exit in exits: - set_pair(exit, struct) - - # Make sure we actually paired everything; might fail if plando - try: - assert len(exits) == len(structs) == 0 - except AssertionError: - raise Exception(f"Failed to connect all Minecraft structures for player {player} ({world.player_name[player]})") - - for exit in exits_spoiler: - world.get_entrance(exit, player).connect(world.get_region(pairs[exit], player)) - if world.shuffle_structures[player] or world.plando_connections[player]: - world.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) - - - -# (Region name, list of exits) -mc_regions = [ - ('Menu', ['New World']), - ('Overworld', ['Nether Portal', 'End Portal', 'Overworld Structure 1', 'Overworld Structure 2']), - ('The Nether', ['Nether Structure 1', 'Nether Structure 2']), - ('The End', ['The End Structure']), - ('Village', []), - ('Pillager Outpost', []), - ('Nether Fortress', []), - ('Bastion Remnant', []), - ('End City', []) -] - -# (Entrance, region pointed to) -mandatory_connections = [ - ('New World', 'Overworld'), - ('Nether Portal', 'The Nether'), - ('End Portal', 'The End') -] - -default_connections = [ - ('Overworld Structure 1', 'Village'), - ('Overworld Structure 2', 'Pillager Outpost'), - ('Nether Structure 1', 'Nether Fortress'), - ('Nether Structure 2', 'Bastion Remnant'), - ('The End Structure', 'End City') -] - -# Structure: illegal locations -illegal_connections = { - 'Nether Fortress': ['The End Structure'] -} - diff --git a/worlds/minecraft/Rules.py b/worlds/minecraft/Rules.py index 2ec9523762..dae4241b99 100644 --- a/worlds/minecraft/Rules.py +++ b/worlds/minecraft/Rules.py @@ -1,317 +1,313 @@ -from ..generic.Rules import set_rule, add_rule -from .Locations import exclusion_table, get_postgame_advancements -from BaseClasses import MultiWorld -from ..AutoWorld import LogicMixin +import typing +from collections.abc import Callable + +from BaseClasses import CollectionState +from worlds.generic.Rules import exclusion_rules +from worlds.AutoWorld import World + +from . import Constants + +# Helper functions +# moved from logicmixin + +def has_iron_ingots(state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) + +def has_copper_ingots(state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player) and state.has('Progressive Resource Crafting', player) + +def has_gold_ingots(state: CollectionState, player: int) -> bool: + return state.has('Progressive Resource Crafting', player) and (state.has('Progressive Tools', player, 2) or state.can_reach('The Nether', 'Region', player)) + +def has_diamond_pickaxe(state: CollectionState, player: int) -> bool: + return state.has('Progressive Tools', player, 3) and has_iron_ingots(state, player) + +def craft_crossbow(state: CollectionState, player: int) -> bool: + return state.has('Archery', player) and has_iron_ingots(state, player) + +def has_bottle(state: CollectionState, player: int) -> bool: + return state.has('Bottles', player) and state.has('Progressive Resource Crafting', player) + +def has_spyglass(state: CollectionState, player: int) -> bool: + return has_copper_ingots(state, player) and state.has('Spyglass', player) and can_adventure(state, player) + +def can_enchant(state: CollectionState, player: int) -> bool: + return state.has('Enchanting', player) and has_diamond_pickaxe(state, player) # mine obsidian and lapis + +def can_use_anvil(state: CollectionState, player: int) -> bool: + return state.has('Enchanting', player) and state.has('Progressive Resource Crafting', player, 2) and has_iron_ingots(state, player) + +def fortress_loot(state: CollectionState, player: int) -> bool: # saddles, blaze rods, wither skulls + return state.can_reach('Nether Fortress', 'Region', player) and basic_combat(state, player) + +def can_brew_potions(state: CollectionState, player: int) -> bool: + return state.has('Blaze Rods', player) and state.has('Brewing', player) and has_bottle(state, player) + +def can_piglin_trade(state: CollectionState, player: int) -> bool: + return has_gold_ingots(state, player) and ( + state.can_reach('The Nether', 'Region', player) or + state.can_reach('Bastion Remnant', 'Region', player)) + +def overworld_villager(state: CollectionState, player: int) -> bool: + village_region = state.multiworld.get_region('Village', player).entrances[0].parent_region.name + if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village + return (state.can_reach('Zombie Doctor', 'Location', player) or + (has_diamond_pickaxe(state, player) and state.can_reach('Village', 'Region', player))) + elif village_region == 'The End': + return state.can_reach('Zombie Doctor', 'Location', player) + return state.can_reach('Village', 'Region', player) + +def enter_stronghold(state: CollectionState, player: int) -> bool: + return state.has('Blaze Rods', player) and state.has('Brewing', player) and state.has('3 Ender Pearls', player) + +# Difficulty-dependent functions +def combat_difficulty(state: CollectionState, player: int) -> bool: + return state.multiworld.combat_difficulty[player].current_key + +def can_adventure(state: CollectionState, player: int) -> bool: + death_link_check = not state.multiworld.death_link[player] or state.has('Bed', player) + if combat_difficulty(state, player) == 'easy': + return state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and death_link_check + elif combat_difficulty(state, player) == 'hard': + return True + return (state.has('Progressive Weapons', player) and death_link_check and + (state.has('Progressive Resource Crafting', player) or state.has('Campfire', player))) + +def basic_combat(state: CollectionState, player: int) -> bool: + if combat_difficulty(state, player) == 'easy': + return state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player) and \ + state.has('Shield', player) and has_iron_ingots(state, player) + elif combat_difficulty(state, player) == 'hard': + return True + return state.has('Progressive Weapons', player) and (state.has('Progressive Armor', player) or state.has('Shield', player)) and has_iron_ingots(state, player) + +def complete_raid(state: CollectionState, player: int) -> bool: + reach_regions = state.can_reach('Village', 'Region', player) and state.can_reach('Pillager Outpost', 'Region', player) + if combat_difficulty(state, player) == 'easy': + return reach_regions and \ + state.has('Progressive Weapons', player, 3) and state.has('Progressive Armor', player, 2) and \ + state.has('Shield', player) and state.has('Archery', player) and \ + state.has('Progressive Tools', player, 2) and has_iron_ingots(state, player) + elif combat_difficulty(state, player) == 'hard': # might be too hard? + return reach_regions and state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and \ + (state.has('Progressive Armor', player) or state.has('Shield', player)) + return reach_regions and state.has('Progressive Weapons', player, 2) and has_iron_ingots(state, player) and \ + state.has('Progressive Armor', player) and state.has('Shield', player) + +def can_kill_wither(state: CollectionState, player: int) -> bool: + normal_kill = state.has("Progressive Weapons", player, 3) and state.has("Progressive Armor", player, 2) and can_brew_potions(state, player) and can_enchant(state, player) + if combat_difficulty(state, player) == 'easy': + return fortress_loot(state, player) and normal_kill and state.has('Archery', player) + elif combat_difficulty(state, player) == 'hard': # cheese kill using bedrock ceilings + return fortress_loot(state, player) and (normal_kill or state.can_reach('The Nether', 'Region', player) or state.can_reach('The End', 'Region', player)) + return fortress_loot(state, player) and normal_kill + +def can_respawn_ender_dragon(state: CollectionState, player: int) -> bool: + return state.can_reach('The Nether', 'Region', player) and state.can_reach('The End', 'Region', player) and \ + state.has('Progressive Resource Crafting', player) # smelt sand into glass + +def can_kill_ender_dragon(state: CollectionState, player: int) -> bool: + if combat_difficulty(state, player) == 'easy': + return state.has("Progressive Weapons", player, 3) and state.has("Progressive Armor", player, 2) and \ + state.has('Archery', player) and can_brew_potions(state, player) and can_enchant(state, player) + if combat_difficulty(state, player) == 'hard': + return (state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player)) or \ + (state.has('Progressive Weapons', player, 1) and state.has('Bed', player)) + return state.has('Progressive Weapons', player, 2) and state.has('Progressive Armor', player) and state.has('Archery', player) + +def has_structure_compass(state: CollectionState, entrance_name: str, player: int) -> bool: + if not state.multiworld.structure_compasses[player]: + return True + return state.has(f"Structure Compass ({state.multiworld.get_entrance(entrance_name, player).connected_region.name})", player) -class MinecraftLogic(LogicMixin): +def get_rules_lookup(player: int): + rules_lookup: typing.Dict[str, typing.List[Callable[[CollectionState], bool]]] = { + "entrances": { + "Nether Portal": lambda state: (state.has('Flint and Steel', player) and + (state.has('Bucket', player) or state.has('Progressive Tools', player, 3)) and + has_iron_ingots(state, player)), + "End Portal": lambda state: enter_stronghold(state, player) and state.has('3 Ender Pearls', player, 4), + "Overworld Structure 1": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Overworld Structure 1", player)), + "Overworld Structure 2": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Overworld Structure 2", player)), + "Nether Structure 1": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Nether Structure 1", player)), + "Nether Structure 2": lambda state: (can_adventure(state, player) and has_structure_compass(state, "Nether Structure 2", player)), + "The End Structure": lambda state: (can_adventure(state, player) and has_structure_compass(state, "The End Structure", player)), + }, + "locations": { + "Ender Dragon": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Wither": lambda state: can_kill_wither(state, player), + "Blaze Rods": lambda state: fortress_loot(state, player), - def _mc_has_iron_ingots(self, player: int): - return self.has('Progressive Tools', player) and self.has('Progressive Resource Crafting', player) - - def _mc_has_copper_ingots(self, player: int): - return self.has('Progressive Tools', player) and self.has('Progressive Resource Crafting', player) - - def _mc_has_gold_ingots(self, player: int): - return self.has('Progressive Resource Crafting', player) and (self.has('Progressive Tools', player, 2) or self.can_reach('The Nether', 'Region', player)) - - def _mc_has_diamond_pickaxe(self, player: int): - return self.has('Progressive Tools', player, 3) and self._mc_has_iron_ingots(player) - - def _mc_craft_crossbow(self, player: int): - return self.has('Archery', player) and self._mc_has_iron_ingots(player) - - def _mc_has_bottle(self, player: int): - return self.has('Bottles', player) and self.has('Progressive Resource Crafting', player) - - def _mc_has_spyglass(self, player: int): - return self._mc_has_copper_ingots(player) and self.has('Spyglass', player) and self._mc_can_adventure(player) - - def _mc_can_enchant(self, player: int): - return self.has('Enchanting', player) and self._mc_has_diamond_pickaxe(player) # mine obsidian and lapis - - def _mc_can_use_anvil(self, player: int): - return self.has('Enchanting', player) and self.has('Progressive Resource Crafting', player, 2) and self._mc_has_iron_ingots(player) - - def _mc_fortress_loot(self, player: int): # saddles, blaze rods, wither skulls - return self.can_reach('Nether Fortress', 'Region', player) and self._mc_basic_combat(player) - - def _mc_can_brew_potions(self, player: int): - return self.has('Blaze Rods', player) and self.has('Brewing', player) and self._mc_has_bottle(player) - - def _mc_can_piglin_trade(self, player: int): - return self._mc_has_gold_ingots(player) and ( - self.can_reach('The Nether', 'Region', player) or - self.can_reach('Bastion Remnant', 'Region', player)) - - def _mc_overworld_villager(self, player: int): - village_region = self.multiworld.get_region('Village', player).entrances[0].parent_region.name - if village_region == 'The Nether': # 2 options: cure zombie villager or build portal in village - return (self.can_reach('Zombie Doctor', 'Location', player) or - (self._mc_has_diamond_pickaxe(player) and self.can_reach('Village', 'Region', player))) - elif village_region == 'The End': - return self.can_reach('Zombie Doctor', 'Location', player) - return self.can_reach('Village', 'Region', player) - - def _mc_enter_stronghold(self, player: int): - return self.has('Blaze Rods', player) and self.has('Brewing', player) and self.has('3 Ender Pearls', player) - - # Difficulty-dependent functions - def _mc_combat_difficulty(self, player: int): - return self.multiworld.combat_difficulty[player].current_key - - def _mc_can_adventure(self, player: int): - death_link_check = not self.multiworld.death_link[player] or self.has('Bed', player) - if self._mc_combat_difficulty(player) == 'easy': - return self.has('Progressive Weapons', player, 2) and self._mc_has_iron_ingots(player) and death_link_check - elif self._mc_combat_difficulty(player) == 'hard': - return True - return (self.has('Progressive Weapons', player) and death_link_check and - (self.has('Progressive Resource Crafting', player) or self.has('Campfire', player))) - - def _mc_basic_combat(self, player: int): - if self._mc_combat_difficulty(player) == 'easy': - return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and \ - self.has('Shield', player) and self._mc_has_iron_ingots(player) - elif self._mc_combat_difficulty(player) == 'hard': - return True - return self.has('Progressive Weapons', player) and (self.has('Progressive Armor', player) or self.has('Shield', player)) and self._mc_has_iron_ingots(player) - - def _mc_complete_raid(self, player: int): - reach_regions = self.can_reach('Village', 'Region', player) and self.can_reach('Pillager Outpost', 'Region', player) - if self._mc_combat_difficulty(player) == 'easy': - return reach_regions and \ - self.has('Progressive Weapons', player, 3) and self.has('Progressive Armor', player, 2) and \ - self.has('Shield', player) and self.has('Archery', player) and \ - self.has('Progressive Tools', player, 2) and self._mc_has_iron_ingots(player) - elif self._mc_combat_difficulty(player) == 'hard': # might be too hard? - return reach_regions and self.has('Progressive Weapons', player, 2) and self._mc_has_iron_ingots(player) and \ - (self.has('Progressive Armor', player) or self.has('Shield', player)) - return reach_regions and self.has('Progressive Weapons', player, 2) and self._mc_has_iron_ingots(player) and \ - self.has('Progressive Armor', player) and self.has('Shield', player) - - def _mc_can_kill_wither(self, player: int): - normal_kill = self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and self._mc_can_brew_potions(player) and self._mc_can_enchant(player) - if self._mc_combat_difficulty(player) == 'easy': - return self._mc_fortress_loot(player) and normal_kill and self.has('Archery', player) - elif self._mc_combat_difficulty(player) == 'hard': # cheese kill using bedrock ceilings - return self._mc_fortress_loot(player) and (normal_kill or self.can_reach('The Nether', 'Region', player) or self.can_reach('The End', 'Region', player)) - return self._mc_fortress_loot(player) and normal_kill - - def _mc_can_respawn_ender_dragon(self, player: int): - return self.can_reach('The Nether', 'Region', player) and self.can_reach('The End', 'Region', player) and \ - self.has('Progressive Resource Crafting', player) # smelt sand into glass - - def _mc_can_kill_ender_dragon(self, player: int): - if self._mc_combat_difficulty(player) == 'easy': - return self.has("Progressive Weapons", player, 3) and self.has("Progressive Armor", player, 2) and \ - self.has('Archery', player) and self._mc_can_brew_potions(player) and self._mc_can_enchant(player) - if self._mc_combat_difficulty(player) == 'hard': - return (self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player)) or \ - (self.has('Progressive Weapons', player, 1) and self.has('Bed', player)) - return self.has('Progressive Weapons', player, 2) and self.has('Progressive Armor', player) and self.has('Archery', player) - - def _mc_has_structure_compass(self, entrance_name: str, player: int): - if not self.multiworld.structure_compasses[player]: - return True - return self.has(f"Structure Compass ({self.multiworld.get_entrance(entrance_name, player).connected_region.name})", player) - -# Sets rules on entrances and advancements that are always applied -def set_advancement_rules(world: MultiWorld, player: int): - - # Retrieves the appropriate structure compass for the given entrance - def get_struct_compass(entrance_name): - struct = world.get_entrance(entrance_name, player).connected_region.name - return f"Structure Compass ({struct})" - - set_rule(world.get_entrance("Nether Portal", player), lambda state: state.has('Flint and Steel', player) and - (state.has('Bucket', player) or state.has('Progressive Tools', player, 3)) and - state._mc_has_iron_ingots(player)) - set_rule(world.get_entrance("End Portal", player), lambda state: state._mc_enter_stronghold(player) and state.has('3 Ender Pearls', player, 4)) - set_rule(world.get_entrance("Overworld Structure 1", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Overworld Structure 1", player)) - set_rule(world.get_entrance("Overworld Structure 2", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Overworld Structure 2", player)) - set_rule(world.get_entrance("Nether Structure 1", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Nether Structure 1", player)) - set_rule(world.get_entrance("Nether Structure 2", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("Nether Structure 2", player)) - set_rule(world.get_entrance("The End Structure", player), lambda state: state._mc_can_adventure(player) and state._mc_has_structure_compass("The End Structure", player)) - - set_rule(world.get_location("Ender Dragon", player), lambda state: state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("Wither", player), lambda state: state._mc_can_kill_wither(player)) - set_rule(world.get_location("Blaze Spawner", player), lambda state: state._mc_fortress_loot(player)) - - set_rule(world.get_location("Who is Cutting Onions?", player), lambda state: state._mc_can_piglin_trade(player)) - set_rule(world.get_location("Oh Shiny", player), lambda state: state._mc_can_piglin_trade(player)) - set_rule(world.get_location("Suit Up", player), lambda state: state.has("Progressive Armor", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Very Very Frightening", player), lambda state: state.has("Channeling Book", player) and - state._mc_can_use_anvil(player) and state._mc_can_enchant(player) and state._mc_overworld_villager(player)) - set_rule(world.get_location("Hot Stuff", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Free the End", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("A Furious Cocktail", player), lambda state: state._mc_can_brew_potions(player) and - state.has("Fishing Rod", player) and # Water Breathing - state.can_reach('The Nether', 'Region', player) and # Regeneration, Fire Resistance, gold nuggets - state.can_reach('Village', 'Region', player) and # Night Vision, Invisibility - state.can_reach('Bring Home the Beacon', 'Location', player)) # Resistance - # set_rule(world.get_location("Best Friends Forever", player), lambda state: True) - set_rule(world.get_location("Bring Home the Beacon", player), lambda state: state._mc_can_kill_wither(player) and - state._mc_has_diamond_pickaxe(player) and state.has("Progressive Resource Crafting", player, 2)) - set_rule(world.get_location("Not Today, Thank You", player), lambda state: state.has("Shield", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Isn't It Iron Pick", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Local Brewery", player), lambda state: state._mc_can_brew_potions(player)) - set_rule(world.get_location("The Next Generation", player), lambda state: state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("Fishy Business", player), lambda state: state.has("Fishing Rod", player)) - # set_rule(world.get_location("Hot Tourist Destinations", player), lambda state: True) - set_rule(world.get_location("This Boat Has Legs", player), lambda state: (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and - state.has("Saddle", player) and state.has("Fishing Rod", player)) - set_rule(world.get_location("Sniper Duel", player), lambda state: state.has("Archery", player)) - # set_rule(world.get_location("Nether", player), lambda state: True) - set_rule(world.get_location("Great View From Up Here", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("How Did We Get Here?", player), lambda state: state._mc_can_brew_potions(player) and - state._mc_has_gold_ingots(player) and # Absorption - state.can_reach('End City', 'Region', player) and # Levitation - state.can_reach('The Nether', 'Region', player) and # potion ingredients - state.has("Fishing Rod", player) and state.has("Archery",player) and # Pufferfish, Nautilus Shells; spectral arrows - state.can_reach("Bring Home the Beacon", "Location", player) and # Haste - state.can_reach("Hero of the Village", "Location", player)) # Bad Omen, Hero of the Village - set_rule(world.get_location("Bullseye", player), lambda state: state.has("Archery", player) and state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Spooky Scary Skeleton", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Two by Two", player), lambda state: state._mc_has_iron_ingots(player) and state.has("Bucket", player) and state._mc_can_adventure(player)) # shears > seagrass > turtles; buckets of tropical fish > axolotls; nether > striders; gold carrots > horses skips ingots - # set_rule(world.get_location("Stone Age", player), lambda state: True) - set_rule(world.get_location("Two Birds, One Arrow", player), lambda state: state._mc_craft_crossbow(player) and state._mc_can_enchant(player)) - # set_rule(world.get_location("We Need to Go Deeper", player), lambda state: True) - set_rule(world.get_location("Who's the Pillager Now?", player), lambda state: state._mc_craft_crossbow(player)) - set_rule(world.get_location("Getting an Upgrade", player), lambda state: state.has("Progressive Tools", player)) - set_rule(world.get_location("Tactical Fishing", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Zombie Doctor", player), lambda state: state._mc_can_brew_potions(player) and state._mc_has_gold_ingots(player)) - # set_rule(world.get_location("The City at the End of the Game", player), lambda state: True) - set_rule(world.get_location("Ice Bucket Challenge", player), lambda state: state._mc_has_diamond_pickaxe(player)) - # set_rule(world.get_location("Remote Getaway", player), lambda state: True) - set_rule(world.get_location("Into Fire", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("War Pigs", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Take Aim", player), lambda state: state.has("Archery", player)) - set_rule(world.get_location("Total Beelocation", player), lambda state: state.has("Silk Touch Book", player) and state._mc_can_use_anvil(player) and state._mc_can_enchant(player)) - set_rule(world.get_location("Arbalistic", player), lambda state: state._mc_craft_crossbow(player) and state.has("Piercing IV Book", player) and - state._mc_can_use_anvil(player) and state._mc_can_enchant(player)) - set_rule(world.get_location("The End... Again...", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player)) - set_rule(world.get_location("Acquire Hardware", player), lambda state: state._mc_has_iron_ingots(player)) - set_rule(world.get_location("Not Quite \"Nine\" Lives", player), lambda state: state._mc_can_piglin_trade(player) and state.has("Progressive Resource Crafting", player, 2)) - set_rule(world.get_location("Cover Me With Diamonds", player), lambda state: state.has("Progressive Armor", player, 2) and state.can_reach("Diamonds!", "Location", player)) - set_rule(world.get_location("Sky's the Limit", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Hired Help", player), lambda state: state.has("Progressive Resource Crafting", player, 2) and state._mc_has_iron_ingots(player)) - # set_rule(world.get_location("Return to Sender", player), lambda state: True) - set_rule(world.get_location("Sweet Dreams", player), lambda state: state.has("Bed", player) or state.can_reach('Village', 'Region', player)) - set_rule(world.get_location("You Need a Mint", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_has_bottle(player)) - # set_rule(world.get_location("Adventure", player), lambda state: True) - set_rule(world.get_location("Monsters Hunted", player), lambda state: state._mc_can_respawn_ender_dragon(player) and state._mc_can_kill_ender_dragon(player) and - state._mc_can_kill_wither(player) and state.has("Fishing Rod", player)) # pufferfish for Water Breathing - set_rule(world.get_location("Enchanter", player), lambda state: state._mc_can_enchant(player)) - set_rule(world.get_location("Voluntary Exile", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Eye Spy", player), lambda state: state._mc_enter_stronghold(player)) - # set_rule(world.get_location("The End", player), lambda state: True) - set_rule(world.get_location("Serious Dedication", player), lambda state: state.can_reach("Hidden in the Depths", "Location", player) and state._mc_has_gold_ingots(player)) - set_rule(world.get_location("Postmortal", player), lambda state: state._mc_complete_raid(player)) - # set_rule(world.get_location("Monster Hunter", player), lambda state: True) - set_rule(world.get_location("Adventuring Time", player), lambda state: state._mc_can_adventure(player)) - # set_rule(world.get_location("A Seedy Place", player), lambda state: True) - # set_rule(world.get_location("Those Were the Days", player), lambda state: True) - set_rule(world.get_location("Hero of the Village", player), lambda state: state._mc_complete_raid(player)) - set_rule(world.get_location("Hidden in the Depths", player), lambda state: state._mc_can_brew_potions(player) and state.has("Bed", player) and state._mc_has_diamond_pickaxe(player)) # bed mining :) - set_rule(world.get_location("Beaconator", player), lambda state: state._mc_can_kill_wither(player) and state._mc_has_diamond_pickaxe(player) and - state.has("Progressive Resource Crafting", player, 2)) - set_rule(world.get_location("Withering Heights", player), lambda state: state._mc_can_kill_wither(player)) - set_rule(world.get_location("A Balanced Diet", player), lambda state: state._mc_has_bottle(player) and state._mc_has_gold_ingots(player) and # honey bottle; gapple - state.has("Progressive Resource Crafting", player, 2) and state.can_reach('The End', 'Region', player)) # notch apple, chorus fruit - set_rule(world.get_location("Subspace Bubble", player), lambda state: state._mc_has_diamond_pickaxe(player)) - # set_rule(world.get_location("Husbandry", player), lambda state: True) - set_rule(world.get_location("Country Lode, Take Me Home", player), lambda state: state.can_reach("Hidden in the Depths", "Location", player) and state._mc_has_gold_ingots(player)) - set_rule(world.get_location("Bee Our Guest", player), lambda state: state.has("Campfire", player) and state._mc_has_bottle(player)) - # set_rule(world.get_location("What a Deal!", player), lambda state: True) - set_rule(world.get_location("Uneasy Alliance", player), lambda state: state._mc_has_diamond_pickaxe(player) and state.has('Fishing Rod', player)) - set_rule(world.get_location("Diamonds!", player), lambda state: state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - # set_rule(world.get_location("A Terrible Fortress", player), lambda state: True) # since you don't have to fight anything - set_rule(world.get_location("A Throwaway Joke", player), lambda state: state._mc_can_adventure(player)) # kill drowned - # set_rule(world.get_location("Minecraft", player), lambda state: True) - set_rule(world.get_location("Sticky Situation", player), lambda state: state.has("Campfire", player) and state._mc_has_bottle(player)) - set_rule(world.get_location("Ol' Betsy", player), lambda state: state._mc_craft_crossbow(player)) - set_rule(world.get_location("Cover Me in Debris", player), lambda state: state.has("Progressive Armor", player, 2) and - state.has("8 Netherite Scrap", player, 2) and state.has("Progressive Resource Crafting", player) and - state.can_reach("Diamonds!", "Location", player) and state.can_reach("Hidden in the Depths", "Location", player)) - # set_rule(world.get_location("The End?", player), lambda state: True) - # set_rule(world.get_location("The Parrots and the Bats", player), lambda state: True) - # set_rule(world.get_location("A Complete Catalogue", player), lambda state: True) # kill fish for raw - # set_rule(world.get_location("Getting Wood", player), lambda state: True) - # set_rule(world.get_location("Time to Mine!", player), lambda state: True) - set_rule(world.get_location("Hot Topic", player), lambda state: state.has("Progressive Resource Crafting", player)) - # set_rule(world.get_location("Bake Bread", player), lambda state: True) - set_rule(world.get_location("The Lie", player), lambda state: state._mc_has_iron_ingots(player) and state.has("Bucket", player)) - set_rule(world.get_location("On a Rail", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Progressive Tools', player, 2)) # powered rails - # set_rule(world.get_location("Time to Strike!", player), lambda state: True) - # set_rule(world.get_location("Cow Tipper", player), lambda state: True) - set_rule(world.get_location("When Pigs Fly", player), lambda state: (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and - state.has("Saddle", player) and state.has("Fishing Rod", player) and state._mc_can_adventure(player)) - set_rule(world.get_location("Overkill", player), lambda state: state._mc_can_brew_potions(player) and - (state.has("Progressive Weapons", player) or state.can_reach('The Nether', 'Region', player))) # strength 1 + stone axe crit OR strength 2 + wood axe crit - set_rule(world.get_location("Librarian", player), lambda state: state.has("Enchanting", player)) - set_rule(world.get_location("Overpowered", player), lambda state: state._mc_has_iron_ingots(player) and - state.has('Progressive Tools', player, 2) and state._mc_basic_combat(player)) # mine gold blocks w/ iron pick - set_rule(world.get_location("Wax On", player), lambda state: state._mc_has_copper_ingots(player) and state.has('Campfire', player) and - state.has('Progressive Resource Crafting', player, 2)) - set_rule(world.get_location("Wax Off", player), lambda state: state._mc_has_copper_ingots(player) and state.has('Campfire', player) and - state.has('Progressive Resource Crafting', player, 2)) - set_rule(world.get_location("The Cutest Predator", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player)) - set_rule(world.get_location("The Healing Power of Friendship", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player)) - set_rule(world.get_location("Is It a Bird?", player), lambda state: state._mc_has_spyglass(player) and state._mc_can_adventure(player)) - set_rule(world.get_location("Is It a Balloon?", player), lambda state: state._mc_has_spyglass(player)) - set_rule(world.get_location("Is It a Plane?", player), lambda state: state._mc_has_spyglass(player) and state._mc_can_respawn_ender_dragon(player)) - set_rule(world.get_location("Surge Protector", player), lambda state: state.has("Channeling Book", player) and - state._mc_can_use_anvil(player) and state._mc_can_enchant(player) and state._mc_overworld_villager(player)) - set_rule(world.get_location("Light as a Rabbit", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has('Bucket', player)) - set_rule(world.get_location("Glow and Behold!", player), lambda state: state._mc_can_adventure(player)) - set_rule(world.get_location("Whatever Floats Your Goat!", player), lambda state: state._mc_can_adventure(player)) - set_rule(world.get_location("Caves & Cliffs", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player) and state.has('Progressive Tools', player, 2)) - set_rule(world.get_location("Feels like home", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player) and state.has('Fishing Rod', player) and - (state._mc_fortress_loot(player) or state._mc_complete_raid(player)) and state.has("Saddle", player)) - set_rule(world.get_location("Sound of Music", player), lambda state: state.can_reach("Diamonds!", "Location", player) and state._mc_basic_combat(player)) - set_rule(world.get_location("Star Trader", player), lambda state: state._mc_has_iron_ingots(player) and state.has('Bucket', player) and - (state.can_reach("The Nether", 'Region', player) or state.can_reach("Nether Fortress", 'Region', player) or state._mc_can_piglin_trade(player)) and # soul sand for water elevator - state._mc_overworld_villager(player)) - - # 1.19 advancements - - # can make a cake, and a noteblock, and can reach a pillager outposts for allays - set_rule(world.get_location("Birthday Song", player), lambda state: state.can_reach("The Lie", "Location", player) and state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) - # can get to outposts. - # set_rule(world.get_location("You've Got a Friend in Me", player), lambda state: True) - # craft bucket and adventure to find frog spawning biome - set_rule(world.get_location("Bukkit Bukkit", player), lambda state: state.has("Bucket", player) and state._mc_has_iron_ingots(player) and state._mc_can_adventure(player)) - # I don't like this one its way to easy to get. just a pain to find. - set_rule(world.get_location("It Spreads", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has("Progressive Tools", player, 2)) - # literally just a duplicate of It spreads. - set_rule(world.get_location("Sneak 100", player), lambda state: state._mc_can_adventure(player) and state._mc_has_iron_ingots(player) and state.has("Progressive Tools", player, 2)) - set_rule(world.get_location("When the Squad Hops into Town", player), lambda state: state._mc_can_adventure(player) and state.has("Lead", player)) - # lead frogs to the nether and a basalt delta's biomes to find magma cubes. - set_rule(world.get_location("With Our Powers Combined!", player), lambda state: state._mc_can_adventure(player) and state.has("Lead", player)) + "Who is Cutting Onions?": lambda state: can_piglin_trade(state, player), + "Oh Shiny": lambda state: can_piglin_trade(state, player), + "Suit Up": lambda state: state.has("Progressive Armor", player) and has_iron_ingots(state, player), + "Very Very Frightening": lambda state: (state.has("Channeling Book", player) and + can_use_anvil(state, player) and can_enchant(state, player) and overworld_villager(state, player)), + "Hot Stuff": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player), + "Free the End": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "A Furious Cocktail": lambda state: (can_brew_potions(state, player) and + state.has("Fishing Rod", player) and # Water Breathing + state.can_reach("The Nether", "Region", player) and # Regeneration, Fire Resistance, gold nuggets + state.can_reach("Village", "Region", player) and # Night Vision, Invisibility + state.can_reach("Bring Home the Beacon", "Location", player)), # Resistance + "Bring Home the Beacon": lambda state: (can_kill_wither(state, player) and + has_diamond_pickaxe(state, player) and state.has("Progressive Resource Crafting", player, 2)), + "Not Today, Thank You": lambda state: state.has("Shield", player) and has_iron_ingots(state, player), + "Isn't It Iron Pick": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), + "Local Brewery": lambda state: can_brew_potions(state, player), + "The Next Generation": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Fishy Business": lambda state: state.has("Fishing Rod", player), + "This Boat Has Legs": lambda state: ((fortress_loot(state, player) or complete_raid(state, player)) and + state.has("Saddle", player) and state.has("Fishing Rod", player)), + "Sniper Duel": lambda state: state.has("Archery", player), + "Great View From Up Here": lambda state: basic_combat(state, player), + "How Did We Get Here?": lambda state: (can_brew_potions(state, player) and + has_gold_ingots(state, player) and # Absorption + state.can_reach('End City', 'Region', player) and # Levitation + state.can_reach('The Nether', 'Region', player) and # potion ingredients + state.has("Fishing Rod", player) and state.has("Archery",player) and # Pufferfish, Nautilus Shells; spectral arrows + state.can_reach("Bring Home the Beacon", "Location", player) and # Haste + state.can_reach("Hero of the Village", "Location", player)), # Bad Omen, Hero of the Village + "Bullseye": lambda state: (state.has("Archery", player) and state.has("Progressive Tools", player, 2) and + has_iron_ingots(state, player)), + "Spooky Scary Skeleton": lambda state: basic_combat(state, player), + "Two by Two": lambda state: has_iron_ingots(state, player) and state.has("Bucket", player) and can_adventure(state, player), + "Two Birds, One Arrow": lambda state: craft_crossbow(state, player) and can_enchant(state, player), + "Who's the Pillager Now?": lambda state: craft_crossbow(state, player), + "Getting an Upgrade": lambda state: state.has("Progressive Tools", player), + "Tactical Fishing": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player), + "Zombie Doctor": lambda state: can_brew_potions(state, player) and has_gold_ingots(state, player), + "Ice Bucket Challenge": lambda state: has_diamond_pickaxe(state, player), + "Into Fire": lambda state: basic_combat(state, player), + "War Pigs": lambda state: basic_combat(state, player), + "Take Aim": lambda state: state.has("Archery", player), + "Total Beelocation": lambda state: state.has("Silk Touch Book", player) and can_use_anvil(state, player) and can_enchant(state, player), + "Arbalistic": lambda state: (craft_crossbow(state, player) and state.has("Piercing IV Book", player) and + can_use_anvil(state, player) and can_enchant(state, player)), + "The End... Again...": lambda state: can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player), + "Acquire Hardware": lambda state: has_iron_ingots(state, player), + "Not Quite \"Nine\" Lives": lambda state: can_piglin_trade(state, player) and state.has("Progressive Resource Crafting", player, 2), + "Cover Me With Diamonds": lambda state: (state.has("Progressive Armor", player, 2) and + state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player)), + "Sky's the Limit": lambda state: basic_combat(state, player), + "Hired Help": lambda state: state.has("Progressive Resource Crafting", player, 2) and has_iron_ingots(state, player), + "Sweet Dreams": lambda state: state.has("Bed", player) or state.can_reach('Village', 'Region', player), + "You Need a Mint": lambda state: can_respawn_ender_dragon(state, player) and has_bottle(state, player), + "Monsters Hunted": lambda state: (can_respawn_ender_dragon(state, player) and can_kill_ender_dragon(state, player) and + can_kill_wither(state, player) and state.has("Fishing Rod", player)), + "Enchanter": lambda state: can_enchant(state, player), + "Voluntary Exile": lambda state: basic_combat(state, player), + "Eye Spy": lambda state: enter_stronghold(state, player), + "Serious Dedication": lambda state: (can_brew_potions(state, player) and state.has("Bed", player) and + has_diamond_pickaxe(state, player) and has_gold_ingots(state, player)), + "Postmortal": lambda state: complete_raid(state, player), + "Adventuring Time": lambda state: can_adventure(state, player), + "Hero of the Village": lambda state: complete_raid(state, player), + "Hidden in the Depths": lambda state: can_brew_potions(state, player) and state.has("Bed", player) and has_diamond_pickaxe(state, player), + "Beaconator": lambda state: (can_kill_wither(state, player) and has_diamond_pickaxe(state, player) and + state.has("Progressive Resource Crafting", player, 2)), + "Withering Heights": lambda state: can_kill_wither(state, player), + "A Balanced Diet": lambda state: (has_bottle(state, player) and has_gold_ingots(state, player) and # honey bottle; gapple + state.has("Progressive Resource Crafting", player, 2) and state.can_reach('The End', 'Region', player)), # notch apple, chorus fruit + "Subspace Bubble": lambda state: has_diamond_pickaxe(state, player), + "Country Lode, Take Me Home": lambda state: state.can_reach("Hidden in the Depths", "Location", player) and has_gold_ingots(state, player), + "Bee Our Guest": lambda state: state.has("Campfire", player) and has_bottle(state, player), + "Uneasy Alliance": lambda state: has_diamond_pickaxe(state, player) and state.has('Fishing Rod', player), + "Diamonds!": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), + "A Throwaway Joke": lambda state: can_adventure(state, player), + "Sticky Situation": lambda state: state.has("Campfire", player) and has_bottle(state, player), + "Ol' Betsy": lambda state: craft_crossbow(state, player), + "Cover Me in Debris": lambda state: (state.has("Progressive Armor", player, 2) and + state.has("8 Netherite Scrap", player, 2) and state.has("Progressive Resource Crafting", player) and + has_diamond_pickaxe(state, player) and has_iron_ingots(state, player) and + can_brew_potions(state, player) and state.has("Bed", player)), + "Hot Topic": lambda state: state.has("Progressive Resource Crafting", player), + "The Lie": lambda state: has_iron_ingots(state, player) and state.has("Bucket", player), + "On a Rail": lambda state: has_iron_ingots(state, player) and state.has('Progressive Tools', player, 2), + "When Pigs Fly": lambda state: ((fortress_loot(state, player) or complete_raid(state, player)) and + state.has("Saddle", player) and state.has("Fishing Rod", player) and can_adventure(state, player)), + "Overkill": lambda state: (can_brew_potions(state, player) and + (state.has("Progressive Weapons", player) or state.can_reach('The Nether', 'Region', player))), + "Librarian": lambda state: state.has("Enchanting", player), + "Overpowered": lambda state: (has_iron_ingots(state, player) and + state.has('Progressive Tools', player, 2) and basic_combat(state, player)), + "Wax On": lambda state: (has_copper_ingots(state, player) and state.has('Campfire', player) and + state.has('Progressive Resource Crafting', player, 2)), + "Wax Off": lambda state: (has_copper_ingots(state, player) and state.has('Campfire', player) and + state.has('Progressive Resource Crafting', player, 2)), + "The Cutest Predator": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player), + "The Healing Power of Friendship": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player), + "Is It a Bird?": lambda state: has_spyglass(state, player) and can_adventure(state, player), + "Is It a Balloon?": lambda state: has_spyglass(state, player), + "Is It a Plane?": lambda state: has_spyglass(state, player) and can_respawn_ender_dragon(state, player), + "Surge Protector": lambda state: (state.has("Channeling Book", player) and + can_use_anvil(state, player) and can_enchant(state, player) and overworld_villager(state, player)), + "Light as a Rabbit": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has('Bucket', player), + "Glow and Behold!": lambda state: can_adventure(state, player), + "Whatever Floats Your Goat!": lambda state: can_adventure(state, player), + "Caves & Cliffs": lambda state: has_iron_ingots(state, player) and state.has('Bucket', player) and state.has('Progressive Tools', player, 2), + "Feels like home": lambda state: (has_iron_ingots(state, player) and state.has('Bucket', player) and state.has('Fishing Rod', player) and + (fortress_loot(state, player) or complete_raid(state, player)) and state.has("Saddle", player)), + "Sound of Music": lambda state: state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player) and basic_combat(state, player), + "Star Trader": lambda state: (has_iron_ingots(state, player) and state.has('Bucket', player) and + (state.can_reach("The Nether", 'Region', player) or + state.can_reach("Nether Fortress", 'Region', player) or # soul sand for water elevator + can_piglin_trade(state, player)) and + overworld_villager(state, player)), + "Birthday Song": lambda state: state.can_reach("The Lie", "Location", player) and state.has("Progressive Tools", player, 2) and has_iron_ingots(state, player), + "Bukkit Bukkit": lambda state: state.has("Bucket", player) and has_iron_ingots(state, player) and can_adventure(state, player), + "It Spreads": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has("Progressive Tools", player, 2), + "Sneak 100": lambda state: can_adventure(state, player) and has_iron_ingots(state, player) and state.has("Progressive Tools", player, 2), + "When the Squad Hops into Town": lambda state: can_adventure(state, player) and state.has("Lead", player), + "With Our Powers Combined!": lambda state: can_adventure(state, player) and state.has("Lead", player), + } + } + return rules_lookup -# Sets rules on completion condition and postgame advancements -def set_completion_rules(world: MultiWorld, player: int): - def reachable_locations(state): - postgame_advancements = get_postgame_advancements(world.required_bosses[player].current_key) - return [location for location in world.get_locations() if - location.player == player and - location.name not in postgame_advancements and - location.address != None and - location.can_reach(state)] +def set_rules(mc_world: World) -> None: + multiworld = mc_world.multiworld + player = mc_world.player - def defeated_required_bosses(state): - return (world.required_bosses[player].current_key not in {"ender_dragon", "both"} or state.has("Defeat Ender Dragon", player)) and \ - (world.required_bosses[player].current_key not in {"wither", "both"} or state.has("Defeat Wither", player)) + rules_lookup = get_rules_lookup(player) - # 103 total advancements. Goal is to complete X advancements and then defeat the dragon. - # There are 11 possible postgame advancements; 5 for dragon, 5 for wither, 1 shared between them - # Hence the max for completion is 92 - egg_shards = min(world.egg_shards_required[player], world.egg_shards_available[player]) - completion_requirements = lambda state: len(reachable_locations(state)) >= world.advancement_goal[player] and \ - state.has("Dragon Egg Shard", player, egg_shards) - world.completion_condition[player] = lambda state: completion_requirements(state) and defeated_required_bosses(state) - # Set rules on postgame advancements - for adv_name in get_postgame_advancements(world.required_bosses[player].current_key): - add_rule(world.get_location(adv_name, player), completion_requirements) + # Set entrance rules + for entrance_name, rule in rules_lookup["entrances"].items(): + multiworld.get_entrance(entrance_name, player).access_rule = rule + + # Set location rules + for location_name, rule in rules_lookup["locations"].items(): + multiworld.get_location(location_name, player).access_rule = rule + + # Set rules surrounding completion + bosses = multiworld.required_bosses[player] + postgame_advancements = set() + if bosses.dragon: + postgame_advancements.update(Constants.exclusion_info["ender_dragon"]) + if bosses.wither: + postgame_advancements.update(Constants.exclusion_info["wither"]) + + def location_count(state: CollectionState) -> bool: + return len([location for location in multiworld.get_locations(player) if + location.address != None and + location.can_reach(state)]) + + def defeated_bosses(state: CollectionState) -> bool: + return ((not bosses.dragon or state.has("Ender Dragon", player)) + and (not bosses.wither or state.has("Wither", player))) + + egg_shards = min(multiworld.egg_shards_required[player], multiworld.egg_shards_available[player]) + completion_requirements = lambda state: (location_count(state) >= multiworld.advancement_goal[player] + and state.has("Dragon Egg Shard", player, egg_shards)) + multiworld.completion_condition[player] = lambda state: completion_requirements(state) and defeated_bosses(state) + + # Set exclusions on hard/unreasonable/postgame + excluded_advancements = set() + if not multiworld.include_hard_advancements[player]: + excluded_advancements.update(Constants.exclusion_info["hard"]) + if not multiworld.include_unreasonable_advancements[player]: + excluded_advancements.update(Constants.exclusion_info["unreasonable"]) + if not multiworld.include_postgame_advancements[player]: + excluded_advancements.update(postgame_advancements) + exclusion_rules(multiworld, player, excluded_advancements) diff --git a/worlds/minecraft/Structures.py b/worlds/minecraft/Structures.py new file mode 100644 index 0000000000..95bafc9efb --- /dev/null +++ b/worlds/minecraft/Structures.py @@ -0,0 +1,57 @@ +from worlds.AutoWorld import World + +from . import Constants + +def shuffle_structures(mc_world: World) -> None: + multiworld = mc_world.multiworld + player = mc_world.player + + default_connections = Constants.region_info["default_connections"] + illegal_connections = Constants.region_info["illegal_connections"] + + # Get all unpaired exits and all regions without entrances (except the Menu) + # This function is destructive on these lists. + exits = [exit.name for r in multiworld.regions if r.player == player for exit in r.exits if exit.connected_region == None] + structs = [r.name for r in multiworld.regions if r.player == player and r.entrances == [] and r.name != 'Menu'] + exits_spoiler = exits[:] # copy the original order for the spoiler log + + pairs = {} + + def set_pair(exit, struct): + if (exit in exits) and (struct in structs) and (exit not in illegal_connections.get(struct, [])): + pairs[exit] = struct + exits.remove(exit) + structs.remove(struct) + else: + raise Exception(f"Invalid connection: {exit} => {struct} for player {player} ({multiworld.player_name[player]})") + + # Connect plando structures first + if multiworld.plando_connections[player]: + for conn in multiworld.plando_connections[player]: + set_pair(conn.entrance, conn.exit) + + # The algorithm tries to place the most restrictive structures first. This algorithm always works on the + # relatively small set of restrictions here, but does not work on all possible inputs with valid configurations. + if multiworld.shuffle_structures[player]: + structs.sort(reverse=True, key=lambda s: len(illegal_connections.get(s, []))) + for struct in structs[:]: + try: + exit = multiworld.random.choice([e for e in exits if e not in illegal_connections.get(struct, [])]) + except IndexError: + raise Exception(f"No valid structure placements remaining for player {player} ({multiworld.player_name[player]})") + set_pair(exit, struct) + else: # write remaining default connections + for (exit, struct) in default_connections: + if exit in exits: + set_pair(exit, struct) + + # Make sure we actually paired everything; might fail if plando + try: + assert len(exits) == len(structs) == 0 + except AssertionError: + raise Exception(f"Failed to connect all Minecraft structures for player {player} ({multiworld.player_name[player]})") + + for exit in exits_spoiler: + multiworld.get_entrance(exit, player).connect(multiworld.get_region(pairs[exit], player)) + if multiworld.shuffle_structures[player] or multiworld.plando_connections[player]: + multiworld.spoiler.set_entrance(exit, pairs[exit], 'entrance', player) diff --git a/worlds/minecraft/__init__.py b/worlds/minecraft/__init__.py index cbd274ba84..a685d1ab4b 100644 --- a/worlds/minecraft/__init__.py +++ b/worlds/minecraft/__init__.py @@ -1,17 +1,16 @@ import os import json from base64 import b64encode, b64decode -from math import ceil +from typing import Dict, Any -from .Items import MinecraftItem, item_table, required_items, junk_weights -from .Locations import MinecraftAdvancement, advancement_table, exclusion_table, get_postgame_advancements -from .Regions import mc_regions, link_minecraft_structures, default_connections -from .Rules import set_advancement_rules, set_completion_rules -from worlds.generic.Rules import exclusion_rules +from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification, Location +from worlds.AutoWorld import World, WebWorld -from BaseClasses import Region, Entrance, Item, Tutorial, ItemClassification +from . import Constants from .Options import minecraft_options -from ..AutoWorld import World, WebWorld +from .Structures import shuffle_structures +from .ItemPool import build_item_pool, get_junk_item_names +from .Rules import set_rules client_version = 9 @@ -47,7 +46,16 @@ class MinecraftWebWorld(WebWorld): ["Albinum"] ) - tutorials = [setup, setup_es, setup_sv] + setup_fr = Tutorial( + setup.tutorial_name, + setup.description, + "Français", + "minecraft_fr.md", + "minecraft/fr", + ["TheLynk"] + ) + + tutorials = [setup, setup_es, setup_sv, setup_fr] class MinecraftWorld(World): @@ -62,13 +70,13 @@ class MinecraftWorld(World): topology_present = True web = MinecraftWebWorld() - item_name_to_id = {name: data.code for name, data in item_table.items()} - location_name_to_id = {name: data.id for name, data in advancement_table.items()} + item_name_to_id = Constants.item_name_to_id + location_name_to_id = Constants.location_name_to_id data_version = 7 - def _get_mc_data(self): - exits = [connection[0] for connection in default_connections] + def _get_mc_data(self) -> Dict[str, Any]: + exits = [connection[0] for connection in Constants.region_info["default_connections"]] return { 'world_seed': self.multiworld.per_slot_randoms[self.player].getrandbits(32), 'seed_name': self.multiworld.seed_name, @@ -87,74 +95,70 @@ class MinecraftWorld(World): 'race': self.multiworld.is_race, } - def generate_basic(self): + def create_item(self, name: str) -> Item: + item_class = ItemClassification.filler + if name in Constants.item_info["progression_items"]: + item_class = ItemClassification.progression + elif name in Constants.item_info["useful_items"]: + item_class = ItemClassification.useful + elif name in Constants.item_info["trap_items"]: + item_class = ItemClassification.trap - # Generate item pool - itempool = [] - junk_pool = junk_weights.copy() - # Add all required progression items - for (name, num) in required_items.items(): - itempool += [name] * num - # Add structure compasses if desired - if self.multiworld.structure_compasses[self.player]: - structures = [connection[1] for connection in default_connections] - for struct_name in structures: - itempool.append(f"Structure Compass ({struct_name})") - # Add dragon egg shards - if self.multiworld.egg_shards_required[self.player] > 0: - itempool += ["Dragon Egg Shard"] * self.multiworld.egg_shards_available[self.player] - # Add bee traps if desired - bee_trap_quantity = ceil(self.multiworld.bee_traps[self.player] * (len(self.location_names) - len(itempool)) * 0.01) - itempool += ["Bee Trap"] * bee_trap_quantity - # Fill remaining items with randomly generated junk - itempool += self.multiworld.random.choices(list(junk_pool.keys()), weights=list(junk_pool.values()), k=len(self.location_names) - len(itempool)) - # Convert itempool into real items - itempool = [item for item in map(lambda name: self.create_item(name), itempool)] + return MinecraftItem(name, item_class, self.item_name_to_id.get(name, None), self.player) - # Choose locations to automatically exclude based on settings - exclusion_pool = set() - exclusion_types = ['hard', 'unreasonable'] - for key in exclusion_types: - if not getattr(self.multiworld, f"include_{key}_advancements")[self.player]: - exclusion_pool.update(exclusion_table[key]) - # For postgame advancements, check with the boss goal - exclusion_pool.update(get_postgame_advancements(self.multiworld.required_bosses[self.player].current_key)) - exclusion_rules(self.multiworld, self.player, exclusion_pool) + def create_event(self, region_name: str, event_name: str) -> None: + region = self.multiworld.get_region(region_name, self.player) + loc = MinecraftLocation(self.player, event_name, None, region) + loc.place_locked_item(self.create_event_item(event_name)) + region.locations.append(loc) - # Prefill event locations with their events - self.multiworld.get_location("Blaze Spawner", self.player).place_locked_item(self.create_item("Blaze Rods")) - self.multiworld.get_location("Ender Dragon", self.player).place_locked_item(self.create_item("Defeat Ender Dragon")) - self.multiworld.get_location("Wither", self.player).place_locked_item(self.create_item("Defeat Wither")) + def create_event_item(self, name: str) -> None: + item = self.create_item(name) + item.classification = ItemClassification.progression + return item - self.multiworld.itempool += itempool + def create_regions(self) -> None: + # Create regions + for region_name, exits in Constants.region_info["regions"]: + r = Region(region_name, self.player, self.multiworld) + for exit_name in exits: + r.exits.append(Entrance(self.player, exit_name, r)) + self.multiworld.regions.append(r) - def get_filler_item_name(self) -> str: - return self.multiworld.random.choices(list(junk_weights.keys()), weights=list(junk_weights.values()))[0] + # Bind mandatory connections + for entr_name, region_name in Constants.region_info["mandatory_connections"]: + e = self.multiworld.get_entrance(entr_name, self.player) + r = self.multiworld.get_region(region_name, self.player) + e.connect(r) - def set_rules(self): - set_advancement_rules(self.multiworld, self.player) - set_completion_rules(self.multiworld, self.player) + # Add locations + for region_name, locations in Constants.location_info["locations_by_region"].items(): + region = self.multiworld.get_region(region_name, self.player) + for loc_name in locations: + loc = MinecraftLocation(self.player, loc_name, + self.location_name_to_id.get(loc_name, None), region) + region.locations.append(loc) - def create_regions(self): - def MCRegion(region_name: str, exits=[]): - ret = Region(region_name, self.player, self.multiworld) - ret.locations = [MinecraftAdvancement(self.player, loc_name, loc_data.id, ret) - for loc_name, loc_data in advancement_table.items() - if loc_data.region == region_name] - for exit in exits: - ret.exits.append(Entrance(self.player, exit, ret)) - return ret + # Add events + self.create_event("Nether Fortress", "Blaze Rods") + self.create_event("The End", "Ender Dragon") + self.create_event("Nether Fortress", "Wither") - self.multiworld.regions += [MCRegion(*r) for r in mc_regions] - link_minecraft_structures(self.multiworld, self.player) + # Shuffle the connections + shuffle_structures(self) - def generate_output(self, output_directory: str): + def create_items(self) -> None: + self.multiworld.itempool += build_item_pool(self) + + set_rules = set_rules + + def generate_output(self, output_directory: str) -> None: data = self._get_mc_data() filename = f"AP_{self.multiworld.get_out_file_name_base(self.player)}.apmc" with open(os.path.join(output_directory, filename), 'wb') as f: f.write(b64encode(bytes(json.dumps(data), 'utf-8'))) - def fill_slot_data(self): + def fill_slot_data(self) -> dict: slot_data = self._get_mc_data() for option_name in minecraft_options: option = getattr(self.multiworld, option_name)[self.player] @@ -162,20 +166,16 @@ class MinecraftWorld(World): slot_data[option_name] = int(option.value) return slot_data - def create_item(self, name: str) -> Item: - item_data = item_table[name] - if name == "Bee Trap": - classification = ItemClassification.trap - # prevent books from going on excluded locations - elif name in ("Sharpness III Book", "Infinity Book", "Looting III Book"): - classification = ItemClassification.useful - elif item_data.progression: - classification = ItemClassification.progression - else: - classification = ItemClassification.filler - item = MinecraftItem(name, classification, item_data.code, self.player) + def get_filler_item_name(self) -> str: + return get_junk_item_names(self.multiworld.random, 1)[0] + + +class MinecraftLocation(Location): + game = "Minecraft" + +class MinecraftItem(Item): + game = "Minecraft" - return item def mc_update_output(raw_data, server, port): data = json.loads(b64decode(raw_data)) diff --git a/worlds/minecraft/data/excluded_locations.json b/worlds/minecraft/data/excluded_locations.json new file mode 100644 index 0000000000..2f6fbbba6d --- /dev/null +++ b/worlds/minecraft/data/excluded_locations.json @@ -0,0 +1,40 @@ +{ + "hard": [ + "Very Very Frightening", + "A Furious Cocktail", + "Two by Two", + "Two Birds, One Arrow", + "Arbalistic", + "Monsters Hunted", + "Beaconator", + "A Balanced Diet", + "Uneasy Alliance", + "Cover Me in Debris", + "A Complete Catalogue", + "Surge Protector", + "Sound of Music", + "Star Trader", + "When the Squad Hops into Town", + "With Our Powers Combined!" + ], + "unreasonable": [ + "How Did We Get Here?", + "Adventuring Time" + ], + "ender_dragon": [ + "Free the End", + "The Next Generation", + "The End... Again...", + "You Need a Mint", + "Monsters Hunted", + "Is It a Plane?" + ], + "wither": [ + "Withering Heights", + "Bring Home the Beacon", + "Beaconator", + "A Furious Cocktail", + "How Did We Get Here?", + "Monsters Hunted" + ] +} \ No newline at end of file diff --git a/worlds/minecraft/data/items.json b/worlds/minecraft/data/items.json new file mode 100644 index 0000000000..7d35d18aeb --- /dev/null +++ b/worlds/minecraft/data/items.json @@ -0,0 +1,128 @@ +{ + "all_items": [ + "Archery", + "Progressive Resource Crafting", + "Resource Blocks", + "Brewing", + "Enchanting", + "Bucket", + "Flint and Steel", + "Bed", + "Bottles", + "Shield", + "Fishing Rod", + "Campfire", + "Progressive Weapons", + "Progressive Tools", + "Progressive Armor", + "8 Netherite Scrap", + "8 Emeralds", + "4 Emeralds", + "Channeling Book", + "Silk Touch Book", + "Sharpness III Book", + "Piercing IV Book", + "Looting III Book", + "Infinity Book", + "4 Diamond Ore", + "16 Iron Ore", + "500 XP", + "100 XP", + "50 XP", + "3 Ender Pearls", + "4 Lapis Lazuli", + "16 Porkchops", + "8 Gold Ore", + "Rotten Flesh", + "Single Arrow", + "32 Arrows", + "Saddle", + "Structure Compass (Village)", + "Structure Compass (Pillager Outpost)", + "Structure Compass (Nether Fortress)", + "Structure Compass (Bastion Remnant)", + "Structure Compass (End City)", + "Shulker Box", + "Dragon Egg Shard", + "Spyglass", + "Lead", + "Bee Trap" + ], + "progression_items": [ + "Archery", + "Progressive Resource Crafting", + "Resource Blocks", + "Brewing", + "Enchanting", + "Bucket", + "Flint and Steel", + "Bed", + "Bottles", + "Shield", + "Fishing Rod", + "Campfire", + "Progressive Weapons", + "Progressive Tools", + "Progressive Armor", + "8 Netherite Scrap", + "Channeling Book", + "Silk Touch Book", + "Piercing IV Book", + "3 Ender Pearls", + "Saddle", + "Structure Compass (Village)", + "Structure Compass (Pillager Outpost)", + "Structure Compass (Nether Fortress)", + "Structure Compass (Bastion Remnant)", + "Structure Compass (End City)", + "Dragon Egg Shard", + "Spyglass", + "Lead" + ], + "useful_items": [ + "Sharpness III Book", + "Looting III Book", + "Infinity Book" + ], + "trap_items": [ + "Bee Trap" + ], + + "required_pool": { + "Archery": 1, + "Progressive Resource Crafting": 2, + "Brewing": 1, + "Enchanting": 1, + "Bucket": 1, + "Flint and Steel": 1, + "Bed": 1, + "Bottles": 1, + "Shield": 1, + "Fishing Rod": 1, + "Campfire": 1, + "Progressive Weapons": 3, + "Progressive Tools": 3, + "Progressive Armor": 2, + "8 Netherite Scrap": 2, + "Channeling Book": 1, + "Silk Touch Book": 1, + "Sharpness III Book": 1, + "Piercing IV Book": 1, + "Looting III Book": 1, + "Infinity Book": 1, + "3 Ender Pearls": 4, + "Saddle": 1, + "Spyglass": 1, + "Lead": 1 + }, + "junk_weights": { + "4 Emeralds": 2, + "4 Diamond Ore": 1, + "16 Iron Ore": 1, + "50 XP": 4, + "16 Porkchops": 2, + "8 Gold Ore": 1, + "Rotten Flesh": 1, + "32 Arrows": 1 + } +} \ No newline at end of file diff --git a/worlds/minecraft/data/locations.json b/worlds/minecraft/data/locations.json new file mode 100644 index 0000000000..7cd00e5851 --- /dev/null +++ b/worlds/minecraft/data/locations.json @@ -0,0 +1,250 @@ +{ + "all_locations": [ + "Who is Cutting Onions?", + "Oh Shiny", + "Suit Up", + "Very Very Frightening", + "Hot Stuff", + "Free the End", + "A Furious Cocktail", + "Best Friends Forever", + "Bring Home the Beacon", + "Not Today, Thank You", + "Isn't It Iron Pick", + "Local Brewery", + "The Next Generation", + "Fishy Business", + "Hot Tourist Destinations", + "This Boat Has Legs", + "Sniper Duel", + "Nether", + "Great View From Up Here", + "How Did We Get Here?", + "Bullseye", + "Spooky Scary Skeleton", + "Two by Two", + "Stone Age", + "Two Birds, One Arrow", + "We Need to Go Deeper", + "Who's the Pillager Now?", + "Getting an Upgrade", + "Tactical Fishing", + "Zombie Doctor", + "The City at the End of the Game", + "Ice Bucket Challenge", + "Remote Getaway", + "Into Fire", + "War Pigs", + "Take Aim", + "Total Beelocation", + "Arbalistic", + "The End... Again...", + "Acquire Hardware", + "Not Quite \"Nine\" Lives", + "Cover Me With Diamonds", + "Sky's the Limit", + "Hired Help", + "Return to Sender", + "Sweet Dreams", + "You Need a Mint", + "Adventure", + "Monsters Hunted", + "Enchanter", + "Voluntary Exile", + "Eye Spy", + "The End", + "Serious Dedication", + "Postmortal", + "Monster Hunter", + "Adventuring Time", + "A Seedy Place", + "Those Were the Days", + "Hero of the Village", + "Hidden in the Depths", + "Beaconator", + "Withering Heights", + "A Balanced Diet", + "Subspace Bubble", + "Husbandry", + "Country Lode, Take Me Home", + "Bee Our Guest", + "What a Deal!", + "Uneasy Alliance", + "Diamonds!", + "A Terrible Fortress", + "A Throwaway Joke", + "Minecraft", + "Sticky Situation", + "Ol' Betsy", + "Cover Me in Debris", + "The End?", + "The Parrots and the Bats", + "A Complete Catalogue", + "Getting Wood", + "Time to Mine!", + "Hot Topic", + "Bake Bread", + "The Lie", + "On a Rail", + "Time to Strike!", + "Cow Tipper", + "When Pigs Fly", + "Overkill", + "Librarian", + "Overpowered", + "Wax On", + "Wax Off", + "The Cutest Predator", + "The Healing Power of Friendship", + "Is It a Bird?", + "Is It a Balloon?", + "Is It a Plane?", + "Surge Protector", + "Light as a Rabbit", + "Glow and Behold!", + "Whatever Floats Your Goat!", + "Caves & Cliffs", + "Feels like home", + "Sound of Music", + "Star Trader", + "Birthday Song", + "Bukkit Bukkit", + "It Spreads", + "Sneak 100", + "When the Squad Hops into Town", + "With Our Powers Combined!", + "You've Got a Friend in Me" + ], + "locations_by_region": { + "Overworld": [ + "Who is Cutting Onions?", + "Oh Shiny", + "Suit Up", + "Very Very Frightening", + "Hot Stuff", + "Best Friends Forever", + "Not Today, Thank You", + "Isn't It Iron Pick", + "Fishy Business", + "Sniper Duel", + "Bullseye", + "Stone Age", + "Two Birds, One Arrow", + "Getting an Upgrade", + "Tactical Fishing", + "Zombie Doctor", + "Ice Bucket Challenge", + "Take Aim", + "Total Beelocation", + "Arbalistic", + "Acquire Hardware", + "Cover Me With Diamonds", + "Hired Help", + "Sweet Dreams", + "Adventure", + "Monsters Hunted", + "Enchanter", + "Eye Spy", + "Monster Hunter", + "Adventuring Time", + "A Seedy Place", + "Husbandry", + "Bee Our Guest", + "Diamonds!", + "A Throwaway Joke", + "Minecraft", + "Sticky Situation", + "Ol' Betsy", + "The Parrots and the Bats", + "Getting Wood", + "Time to Mine!", + "Hot Topic", + "Bake Bread", + "The Lie", + "On a Rail", + "Time to Strike!", + "Cow Tipper", + "When Pigs Fly", + "Librarian", + "Wax On", + "Wax Off", + "The Cutest Predator", + "The Healing Power of Friendship", + "Is It a Bird?", + "Surge Protector", + "Light as a Rabbit", + "Glow and Behold!", + "Whatever Floats Your Goat!", + "Caves & Cliffs", + "Sound of Music", + "Bukkit Bukkit", + "It Spreads", + "Sneak 100", + "When the Squad Hops into Town" + ], + "The Nether": [ + "Hot Tourist Destinations", + "This Boat Has Legs", + "Nether", + "Two by Two", + "We Need to Go Deeper", + "Not Quite \"Nine\" Lives", + "Return to Sender", + "Serious Dedication", + "Hidden in the Depths", + "Subspace Bubble", + "Country Lode, Take Me Home", + "Uneasy Alliance", + "Cover Me in Debris", + "Is It a Balloon?", + "Feels like home", + "With Our Powers Combined!" + ], + "The End": [ + "Free the End", + "The Next Generation", + "Remote Getaway", + "The End... Again...", + "You Need a Mint", + "The End", + "The End?", + "Is It a Plane?" + ], + "Village": [ + "Postmortal", + "Hero of the Village", + "A Balanced Diet", + "What a Deal!", + "A Complete Catalogue", + "Star Trader" + ], + "Nether Fortress": [ + "A Furious Cocktail", + "Bring Home the Beacon", + "Local Brewery", + "How Did We Get Here?", + "Spooky Scary Skeleton", + "Into Fire", + "Beaconator", + "Withering Heights", + "A Terrible Fortress", + "Overkill" + ], + "Pillager Outpost": [ + "Who's the Pillager Now?", + "Voluntary Exile", + "Birthday Song", + "You've Got a Friend in Me" + ], + "Bastion Remnant": [ + "War Pigs", + "Those Were the Days", + "Overpowered" + ], + "End City": [ + "Great View From Up Here", + "The City at the End of the Game", + "Sky's the Limit" + ] + } +} \ No newline at end of file diff --git a/worlds/minecraft/data/regions.json b/worlds/minecraft/data/regions.json new file mode 100644 index 0000000000..c9e51e4829 --- /dev/null +++ b/worlds/minecraft/data/regions.json @@ -0,0 +1,28 @@ +{ + "regions": [ + ["Menu", ["New World"]], + ["Overworld", ["Nether Portal", "End Portal", "Overworld Structure 1", "Overworld Structure 2"]], + ["The Nether", ["Nether Structure 1", "Nether Structure 2"]], + ["The End", ["The End Structure"]], + ["Village", []], + ["Pillager Outpost", []], + ["Nether Fortress", []], + ["Bastion Remnant", []], + ["End City", []] + ], + "mandatory_connections": [ + ["New World", "Overworld"], + ["Nether Portal", "The Nether"], + ["End Portal", "The End"] + ], + "default_connections": [ + ["Overworld Structure 1", "Village"], + ["Overworld Structure 2", "Pillager Outpost"], + ["Nether Structure 1", "Nether Fortress"], + ["Nether Structure 2", "Bastion Remnant"], + ["The End Structure", "End City"] + ], + "illegal_connections": { + "Nether Fortress": ["The End Structure"] + } +} \ No newline at end of file diff --git a/worlds/minecraft/docs/minecraft_fr.md b/worlds/minecraft/docs/minecraft_fr.md new file mode 100644 index 0000000000..e25febba42 --- /dev/null +++ b/worlds/minecraft/docs/minecraft_fr.md @@ -0,0 +1,74 @@ +# Guide de configuration du randomiseur Minecraft + +## Logiciel requis + +- Minecraft Java Edition à partir de + la [page de la boutique Minecraft Java Edition](https://www.minecraft.net/en-us/store/minecraft-java-edition) +- Archipelago depuis la [page des versions d'Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) + - (sÊlectionnez `Minecraft Client` lors de l'installation.) + +## Configuration de votre fichier YAML + +### Qu'est-ce qu'un fichier YAML et pourquoi en ai-je besoin ? + +Voir le guide sur la configuration d'un YAML de base lors de la configuration d'Archipelago +guide : [Guide de configuration de base de Multiworld](/tutorial/Archipelago/setup/en) + +### OÚ puis-je obtenir un fichier YAML ? + +Vous pouvez personnaliser vos paramètres Minecraft en allant sur la [page des paramètres de joueur](/games/Minecraft/player-settings) + +## Rejoindre une partie MultiWorld + +### Obtenez votre fichier de donnÊes Minecraft + +**Un seul fichier yaml doit ÃĒtre soumis par monde minecraft, quel que soit le nombre de joueurs qui y jouent.** + +Lorsque vous rejoignez un jeu multimonde, il vous sera demandÊ de fournir votre fichier YAML à l'hÊbergeur. Une fois cela fait, +l'hÊbergeur vous fournira soit un lien pour tÊlÊcharger votre fichier de donnÊes, soit un fichier zip contenant les donnÊes de chacun +des dossiers. Votre fichier de donnÊes doit avoir une extension `.apmc`. + +Double-cliquez sur votre fichier `.apmc` pour que le client Minecraft lance automatiquement le serveur forge installÊ. Assurez-vous de +laissez cette fenÃĒtre ouverte car il s'agit de votre console serveur. + +### Connectez-vous au multiserveur + +Ouvrez Minecraft, accÊdez à "Multijoueur> Connexion directe" et rejoignez l'adresse du serveur "localhost". + +Si vous utilisez le site Web pour hÊberger le jeu, il devrait se connecter automatiquement au serveur AP sans avoir besoin de `/connect` + +sinon, une fois que vous ÃĒtes dans le jeu, tapez `/connect