diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7151ff00c88e..f5eb371567be 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,12 +9,14 @@ on: - 'setup.py' - 'requirements.txt' - '*.iss' + - 'worlds/*/archipelago.json' pull_request: paths: - '.github/workflows/build.yml' - 'setup.py' - 'requirements.txt' - '*.iss' + - 'worlds/*/archipelago.json' workflow_dispatch: env: diff --git a/CommonClient.py b/CommonClient.py index b1d9aeb15624..41cc08d1d0a9 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -856,9 +856,9 @@ async def server_loop(ctx: CommonContext, address: typing.Optional[str] = None) server_url = urllib.parse.urlparse(address) if server_url.username: - ctx.username = server_url.username + ctx.username = urllib.parse.unquote(server_url.username) if server_url.password: - ctx.password = server_url.password + ctx.password = urllib.parse.unquote(server_url.password) def reconnect_hint() -> str: return ", type /connect to reconnect" if ctx.server_address else "" diff --git a/Generate.py b/Generate.py index 8e8132038509..1044a9880d88 100644 --- a/Generate.py +++ b/Generate.py @@ -23,7 +23,7 @@ from Utils import parse_yamls, version_tuple, __version__, tuplize_version -def mystery_argparse(): +def mystery_argparse(argv: list[str] | None = None): from settings import get_settings settings = get_settings() defaults = settings.generator @@ -57,7 +57,7 @@ def mystery_argparse(): parser.add_argument("--spoiler_only", action="store_true", help="Skips generation assertion and multidata, outputting only a spoiler log. " "Intended for debugging and testing purposes.") - args = parser.parse_args() + args = parser.parse_args(argv) if args.skip_output and args.spoiler_only: parser.error("Cannot mix --skip_output and --spoiler_only") diff --git a/MultiServer.py b/MultiServer.py index a96131d58ec7..1de44caddc9d 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -135,6 +135,7 @@ def get_saving_second(seed_name: str, interval: int = 60) -> int: class Client(Endpoint): __slots__ = ( + "__weakref__", "version", "auth", "team", @@ -1199,16 +1200,17 @@ def collect_hints(ctx: Context, team: int, slot: int, item: typing.Union[int, st found = location_id in ctx.location_checks[team, finding_player] entrance = ctx.er_hint_data.get(finding_player, {}).get(location_id, "") + hint_status = status # Assign again because we're in a for loop if found: - status = HintStatus.HINT_FOUND - elif status is None: + hint_status = HintStatus.HINT_FOUND + elif hint_status is None: if item_flags & ItemClassification.trap: - status = HintStatus.HINT_AVOID + hint_status = HintStatus.HINT_AVOID else: - status = HintStatus.HINT_PRIORITY + hint_status = HintStatus.HINT_PRIORITY hints.append( - Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, status) + Hint(receiving_player, finding_player, location_id, item_id, found, entrance, item_flags, hint_status) ) return hints diff --git a/Options.py b/Options.py index 826e45a52ae0..d4e42fc02d8c 100644 --- a/Options.py +++ b/Options.py @@ -1474,8 +1474,10 @@ def verify(self, world: typing.Type[World], player_name: str, plando_options: "P super(ItemLinks, self).verify(world, player_name, plando_options) existing_links = set() for link in self.value: + link["name"] = link["name"].strip()[:16].strip() if link["name"] in existing_links: - raise Exception(f"You cannot have more than one link named {link['name']}.") + raise Exception(f"Item link names are limited to their first 16 characters and must be unique. " + f"You have more than one link named '{link['name']}'.") existing_links.add(link["name"]) pool = self.verify_items(link["item_pool"], link["name"], "item_pool", world) diff --git a/Utils.py b/Utils.py index 02bc8e8f6fbd..e79e54182d88 100644 --- a/Utils.py +++ b/Utils.py @@ -1,6 +1,7 @@ from __future__ import annotations import asyncio +import concurrent.futures import json import typing import builtins @@ -477,7 +478,7 @@ def find_class(self, module: str, name: str) -> type: mod = importlib.import_module(module) obj = getattr(mod, name) if issubclass(obj, (self.options_module.Option, self.options_module.PlandoConnection, - self.options_module.PlandoText)): + self.options_module.PlandoItem, self.options_module.PlandoText)): return obj # Forbid everything else. raise pickle.UnpicklingError(f"global '{module}.{name}' is forbidden") @@ -1138,3 +1139,40 @@ def is_iterable_except_str(obj: object) -> TypeGuard[typing.Iterable[typing.Any] if isinstance(obj, str): return False return isinstance(obj, typing.Iterable) + + +class DaemonThreadPoolExecutor(concurrent.futures.ThreadPoolExecutor): + """ + ThreadPoolExecutor that uses daemonic threads that do not keep the program alive. + NOTE: use this with caution because killed threads will not properly clean up. + """ + + def _adjust_thread_count(self): + # see upstream ThreadPoolExecutor for details + import threading + import weakref + from concurrent.futures.thread import _worker + + if self._idle_semaphore.acquire(timeout=0): + return + + def weakref_cb(_, q=self._work_queue): + q.put(None) + + num_threads = len(self._threads) + if num_threads < self._max_workers: + thread_name = f"{self._thread_name_prefix or self}_{num_threads}" + t = threading.Thread( + name=thread_name, + target=_worker, + args=( + weakref.ref(self, weakref_cb), + self._work_queue, + self._initializer, + self._initargs, + ), + daemon=True, + ) + t.start() + self._threads.add(t) + # NOTE: don't add to _threads_queues so we don't block on shutdown diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index 74086cb8842b..e4c2ab83c76c 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -1,6 +1,7 @@ import base64 import os import socket +import typing import uuid from flask import Flask @@ -61,20 +62,21 @@ Compress(app) -def to_python(value): +def to_python(value: str) -> uuid.UUID: return uuid.UUID(bytes=base64.urlsafe_b64decode(value + '==')) -def to_url(value): +def to_url(value: uuid.UUID) -> str: return base64.urlsafe_b64encode(value.bytes).rstrip(b'=').decode('ascii') class B64UUIDConverter(BaseConverter): - def to_python(self, value): + def to_python(self, value: str) -> uuid.UUID: return to_python(value) - def to_url(self, value): + def to_url(self, value: typing.Any) -> str: + assert isinstance(value, uuid.UUID) return to_url(value) @@ -84,7 +86,7 @@ def to_url(self, value): app.jinja_env.filters["title_sorted"] = title_sorted -def register(): +def register() -> None: """Import submodules, triggering their registering on flask routing. Note: initializes worlds subsystem.""" import importlib diff --git a/WebHostLib/autolauncher.py b/WebHostLib/autolauncher.py index 719963e37508..96ffbe9e9540 100644 --- a/WebHostLib/autolauncher.py +++ b/WebHostLib/autolauncher.py @@ -17,7 +17,7 @@ _stop_event = Event() -def stop(): +def stop() -> None: """Stops previously launched threads""" global _stop_event stop_event = _stop_event @@ -36,25 +36,39 @@ def handle_generation_failure(result: BaseException): logging.exception(e) -def _mp_gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None) -> PrimaryKey | None: +def _mp_gen_game( + gen_options: dict, + meta: dict[str, Any] | None = None, + owner=None, + sid=None, + timeout: int|None = None, +) -> PrimaryKey | None: from setproctitle import setproctitle setproctitle(f"Generator ({sid})") - res = gen_game(gen_options, meta=meta, owner=owner, sid=sid) - setproctitle(f"Generator (idle)") - return res + try: + return gen_game(gen_options, meta=meta, owner=owner, sid=sid, timeout=timeout) + finally: + setproctitle(f"Generator (idle)") -def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation): +def launch_generator(pool: multiprocessing.pool.Pool, generation: Generation, timeout: int|None) -> None: try: meta = json.loads(generation.meta) options = restricted_loads(generation.options) logging.info(f"Generating {generation.id} for {len(options)} players") - pool.apply_async(_mp_gen_game, (options,), - {"meta": meta, - "sid": generation.id, - "owner": generation.owner}, - handle_generation_success, handle_generation_failure) + pool.apply_async( + _mp_gen_game, + (options,), + { + "meta": meta, + "sid": generation.id, + "owner": generation.owner, + "timeout": timeout, + }, + handle_generation_success, + handle_generation_failure, + ) except Exception as e: generation.state = STATE_ERROR commit() @@ -135,6 +149,7 @@ def keep_running(): with multiprocessing.Pool(config["GENERATORS"], initializer=init_generator, initargs=(config,), maxtasksperchild=10) as generator_pool: + job_time = config["JOB_TIME"] with db_session: to_start = select(generation for generation in Generation if generation.state == STATE_STARTED) @@ -145,7 +160,7 @@ def keep_running(): if sid: generation.delete() else: - launch_generator(generator_pool, generation) + launch_generator(generator_pool, generation, timeout=job_time) commit() select(generation for generation in Generation if generation.state == STATE_ERROR).delete() @@ -157,7 +172,7 @@ def keep_running(): generation for generation in Generation if generation.state == STATE_QUEUED).for_update() for generation in to_start: - launch_generator(generator_pool, generation) + launch_generator(generator_pool, generation, timeout=job_time) except AlreadyRunningException: logging.info("Autogen reports as already running, not starting another.") diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index 1bde8f780578..f80663ff43f1 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -14,7 +14,7 @@ from BaseClasses import get_seed, seeddigits from Generate import PlandoOptions, handle_name, mystery_argparse from Main import main as ERmain -from Utils import __version__, restricted_dumps +from Utils import __version__, restricted_dumps, DaemonThreadPoolExecutor from WebHostLib import app from settings import ServerOptions, GeneratorOptions from .check import get_yaml_data, roll_options @@ -107,7 +107,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): else: try: seed_id = gen_game({name: vars(options) for name, options in gen_options.items()}, - meta=meta, owner=session["_id"].int) + meta=meta, owner=session["_id"].int, timeout=app.config["JOB_TIME"]) except BaseException as e: from .autolauncher import handle_generation_failure handle_generation_failure(e) @@ -118,7 +118,7 @@ def start_generation(options: dict[str, dict | str], meta: dict[str, Any]): return redirect(url_for("view_seed", seed=seed_id)) -def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None): +def gen_game(gen_options: dict, meta: dict[str, Any] | None = None, owner=None, sid=None, timeout: int|None = None): if meta is None: meta = {} @@ -137,7 +137,7 @@ def task(): seedname = "W" + (f"{random.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)) - args = mystery_argparse() + args = mystery_argparse([]) # Just to set up the Namespace with defaults args.multi = playercount args.seed = seed args.name = {x: "" for x in range(1, playercount + 1)} # only so it can be overwritten in mystery @@ -172,11 +172,12 @@ def task(): ERmain(args, seed, baked_server_options=meta["server_options"]) return upload_to_db(target.name, sid, owner, race) - thread_pool = concurrent.futures.ThreadPoolExecutor(max_workers=1) + + thread_pool = DaemonThreadPoolExecutor(max_workers=1) thread = thread_pool.submit(task) try: - return thread.result(app.config["JOB_TIME"]) + return thread.result(timeout) except concurrent.futures.TimeoutError as e: if sid: with db_session: @@ -189,6 +190,9 @@ def task(): format_exception(e)) gen.meta = json.dumps(meta) commit() + except (KeyboardInterrupt, SystemExit): + # don't update db, retry next time + raise except BaseException as e: if sid: with db_session: @@ -200,6 +204,11 @@ def task(): gen.meta = json.dumps(meta) commit() raise + finally: + # free resources claimed by thread pool, if possible + # NOTE: Timeout depends on the process being killed at some point + # since we can't actually cancel a running gen at the moment. + thread_pool.shutdown(wait=False, cancel_futures=True) @app.route('/wait/') diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 2df2e64aeb86..c2f1619f9ff0 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -76,7 +76,7 @@ def filter_rst_to_html(text: str) -> str: lines = text.splitlines() text = lines[0] + "\n" + dedent("\n".join(lines[1:])) - return publish_parts(text, writer_name='html', settings=None, settings_overrides={ + return publish_parts(text, writer='html', settings=None, settings_overrides={ 'raw_enable': False, 'file_insertion_enabled': False, 'output_encoding': 'unicode' diff --git a/WebHostLib/requirements.txt b/WebHostLib/requirements.txt index f64ed085c982..e7181bd9a791 100644 --- a/WebHostLib/requirements.txt +++ b/WebHostLib/requirements.txt @@ -4,7 +4,8 @@ pony>=0.7.19; python_version <= '3.12' pony @ git+https://github.com/black-sliver/pony@7feb1221953b7fa4a6735466bf21a8b4d35e33ba#0.7.19; python_version >= '3.13' waitress>=3.0.2 Flask-Caching>=2.3.0 -Flask-Compress>=1.17 +Flask-Compress>=1.17; python_version >= '3.12' +Flask-Compress==1.18; python_version <= '3.11' # 3.11's pkg_resources can't resolve the new "backports.zstd" dependency Flask-Limiter>=3.12 bokeh>=3.6.3 markupsafe>=3.0.2 diff --git a/docs/apworld specification.md b/docs/apworld specification.md index be0e9cc15021..7022e0ac3df2 100644 --- a/docs/apworld specification.md +++ b/docs/apworld specification.md @@ -1,49 +1,49 @@ -# apworld Specification +# APWorld Specification Archipelago depends on worlds to provide game-specific details like items, locations and output generation. -Those are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). +These are called "APWorlds". +They are located in the `worlds/` folder (source) or `/lib/worlds/` (when installed). See [world api.md](world%20api.md) for details. +APWorlds can either be a folder, or they can be packaged as an .apworld file. -apworld provides a way to package and ship a world that is not part of the main distribution by placing a `*.apworld` -file into the worlds folder. +## .apworld File Format -**Warning:** apworlds have to be all lower case, otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! +The `.apworld` file format provides a way to package and ship an APWorld that is not part of the main distribution +by placing a `*.apworld` file into the worlds folder. - -## File Format - -apworld files are zip archives, all lower case, with the file ending `.apworld`. +`.apworld` files are zip archives, all lower case, with the file ending `.apworld`. The zip has to contain a folder with the same name as the zip, case-sensitive, that contains what would normally be in the world's folder in `worlds/`. I.e. `worlds/ror2.apworld` containing `ror2/__init__.py`. +**Warning:** `.apworld` files have to be all lower case, +otherwise they raise a bogus Exception when trying to import in frozen python 3.10+! ## Metadata -Metadata about the apworld is defined in an `archipelago.json` file inside the zip archive. -The current format version has at minimum: +Metadata about the APWorld is defined in an `archipelago.json` file. + +If the APWorld is a folder, the only required field is "game": ```json { - "version": 7, - "compatible_version": 7, - "game": "Game Name" + "game": "Game Name" } ``` -The `version` and `compatible_version` fields refer to Archipelago's internal file packaging scheme -and get automatically added to the `archipelago.json` of an .apworld if it is packaged using the -["Build apworlds" launcher component](#build-apworlds-launcher-component), -which is the correct way to package your .apworld as a world developer. Do not write these fields yourself. -On the other hand, the `game` field should be present in the world folder's manifest file before packaging. - There are also the following optional fields: * `minimum_ap_version` and `maximum_ap_version` - which if present will each be compared against the current Archipelago version respectively to filter those files from being loaded. * `world_version` - an arbitrary version for that world in order to only load the newest valid world. - An apworld without a world_version is always treated as older than one with a version. + An APWorld without a world_version is always treated as older than one with a version (**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`) * `authors` - a list of authors, to eventually be displayed in various user-facing places such as WebHost and package managers. Should always be a list of strings. +If the APWorld is packaged as an `.apworld` zip file, it also needs to have `version` and `compatible_version`, +which refer to the version of the APContainer packaging scheme defined in [Files.py](../worlds/Files.py). +These get automatically added to the `archipelago.json` of an .apworld if it is packaged using the +["Build apworlds" launcher component](#build-apworlds-launcher-component), +which is the correct way to package your `.apworld` as a world developer. Do not write these fields yourself. + ### "Build apworlds" Launcher Component In the Archipelago Launcher, there is a "Build apworlds" component that will package all world folders to `.apworld`, @@ -86,7 +86,7 @@ The zip can contain arbitrary files in addition what was specified above. ## Caveats -Imports from other files inside the apworld have to use relative imports. e.g. `from .options import MyGameOptions` +Imports from other files inside the APWorld have to use relative imports. e.g. `from .options import MyGameOptions` Imports from AP base have to use absolute imports, e.g. `from Options import Toggle` or `from worlds.AutoWorld import World` diff --git a/setup.py b/setup.py index f1d052b81570..1a39857b8e3f 100644 --- a/setup.py +++ b/setup.py @@ -381,7 +381,8 @@ def run(self) -> None: file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] world_directory = self.libfolder / "worlds" / file_name if os.path.isfile(world_directory / "archipelago.json"): - manifest = json.load(open(world_directory / "archipelago.json")) + with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) assert "game" in manifest, ( f"World directory {world_directory} has an archipelago.json manifest file, but it" diff --git a/test/general/test_options.py b/test/general/test_options.py index d8ce7017f27b..f5111e91388d 100644 --- a/test/general/test_options.py +++ b/test/general/test_options.py @@ -1,7 +1,7 @@ import unittest from BaseClasses import PlandoOptions -from Options import ItemLinks, Choice +from Options import Choice, ItemLinks, PlandoConnections, PlandoItems, PlandoTexts from Utils import restricted_dumps from worlds.AutoWorld import AutoWorldRegister @@ -72,8 +72,8 @@ def test_item_links_resolve(self): for link in item_links.values(): self.assertEqual(link.value[0], item_link_group[0]) - def test_pickle_dumps(self): - """Test options can be pickled into database for WebHost generation""" + def test_pickle_dumps_default(self): + """Test that default option values can be pickled into database for WebHost generation""" for gamename, world_type in AutoWorldRegister.world_types.items(): if not world_type.hidden: for option_key, option in world_type.options_dataclass.type_hints.items(): @@ -81,3 +81,23 @@ def test_pickle_dumps(self): restricted_dumps(option.from_any(option.default)) if issubclass(option, Choice) and option.default in option.name_lookup: restricted_dumps(option.from_text(option.name_lookup[option.default])) + + def test_pickle_dumps_plando(self): + """Test that plando options using containers of a custom type can be pickled""" + # The base PlandoConnections class can't be instantiated directly, create a subclass and then cast it + class TestPlandoConnections(PlandoConnections): + entrances = {"An Entrance"} + exits = {"An Exit"} + plando_connection_value = PlandoConnections( + TestPlandoConnections.from_any([{"entrance": "An Entrance", "exit": "An Exit"}]) + ) + + plando_values = { + "PlandoConnections": plando_connection_value, + "PlandoItems": PlandoItems.from_any([{"item": "Something", "location": "Somewhere"}]), + "PlandoTexts": PlandoTexts.from_any([{"text": "Some text.", "at": "text_box"}]), + } + + for option_key, value in plando_values.items(): + with self.subTest(option=option_key): + restricted_dumps(value) diff --git a/test/general/test_world_manifest.py b/test/general/test_world_manifest.py new file mode 100644 index 000000000000..3aaa93b0ac19 --- /dev/null +++ b/test/general/test_world_manifest.py @@ -0,0 +1,102 @@ +"""Check world sources' manifest files""" + +import json +import unittest +from pathlib import Path +from typing import Any, ClassVar + +import test +from Utils import home_path, local_path +from worlds.AutoWorld import AutoWorldRegister +from ..param import classvar_matrix + + +test_path = Path(test.__file__).parent +worlds_paths = [ + Path(local_path("worlds")), + Path(local_path("custom_worlds")), + Path(home_path("worlds")), + Path(home_path("custom_worlds")), +] + +# Only check source folders for now. Zip validation should probably be in the loader and/or installer. +source_world_names = [ + k + for k, v in AutoWorldRegister.world_types.items() + if not v.zip_path and not Path(v.__file__).is_relative_to(test_path) +] + + +def get_source_world_manifest_path(game: str) -> Path | None: + """Get path of archipelago.json in the world's root folder from game name.""" + # TODO: add a feature to AutoWorld that makes this less annoying + world_type = AutoWorldRegister.world_types[game] + world_type_path = Path(world_type.__file__) + for worlds_path in worlds_paths: + if world_type_path.is_relative_to(worlds_path): + world_root = worlds_path / world_type_path.relative_to(worlds_path).parents[0] + manifest_path = world_root / "archipelago.json" + return manifest_path if manifest_path.exists() else None + assert False, f"{world_type_path} not found in any worlds path" + + +# TODO: remove the filter once manifests are mandatory. +@classvar_matrix(game=filter(get_source_world_manifest_path, source_world_names)) +class TestWorldManifest(unittest.TestCase): + game: ClassVar[str] + manifest: ClassVar[dict[str, Any]] + + @classmethod + def setUpClass(cls) -> None: + world_type = AutoWorldRegister.world_types[cls.game] + assert world_type.game == cls.game + manifest_path = get_source_world_manifest_path(cls.game) + assert manifest_path # make mypy happy + with manifest_path.open("r", encoding="utf-8") as f: + cls.manifest = json.load(f) + + def test_game(self) -> None: + """Test that 'game' will be correctly defined when generating APWorld manifest from source.""" + self.assertIn( + "game", + self.manifest, + f"archipelago.json manifest exists for {self.game} but does not contain 'game'", + ) + self.assertEqual( + self.manifest["game"], + self.game, + f"archipelago.json manifest for {self.game} specifies wrong game '{self.manifest['game']}'", + ) + + def test_world_version(self) -> None: + """Test that world_version matches the requirements in apworld specification.md""" + if "world_version" in self.manifest: + world_version: str = self.manifest["world_version"] + self.assertIsInstance( + world_version, + str, + f"world_version in archipelago.json for '{self.game}' has to be string if provided.", + ) + parts = world_version.split(".") + self.assertEqual( + len(parts), + 3, + f"world_version in archipelago.json for '{self.game}' has to be in the form of 'major.minor.build'.", + ) + for part in parts: + self.assertTrue( + part.isdigit(), + f"world_version in archipelago.json for '{self.game}' may only contain numbers.", + ) + + def test_no_container_version(self) -> None: + self.assertNotIn( + "version", + self.manifest, + f"archipelago.json for '{self.game}' must not define 'version', see apworld specification.md.", + ) + self.assertNotIn( + "compatible_version", + self.manifest, + f"archipelago.json for '{self.game}' must not define 'compatible_version', see apworld specification.md.", + ) diff --git a/test/hosting/__main__.py b/test/hosting/__main__.py index e235d7bb7218..8f2a34a7a896 100644 --- a/test/hosting/__main__.py +++ b/test/hosting/__main__.py @@ -3,6 +3,7 @@ # Run with `python test/hosting` instead, import logging import traceback +from pathlib import Path from tempfile import TemporaryDirectory from time import sleep from typing import Any @@ -11,7 +12,7 @@ from test.hosting.generate import generate_local from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room, - stop_autohost, upload_multidata) + stop_autogen, stop_autohost, upload_multidata, generate_remote) from test.hosting.world import copy as copy_world, delete as delete_world failure = False @@ -56,35 +57,62 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: if __name__ == "__main__": + import sys import warnings + warnings.simplefilter("ignore", ResourceWarning) warnings.simplefilter("ignore", UserWarning) + warnings.simplefilter("ignore", DeprecationWarning) spacer = '=' * 80 with TemporaryDirectory() as tempdir: + empty_file = str(Path(tempdir) / "empty") + open(empty_file, "w").close() + sys.argv += ["--config_override", empty_file] # tests #5541 multis = [["VVVVVV"], ["Temp World"], ["VVVVVV", "Temp World"]] - p1_games = [] - data_paths = [] - rooms = [] + p1_games: list[str] = [] + data_paths: list[Path | None] = [] + rooms: list[str] = [] + multidata: Path | None copy_world("VVVVVV", "Temp World") try: for n, games in enumerate(multis, 1): - print(f"Generating [{n}] {', '.join(games)}") + print(f"Generating [{n}] {', '.join(games)} offline") multidata = generate_local(games, tempdir) print(f"Generated [{n}] {', '.join(games)} as {multidata}\n") - p1_games.append(games[0]) data_paths.append(multidata) + p1_games.append(games[0]) finally: delete_world("Temp World") webapp = get_app(tempdir) webhost_client = webapp.test_client() + for n, multidata in enumerate(data_paths, 1): + assert multidata seed = upload_multidata(webhost_client, multidata) + print(f"Uploaded [{n}] {multidata} as {seed}\n") room = create_room(webhost_client, seed) - print(f"Uploaded [{n}] {multidata} as {room}\n") + print(f"Started [{n}] {seed} as {room}\n") + rooms.append(room) + + # Generate 1 extra game on WebHost + from WebHostLib.autolauncher import autogen + for n, games in enumerate(multis[:1], len(multis) + 1): + multis.append(games) + try: + print(f"Generating [{n}] {', '.join(games)} online") + autogen(webapp.config) + sleep(5) # until we have lazy loading of worlds, wait here for the process to start up + seed = generate_remote(webhost_client, games) + print(f"Generated [{n}] {', '.join(games)} as {seed}\n") + finally: + stop_autogen() + data_paths.append(None) # WebHost-only + room = create_room(webhost_client, seed) + print(f"Started [{n}] {seed} as {room}\n") rooms.append(room) print("Starting autohost") @@ -96,31 +124,10 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1): involved_games = {"Archipelago"} | set(multi_games) for collected_items in range(3): - print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected") - with LocalServeGame(multidata) as host: - with Client(host.address, game, "Player1") as client: - local_data_packages = client.games_packages - local_collected_items = len(client.checked_locations) - if collected_items < 2: # Don't collect anything on the last iteration - client.collect_any() - # TODO: Ctrl+C test here as well - - for game_name in sorted(involved_games): - expect_true(game_name in local_data_packages, - f"{game_name} missing from MultiServer datap ackage") - expect_true("item_name_groups" not in local_data_packages.get(game_name, {}), - f"item_name_groups are not supposed to be in MultiServer data for {game_name}") - expect_true("location_name_groups" not in local_data_packages.get(game_name, {}), - f"location_name_groups are not supposed to be in MultiServer data for {game_name}") - for game_name in local_data_packages: - expect_true(game_name in involved_games, - f"Received unexpected extra data package for {game_name} from MultiServer") - assert_equal(local_collected_items, collected_items, - "MultiServer did not load or save correctly") - print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected") prev_host_adr: str with WebHostServeGame(webhost_client, room) as host: + sleep(.1) # wait for the server to fully start before doing anything prev_host_adr = host.address with Client(host.address, game, "Player1") as client: web_data_packages = client.games_packages @@ -134,6 +141,7 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: autohost(webapp.config) # this will spin the room right up again sleep(1) # make log less annoying # if saving failed, the next iteration will fail below + sleep(2) # work around issue #5571 # verify server shut down try: @@ -156,6 +164,31 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: "customserver did not load or save correctly during/after " + ("Ctrl+C" if collected_items == 2 else "/exit")) + if not multidata: + continue # games rolled on WebHost can not be tested against MultiServer + + print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected") + with LocalServeGame(multidata) as host: + with Client(host.address, game, "Player1") as client: + local_data_packages = client.games_packages + local_collected_items = len(client.checked_locations) + if collected_items < 2: # Don't collect anything on the last iteration + client.collect_any() + # TODO: Ctrl+C test here as well + + for game_name in sorted(involved_games): + expect_true(game_name in local_data_packages, + f"{game_name} missing from MultiServer datapackage") + expect_true("item_name_groups" not in local_data_packages.get(game_name, {}), + f"item_name_groups are not supposed to be in MultiServer data for {game_name}") + expect_true("location_name_groups" not in local_data_packages.get(game_name, {}), + f"location_name_groups are not supposed to be in MultiServer data for {game_name}") + for game_name in local_data_packages: + expect_true(game_name in involved_games, + f"Received unexpected extra data package for {game_name} from MultiServer") + assert_equal(local_collected_items, collected_items, + "MultiServer did not load or save correctly") + # compare customserver to MultiServer expect_equal(local_data_packages, web_data_packages, "customserver datapackage differs from MultiServer") @@ -176,10 +209,12 @@ def expect_equal(first: Any, second: Any, msg: str = "") -> None: print(f"Restoring multidata for {room}") set_multidata_for_room(webhost_client, room, old_data) with WebHostServeGame(webhost_client, room) as host: + sleep(.1) # wait for the server to fully start before doing anything with Client(host.address, game, "Player1") as client: assert_equal(len(client.checked_locations), 2, "Save was destroyed during exception in customserver") print("Save file is not busted 🥳") + sleep(2) # work around issue #5571 finally: print("Stopping autohost") diff --git a/test/hosting/webhost.py b/test/hosting/webhost.py index 8888c3fb87fc..a8e70a50c20c 100644 --- a/test/hosting/webhost.py +++ b/test/hosting/webhost.py @@ -1,6 +1,10 @@ +import io +import json import re +import time +import zipfile from pathlib import Path -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, Iterable, Optional, cast from WebHostLib import to_python @@ -10,6 +14,7 @@ __all__ = [ "get_app", + "generate_remote", "upload_multidata", "create_room", "start_room", @@ -17,6 +22,7 @@ "set_room_timeout", "get_multidata_for_room", "set_multidata_for_room", + "stop_autogen", "stop_autohost", ] @@ -33,10 +39,43 @@ def get_app(tempdir: str) -> "Flask": "TESTING": True, "HOST_ADDRESS": "localhost", "HOSTERS": 1, + "GENERATORS": 1, + "JOB_THRESHOLD": 1, }) return get_app() +def generate_remote(app_client: "FlaskClient", games: Iterable[str]) -> str: + data = io.BytesIO() + with zipfile.ZipFile(data, "a", zipfile.ZIP_DEFLATED, False) as zip_file: + for n, game in enumerate(games, 1): + name = f"{n}.yaml" + zip_file.writestr(name, json.dumps({ + "name": f"Player{n}", + "game": game, + game: {}, + "description": f"generate_remote slot {n} ('Player{n}'): {game}", + })) + data.seek(0) + response = app_client.post("/generate", content_type="multipart/form-data", data={ + "file": (data, "yamls.zip"), + }) + assert response.status_code < 400, f"Starting gen failed: status {response.status_code}" + assert "Location" in response.headers, f"Starting gen failed: no redirect" + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/wait/"), f"Starting WebHost gen failed: unexpected redirect to {location}" + for attempt in range(10): + response = app_client.get(location) + if "Location" in response.headers: + location = response.headers["Location"] + assert isinstance(location, str) + assert location.startswith("/seed/"), f"Finishing WebHost gen failed: unexpected redirect to {location}" + return location[6:] + time.sleep(1) + raise TimeoutError("WebHost gen did not finish") + + def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str: response = app_client.post("/uploads", data={ "file": multidata.open("rb"), @@ -188,7 +227,7 @@ def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: by room.seed.multidata = data -def stop_autohost(graceful: bool = True) -> None: +def _stop_webhost_mp(name_filter: str, graceful: bool = True) -> None: import os import signal @@ -198,13 +237,30 @@ def stop_autohost(graceful: bool = True) -> None: stop() proc: multiprocessing.process.BaseProcess - for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()): + for proc in filter(lambda child: child.name.startswith(name_filter), multiprocessing.active_children()): + # FIXME: graceful currently does not work on Windows because the signals are not properly emulated + # and ungraceful may not save the game + if proc.pid == os.getpid(): + continue if graceful and proc.pid: os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT)) else: proc.kill() try: - proc.join(30) + try: + proc.join(30) + except TimeoutError: + raise + except KeyboardInterrupt: + # on Windows, the MP exception may be forwarded to the host, so ignore once and retry + proc.join(30) except TimeoutError: proc.kill() proc.join() + +def stop_autogen(graceful: bool = True) -> None: + # FIXME: this name filter is jank, but there seems to be no way to add a custom prefix for a Pool + _stop_webhost_mp("SpawnPoolWorker-", graceful) + +def stop_autohost(graceful: bool = True) -> None: + _stop_webhost_mp("MultiHoster", graceful) diff --git a/test/hosting/world.py b/test/hosting/world.py index cd53453c10c2..20f8df8cb1bb 100644 --- a/test/hosting/world.py +++ b/test/hosting/world.py @@ -11,7 +11,7 @@ def copy(src: str, dst: str) -> None: from Utils import get_file_safe_name - from worlds import AutoWorldRegister + from worlds.AutoWorld import AutoWorldRegister assert dst not in _new_worlds, "World already created" if '"' in dst or "\\" in dst: # easier to reject than to escape diff --git a/test/utils/test_daemon_thread_pool.py b/test/utils/test_daemon_thread_pool.py new file mode 100644 index 000000000000..b8702492311f --- /dev/null +++ b/test/utils/test_daemon_thread_pool.py @@ -0,0 +1,14 @@ +import unittest + +from Utils import DaemonThreadPoolExecutor + + +class DaemonThreadPoolExecutorTest(unittest.TestCase): + def test_is_daemon(self) -> None: + def run() -> None: + pass + + with DaemonThreadPoolExecutor(1) as executor: + executor.submit(run) + + self.assertTrue(next(iter(executor._threads)).daemon) diff --git a/worlds/LauncherComponents.py b/worlds/LauncherComponents.py index 1bc78be1096b..2ec70832c02d 100644 --- a/worlds/LauncherComponents.py +++ b/worlds/LauncherComponents.py @@ -271,7 +271,8 @@ def _build_apworlds(*launch_args: str): file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] world_directory = os.path.join("worlds", file_name) if os.path.isfile(os.path.join(world_directory, "archipelago.json")): - manifest = json.load(open(os.path.join(world_directory, "archipelago.json"))) + with open(os.path.join(world_directory, "archipelago.json"), mode="r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) assert "game" in manifest, ( f"World directory {world_directory} has an archipelago.json manifest file, but it" diff --git a/worlds/__init__.py b/worlds/__init__.py index b7ceb46a1e6f..72ac818198ca 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -122,7 +122,8 @@ def load(self) -> bool: for dirpath, dirnames, filenames in os.walk(world_source.resolved_path): for file in filenames: if file.endswith("archipelago.json"): - manifest = json.load(open(os.path.join(dirpath, file), "r")) + with open(os.path.join(dirpath, file), mode="r", encoding="utf-8") as manifest_file: + manifest = json.load(manifest_file) break if manifest: break diff --git a/worlds/ahit/Regions.py b/worlds/ahit/Regions.py index 857c04f1d7fc..4c6c24be511f 100644 --- a/worlds/ahit/Regions.py +++ b/worlds/ahit/Regions.py @@ -243,7 +243,7 @@ "Time Rift - Mafia of Cooks", "Time Rift - Dead Bird Studio", "Time Rift - Sleepy Subcon", - "Time Rift - Alpine Skyline" + "Time Rift - Alpine Skyline", "Time Rift - Tour", "Time Rift - Rumbi Factory", ] diff --git a/worlds/civ_6/ItemData.py b/worlds/civ_6/ItemData.py index 5f3c16a9b1ba..7ed1d713e30c 100644 --- a/worlds/civ_6/ItemData.py +++ b/worlds/civ_6/ItemData.py @@ -20,6 +20,7 @@ class CivVIBoostData: Prereq: List[str] PrereqRequiredCount: int Classification: str + EraRequired: bool = False class GoodyHutRewardData(TypedDict): diff --git a/worlds/civ_6/Locations.py b/worlds/civ_6/Locations.py index 71f29f1cfc89..7da824dfe8e4 100644 --- a/worlds/civ_6/Locations.py +++ b/worlds/civ_6/Locations.py @@ -150,7 +150,10 @@ def generate_era_location_table() -> Dict[str, Dict[str, CivVILocationData]]: location = CivVILocationData( boost.Type, 0, 0, id_base, boost.EraType, CivVICheckType.BOOST ) - era_locations["ERA_ANCIENT"][boost.Type] = location + # If EraRequired is True, place the boost in its actual era + # Otherwise, place it in ERA_ANCIENT for early access + target_era = boost.EraType if boost.EraRequired else "ERA_ANCIENT" + era_locations[target_era][boost.Type] = location id_base += 1 return era_locations diff --git a/worlds/civ_6/data/boosts.py b/worlds/civ_6/data/boosts.py index 49cedfdfd991..d0bdecc0558c 100644 --- a/worlds/civ_6/data/boosts.py +++ b/worlds/civ_6/data/boosts.py @@ -210,8 +210,8 @@ CivVIBoostData( "BOOST_TECH_SQUARE_RIGGING", "ERA_RENAISSANCE", - ["TECH_GUNPOWDER"], - 1, + ["TECH_GUNPOWDER", "TECH_MILITARY_ENGINEERING", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -252,15 +252,15 @@ CivVIBoostData( "BOOST_TECH_BALLISTICS", "ERA_INDUSTRIAL", - ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING"], - 2, + ["TECH_SIEGE_TACTICS", "TECH_MILITARY_ENGINEERING", "TECH_BRONZE_WORKING"], + 3, "DEFAULT", ), CivVIBoostData( "BOOST_TECH_MILITARY_SCIENCE", "ERA_INDUSTRIAL", - ["TECH_STIRRUPS"], - 1, + ["TECH_BRONZE_WORKING", "TECH_STIRRUPS", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -301,8 +301,8 @@ CivVIBoostData( "BOOST_TECH_REPLACEABLE_PARTS", "ERA_MODERN", - ["TECH_MILITARY_SCIENCE"], - 1, + ["TECH_MILITARY_SCIENCE", "TECH_MINING"], + 2, "DEFAULT", ), CivVIBoostData( @@ -343,8 +343,8 @@ CivVIBoostData( "BOOST_TECH_ADVANCED_FLIGHT", "ERA_ATOMIC", - ["TECH_FLIGHT"], - 1, + ["TECH_FLIGHT", "TECH_REFINING", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -436,8 +436,8 @@ CivVIBoostData( "BOOST_TECH_COMPOSITES", "ERA_INFORMATION", - ["TECH_COMBUSTION"], - 1, + ["TECH_COMBUSTION", "TECH_REFINING", "TECH_MINING"], + 3, "DEFAULT", ), CivVIBoostData( @@ -470,7 +470,7 @@ "TECH_ELECTRICITY", "TECH_NUCLEAR_FISSION", ], - 1, + 4, "DEFAULT", ), CivVIBoostData( @@ -651,10 +651,11 @@ ), CivVIBoostData( "BOOST_CIVIC_FEUDALISM", - "ERA_MEDIEVAL", + "ERA_CLASSICAL", [], 0, "DEFAULT", + True, ), CivVIBoostData( "BOOST_CIVIC_CIVIL_SERVICE", @@ -662,6 +663,7 @@ [], 0, "DEFAULT", + True, ), CivVIBoostData( "BOOST_CIVIC_MERCENARIES", @@ -790,6 +792,7 @@ [], 0, "DEFAULT", + True ), CivVIBoostData( "BOOST_CIVIC_CONSERVATION", @@ -885,6 +888,7 @@ ["TECH_ROCKETRY"], 1, "DEFAULT", + True ), CivVIBoostData( "BOOST_CIVIC_GLOBALIZATION", diff --git a/worlds/civ_6/test/TestBoostsanity.py b/worlds/civ_6/test/TestBoostsanity.py index 6efed6c66e25..54ad74cb08ad 100644 --- a/worlds/civ_6/test/TestBoostsanity.py +++ b/worlds/civ_6/test/TestBoostsanity.py @@ -105,3 +105,78 @@ def test_boosts_are_not_included(self) -> None: if "BOOST" in location.name: found_locations += 1 self.assertEqual(found_locations, 0) + + +class TestBoostsanityEraRequired(CivVITestBase): + options = { + "boostsanity": "true", + "progression_style": "none", + "shuffle_goody_hut_rewards": "false", + } + + def test_era_required_boosts_not_accessible_early(self) -> None: + # BOOST_CIVIC_FEUDALISM has EraRequired=True and ERA_CLASSICAL + # It should NOT be accessible in Ancient era + self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + + # BOOST_CIVIC_URBANIZATION has EraRequired=True and ERA_INDUSTRIAL + # It should NOT be accessible in Ancient era + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # BOOST_CIVIC_SPACE_RACE has EraRequired=True and ERA_ATOMIC + # It should NOT be accessible in Ancient era + self.assertFalse(self.can_reach_location("BOOST_CIVIC_SPACE_RACE")) + + # Regular boosts without EraRequired should be accessible + self.assertTrue(self.can_reach_location("BOOST_TECH_SAILING")) + self.assertTrue(self.can_reach_location("BOOST_CIVIC_MILITARY_TRADITION")) + + def test_era_required_boosts_accessible_in_correct_era(self) -> None: + # Collect items to reach Classical era + self.collect_by_name(["Mining", "Bronze Working", "Astrology", "Writing", + "Irrigation", "Sailing", "Animal Husbandry", + "State Workforce", "Foreign Trade"]) + + # BOOST_CIVIC_FEUDALISM should now be accessible in Classical era + self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + + # BOOST_CIVIC_URBANIZATION still not accessible (requires Industrial) + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # Collect more items to reach Industrial era + self.collect_all_but(["TECH_ROCKETRY"]) + + # Now BOOST_CIVIC_URBANIZATION should be accessible + self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + +class TestBoostsanityEraRequiredWithProgression(CivVITestBase): + options = { + "boostsanity": "true", + "progression_style": "eras_and_districts", + "shuffle_goody_hut_rewards": "false", + } + + def test_era_required_with_progressive_eras(self) -> None: + # Collect all items except Progressive Era + self.collect_all_but(["Progressive Era"]) + + # Even with all other items, era-required boosts should not be accessible + self.assertFalse(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # Collect enough Progressive Era items to reach Classical (needs 2) + self.collect(self.get_item_by_name("Progressive Era")) + self.collect(self.get_item_by_name("Progressive Era")) + + # BOOST_CIVIC_FEUDALISM should now be accessible + self.assertTrue(self.can_reach_location("BOOST_CIVIC_FEUDALISM")) + + # But BOOST_CIVIC_URBANIZATION still requires Industrial era (needs 5 total) + self.assertFalse(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) + + # Collect 3 more Progressive Era items to reach Industrial + self.collect_by_name(["Progressive Era", "Progressive Era", "Progressive Era"]) + + # Now BOOST_CIVIC_URBANIZATION should be accessible + self.assertTrue(self.can_reach_location("BOOST_CIVIC_URBANIZATION")) diff --git a/worlds/dlcquest/Items.py b/worlds/dlcquest/Items.py index 5496885a74f7..380c54290ec9 100644 --- a/worlds/dlcquest/Items.py +++ b/worlds/dlcquest/Items.py @@ -2,6 +2,7 @@ import enum import math from dataclasses import dataclass, field +from functools import reduce from random import Random from typing import Dict, List, Set @@ -61,7 +62,7 @@ def load_item_csv(): item_reader = csv.DictReader(file) for item in item_reader: id = int(item["id"]) if item["id"] else None - classification = ItemClassification[item["classification"]] + classification = reduce((lambda a, b: a | b), {ItemClassification[str_classification] for str_classification in item["classification"].split(",")}) groups = {Group[group] for group in item["groups"].split(",") if group} items.append(ItemData(id, item["name"], classification, groups)) return items diff --git a/worlds/dlcquest/data/items.csv b/worlds/dlcquest/data/items.csv index 82150254b3c1..7d9fdcf364a5 100644 --- a/worlds/dlcquest/data/items.csv +++ b/worlds/dlcquest/data/items.csv @@ -22,7 +22,7 @@ id,name,classification,groups 20,Wall Jump Pack,progression,"DLC,Freemium" 21,Health Bar Pack,useful,"DLC,Freemium" 22,Parallax Pack,filler,"DLC,Freemium" -23,Harmless Plants Pack,progression,"DLC,Freemium" +23,Harmless Plants Pack,"progression,trap","DLC,Freemium" 24,Death of Comedy Pack,progression,"DLC,Freemium" 25,Canadian Dialog Pack,filler,"DLC,Freemium" 26,DLC NPC Pack,progression,"DLC,Freemium" diff --git a/worlds/ff1/Client.py b/worlds/ff1/Client.py index a4279afd3a4a..4b21e2ac5e6c 100644 --- a/worlds/ff1/Client.py +++ b/worlds/ff1/Client.py @@ -16,6 +16,7 @@ rom_name_location = 0x07FFE3 +player_name_location = 0x07BCC0 locations_array_start = 0x200 locations_array_length = 0x100 items_obtained = 0x03 @@ -111,6 +112,12 @@ async def validate_rom(self, ctx: "BizHawkClientContext") -> bool: return True + async def set_auth(self, ctx: "BizHawkClientContext") -> None: + auth_raw = (await bizhawk.read( + ctx.bizhawk_ctx, + [(player_name_location, 0x40, self.rom)]))[0] + ctx.auth = str(auth_raw, "utf-8").replace("\x00", "").strip() + async def game_watcher(self, ctx: "BizHawkClientContext") -> None: if ctx.server is None: return @@ -204,7 +211,7 @@ async def received_items_check(self, ctx: "BizHawkClientContext") -> None: write_list.append((location, [0], self.sram)) elif current_item_name in no_overworld_items: if current_item_name == "Sigil": - location = 0x28 + location = 0x2B else: location = 0x12 write_list.append((location, [1], self.sram)) diff --git a/worlds/ff1/data/locations.json b/worlds/ff1/data/locations.json index 2f465a78970e..a1664d442fbd 100644 --- a/worlds/ff1/data/locations.json +++ b/worlds/ff1/data/locations.json @@ -253,5 +253,17 @@ "CubeBot": 529, "Sarda": 525, "Fairy": 531, - "Lefein": 527 + "Lefein": 527, + "DeepDungeon32B_Chest144": 401, + "DeepDungeon30B_Chest145": 402, + "DeepDungeon29B_Chest146": 403, + "DeepDungeon29B_Chest147": 404, + "DeepDungeon40B_Chest186": 443, + "DeepDungeon38B_Chest188": 445, + "DeepDungeon36B_Chest189": 446, + "DeepDungeon33B_Chest190": 447, + "DeepDungeon40B_Chest191": 448, + "DeepDungeon41B_Chest192": 449, + "DeepDungeon34B_Chest193": 450, + "DeepDungeon39B_Chest194": 451 } diff --git a/worlds/generic/__init__.py b/worlds/generic/__init__.py index fa53f31f7c1b..2e614eba4ddc 100644 --- a/worlds/generic/__init__.py +++ b/worlds/generic/__init__.py @@ -1,4 +1,5 @@ from typing import NamedTuple, Union +from typing_extensions import deprecated import logging from BaseClasses import Item, Tutorial, ItemClassification @@ -49,7 +50,8 @@ def create_item(self, name: str) -> Item: return Item(name, ItemClassification.filler, -1, self.player) raise InvalidItemError(name) - +@deprecated("worlds.generic.PlandoItem is deprecated and will be removed in the next version. " + "Use Options.PlandoItem(s) instead.") class PlandoItem(NamedTuple): item: str location: str diff --git a/worlds/hk/docs/setup_en.md b/worlds/hk/docs/setup_en.md index d49a4002e4b7..6728e38d7394 100644 --- a/worlds/hk/docs/setup_en.md +++ b/worlds/hk/docs/setup_en.md @@ -5,6 +5,8 @@ * A legal copy of Hollow Knight. * Steam, Gog, and Xbox Game Pass versions of the game are supported. * Windows, Mac, and Linux (including Steam Deck) are supported. + +**Do NOT** install BepInEx, it is not required and is incompatible with most mods. Archipelago, along with the majority of mods use custom tooling pre-dating BepInEx, and they are only available through Lumafly and similar installers rather than sites like Nexus Mods. ## Installing the Archipelago Mod using Lumafly 1. Launch Lumafly and ensure it locates your Hollow Knight installation directory. diff --git a/worlds/hk/docs/setup_es.md b/worlds/hk/docs/setup_es.md index 13628c401905..5a7667665bf1 100644 --- a/worlds/hk/docs/setup_es.md +++ b/worlds/hk/docs/setup_es.md @@ -5,6 +5,10 @@ * Tener una copia legal de Hollow Knight. * Las versiones de Steam, GOG y Xbox Game Pass son compatibles * Las versiones de Windows, Mac y Linux (Incluyendo Steam Deck) son compatibles + +**NO** instales BepInEx, **no** es necesario y es incompatible con varios mods. Archipelago (y la mayoría de los mods) +usan herramientas más antiguas que BepInEx, que solo están disponibles por medio de instaladores de mods como Lumafly y +similares, en vez de sitios web como Nexus Mods. ## Instalación del mod de Archipelago con Lumafly 1. Ejecuta Lumafly y asegurate de localizar la carpeta de instalación de Hollow Knight @@ -61,4 +65,4 @@ de Archipelago para generar un YAML usando una interfaz gráfica. ## Consejos y otros comandos Mientras juegas en un multiworld, puedes interactuar con el servidor usando varios comandos listados en la [guía de comandos](/tutorial/Archipelago/commands/en). Puedes usar el Cliente de Texto Archipelago para hacer esto, -que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). \ No newline at end of file +que está incluido en la última versión del [software de Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases/latest). diff --git a/worlds/hk/docs/setup_pt_br.md b/worlds/hk/docs/setup_pt_br.md index 14e54db1315c..6509d27a2c06 100644 --- a/worlds/hk/docs/setup_pt_br.md +++ b/worlds/hk/docs/setup_pt_br.md @@ -5,6 +5,10 @@ * Uma cópia legal de Hollow Knight. * Versões Steam, Gog, e Xbox Game Pass do jogo são suportadas. * Windows, Mac, e Linux (incluindo Steam Deck) são suportados. + +**NÃO** instale o BepInEx, ele **não** é necessário e é incompatível com vários mods. O Archipelago (e a maioria dos mods) +usam ferramentas mais antigas do que o BepInEx, disponíveis apenas a partir de gerenciadores como o Lumafly e semelhantes, +ao invés de sites como o Nexus Mods. ## Instalando o mod Archipelago Mod usando Lumafly 1. Abra o Lumafly e confirme que ele localizou sua pasta de instalação do Hollow Knight. diff --git a/worlds/kh2/archipelago.json b/worlds/kh2/archipelago.json new file mode 100644 index 000000000000..180801e687de --- /dev/null +++ b/worlds/kh2/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Kingdom Hearts 2", + "authors": [ "JaredWeakStrike" ], + "minimum_ap_version": "0.6.3", + "world_version": "2.0.0" +} diff --git a/worlds/ladx/Options.py b/worlds/ladx/Options.py index 2352e0fb91bb..9c532c98082a 100644 --- a/worlds/ladx/Options.py +++ b/worlds/ladx/Options.py @@ -3,7 +3,7 @@ import os.path import typing import logging -from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed +from Options import Choice, Toggle, DefaultOnToggle, Range, FreeText, PerGameCommonOptions, OptionGroup, Removed, StartInventoryPool from collections import defaultdict import Utils @@ -665,6 +665,7 @@ class LinksAwakeningOptions(PerGameCommonOptions): tarins_gift: TarinsGift overworld: Overworld stabilize_item_pool: StabilizeItemPool + start_inventory_from_pool: StartInventoryPool warp_improvements: Removed additional_warp_points: Removed diff --git a/worlds/ladx/__init__.py b/worlds/ladx/__init__.py index a00076a6f793..e5b3d6cc19ca 100644 --- a/worlds/ladx/__init__.py +++ b/worlds/ladx/__init__.py @@ -225,8 +225,6 @@ def create_event(self, event: str): def create_items(self) -> None: itempool = [] - exclude = [item.name for item in self.multiworld.precollected_items[self.player]] - self.prefill_original_dungeon = [ [], [], [], [], [], [], [], [], [] ] self.prefill_own_dungeons = [] self.pre_fill_items = [] @@ -243,50 +241,46 @@ def create_items(self) -> None: continue item_name = ladxr_item_to_la_item_name[ladx_item_name] for _ in range(count): - if item_name in exclude: - exclude.remove(item_name) # this is destructive. create unique list above - self.multiworld.itempool.append(self.create_item(self.get_filler_item_name())) - else: - item = self.create_item(item_name) - - if not self.options.tradequest and isinstance(item.item_data, TradeItemData): - location = self.multiworld.get_location(item.item_data.vanilla_location, self.player) - location.place_locked_item(item) - location.show_in_spoiler = False - continue - - if isinstance(item.item_data, DungeonItemData): - item_type = item.item_data.ladxr_id[:-1] - shuffle_type = self.dungeon_item_types[item_type] - - if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla: - # Find instrument, lock - # TODO: we should be able to pinpoint the region we want, save a lookup table please - found = False - for r in self.multiworld.get_regions(self.player): - if r.dungeon_index != item.item_data.dungeon_index: + item = self.create_item(item_name) + + if not self.options.tradequest and isinstance(item.item_data, TradeItemData): + location = self.multiworld.get_location(item.item_data.vanilla_location, self.player) + location.place_locked_item(item) + location.show_in_spoiler = False + continue + + if isinstance(item.item_data, DungeonItemData): + item_type = item.item_data.ladxr_id[:-1] + shuffle_type = self.dungeon_item_types[item_type] + + if item.item_data.dungeon_item_type == DungeonItemType.INSTRUMENT and shuffle_type == ShuffleInstruments.option_vanilla: + # Find instrument, lock + # TODO: we should be able to pinpoint the region we want, save a lookup table please + found = False + for r in self.multiworld.get_regions(self.player): + if r.dungeon_index != item.item_data.dungeon_index: + continue + for loc in r.locations: + if not isinstance(loc, LinksAwakeningLocation): continue - for loc in r.locations: - if not isinstance(loc, LinksAwakeningLocation): - continue - if not isinstance(loc.ladxr_item, Instrument): - continue - loc.place_locked_item(item) - found = True - break - if found: - break - else: - if shuffle_type == DungeonItemShuffle.option_original_dungeon: - self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item) - self.pre_fill_items.append(item) - elif shuffle_type == DungeonItemShuffle.option_own_dungeons: - self.prefill_own_dungeons.append(item) - self.pre_fill_items.append(item) - else: - itempool.append(item) + if not isinstance(loc.ladxr_item, Instrument): + continue + loc.place_locked_item(item) + found = True + break + if found: + break else: - itempool.append(item) + if shuffle_type == DungeonItemShuffle.option_original_dungeon: + self.prefill_original_dungeon[item.item_data.dungeon_index - 1].append(item) + self.pre_fill_items.append(item) + elif shuffle_type == DungeonItemShuffle.option_own_dungeons: + self.prefill_own_dungeons.append(item) + self.pre_fill_items.append(item) + else: + itempool.append(item) + else: + itempool.append(item) self.multi_key = self.generate_multi_key() diff --git a/worlds/marioland2/logic.py b/worlds/marioland2/logic.py index 5405e8287739..492ba80a12e4 100644 --- a/worlds/marioland2/logic.py +++ b/worlds/marioland2/logic.py @@ -106,26 +106,38 @@ def tree_zone_4_midway_bell(state, player): def tree_zone_4_coins(state, player, coins): auto_scroll = is_auto_scroll(state, player, "Tree Zone 4") - reachable_coins = 0 + entryway = 14 + hall = 4 + first_trip_downstairs = 31 + second_trip_downstairs = 15 + downstairs_with_auto_scroll = 12 + final_room = 10 + + reachable_coins_from_start = 0 + reachable_coins_from_bell = 0 + if has_pipe_up(state, player): - reachable_coins += 14 + reachable_coins_from_start += entryway if has_pipe_right(state, player): - reachable_coins += 4 + reachable_coins_from_start += hall if has_pipe_down(state, player): - reachable_coins += 10 - if not auto_scroll: - reachable_coins += 46 - elif state.has("Tree Zone 4 Midway Bell", player): - if not auto_scroll: - if has_pipe_left(state, player): - reachable_coins += 18 - if has_pipe_down(state, player): - reachable_coins += 10 + if auto_scroll: + reachable_coins_from_start += downstairs_with_auto_scroll + else: + reachable_coins_from_start += final_room + first_trip_downstairs + second_trip_downstairs + if state.has("Tree Zone 4 Midway Bell", player): + if has_pipe_down(state, player) and (auto_scroll or not has_pipe_left(state, player)): + reachable_coins_from_bell += final_room + elif has_pipe_left(state, player) and not auto_scroll: + if has_pipe_down(state, player): + reachable_coins_from_bell += first_trip_downstairs + if has_pipe_right(state, player): + reachable_coins_from_bell += entryway + hall if has_pipe_up(state, player): - reachable_coins += 46 - elif has_pipe_down(state, player): - reachable_coins += 10 - return coins <= reachable_coins + reachable_coins_from_bell += second_trip_downstairs + final_room + else: + reachable_coins_from_bell += entryway + hall + return coins <= max(reachable_coins_from_start, reachable_coins_from_bell) def tree_zone_5_boss(state, player): @@ -239,12 +251,9 @@ def pumpkin_zone_4_coins(state, player, coins): def mario_zone_1_normal_exit(state, player): - if has_pipe_right(state, player): - if state.has_any(["Mushroom", "Fire Flower", "Carrot", "Mario Zone 1 Midway Bell"], player): - return True - if is_auto_scroll(state, player, "Mario Zone 1"): - return True - return False + return has_pipe_right(state, player) and (not is_auto_scroll(state, player, "Mario Zone 1") + or state.has_any(["Mushroom", "Fire Flower", "Carrot", + "Mario Zone 1 Midway Bell"], player)) def mario_zone_1_midway_bell(state, player): diff --git a/worlds/mm2/archipelago.json b/worlds/mm2/archipelago.json index 75c098fdf964..9144dc292492 100644 --- a/worlds/mm2/archipelago.json +++ b/worlds/mm2/archipelago.json @@ -1,5 +1,5 @@ { "game": "Mega Man 2", - "world_version": "0.3.2", + "world_version": "0.3.3", "minimum_ap_version": "0.6.4" } diff --git a/worlds/mm2/data/mm2_basepatch.bsdiff4 b/worlds/mm2/data/mm2_basepatch.bsdiff4 index 8f3c17c3c7af..d593ef49102c 100644 Binary files a/worlds/mm2/data/mm2_basepatch.bsdiff4 and b/worlds/mm2/data/mm2_basepatch.bsdiff4 differ diff --git a/worlds/mm2/rom.py b/worlds/mm2/rom.py index e37c5bc2a148..3c31067ffa69 100644 --- a/worlds/mm2/rom.py +++ b/worlds/mm2/rom.py @@ -16,7 +16,7 @@ from . import MM2World MM2LCHASH = "37f2c36ce7592f1e16b3434b3985c497" -PROTEUSHASH = "9ff045a3ca30018b6e874c749abb3ec4" +PROTEUSHASH = "b69fff40212b80c94f19e786d1efbf61" MM2NESHASH = "0527a0ee512f69e08b8db6dc97964632" MM2VCHASH = "0c78dfe8e90fb8f3eed022ff01126ad3" @@ -327,8 +327,6 @@ def patch_rom(world: "MM2World", patch: MM2ProcedurePatch) -> None: patch.write_byte(0x36089, pool[18]) # Intro patch.write_byte(0x361F1, pool[19]) # Title - - from Utils import __version__ patch.name = bytearray(f'MM2{__version__.replace(".", "")[0:3]}_{world.player}_{world.multiworld.seed:11}\0', 'utf8')[:21] @@ -406,7 +404,7 @@ def get_base_rom_path(file_name: str = "") -> str: return file_name -PRG_OFFSET = 0x8ED70 +PRG_OFFSET = 0x8F170 PRG_SIZE = 0x40000 diff --git a/worlds/mm2/src/mm2_basepatch.asm b/worlds/mm2/src/mm2_basepatch.asm index 00c8500f03df..a43f12bb4d86 100644 --- a/worlds/mm2/src/mm2_basepatch.asm +++ b/worlds/mm2/src/mm2_basepatch.asm @@ -58,6 +58,10 @@ FlashFixTarget1: %org($808D, $0B) FlashFixTarget2: +%org($A65C, $0B) +HeatFix: + CMP #$FF + %org($8015, $0D) ClearRefreshHook: ; if we're already doing a fresh load of the stage select diff --git a/worlds/oot/archipelago.json b/worlds/oot/archipelago.json new file mode 100644 index 000000000000..d651bfeb4715 --- /dev/null +++ b/worlds/oot/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Ocarina of Time", + "authors": ["espeon65536"], + "world_version": "7.0.0", + "minimum_ap_version": "0.6.4" +} diff --git a/worlds/pokemon_rb/regions.py b/worlds/pokemon_rb/regions.py index 5aa6243514b3..182f1fc79a92 100644 --- a/worlds/pokemon_rb/regions.py +++ b/worlds/pokemon_rb/regions.py @@ -1237,7 +1237,7 @@ entrance_only = [ "Route 4-W to Mt Moon 1F", "Saffron City-G to Saffron Gym-S", "Saffron City-Copycat to Saffron Copycat's House 1F", - "Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F" + "Saffron City-Pidgey to Saffron Pidgey House", "Celadon Game Corner-Hidden Stairs to Rocket Hideout B1F", "Cinnabar Island-M to Pokemon Mansion 1F", "Mt Moon B2F to Mt Moon B1F-W", "Silph Co 7F-NW to Silph Co 11F-W", "Viridian City-G", "Cerulean City-Cave to Cerulean Cave 1F-SE", "Cerulean City-T to Cerulean Trashed House", "Route 10-P to Power Plant", "S.S. Anne 2F to S.S. Anne Captain's Room", "Pewter City-M to Pewter Museum 1F-E", diff --git a/worlds/sc2/mission_order/presets.py b/worlds/sc2/mission_order/presets.py index 2910ca2433b9..2a3977eb1b5e 100644 --- a/worlds/sc2/mission_order/presets.py +++ b/worlds/sc2/mission_order/presets.py @@ -209,7 +209,7 @@ OPTION_NAME[ProgressionBalancing]: ProgressionBalancing.default, OPTION_NAME[GameDifficulty]: GameDifficulty.option_normal, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, - OPTION_NAME[MissionOrder]: MissionOrder.option_blitz, + OPTION_NAME[MissionOrder]: MissionOrder.option_golden_path, OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, @@ -331,12 +331,13 @@ OPTION_NAME[GameDifficulty]: GameDifficulty.option_brutal, OPTION_NAME[SelectedRaces]: SelectedRaces.valid_keys, OPTION_NAME[MissionOrder]: MissionOrder.option_grid, - OPTION_NAME[RequiredTactics]: RequiredTactics.option_standard, + OPTION_NAME[RequiredTactics]: RequiredTactics.option_any_units, OPTION_NAME[EnabledCampaigns]: EnabledCampaigns.valid_keys, OPTION_NAME[EnableRaceSwapVariants]: EnableRaceSwapVariants.option_pick_one, OPTION_NAME[EnableMissionRaceBalancing]: EnableMissionRaceBalancing.option_semi_balanced, OPTION_NAME[KeyMode]: KeyMode.option_progressive_questlines, OPTION_NAME[MaximumCampaignSize]: 35, + OPTION_NAME[TwoStartPositions]: TwoStartPositions.option_true, OPTION_NAME[StarterUnit]: StarterUnit.option_off, OPTION_NAME[EnableMorphling]: EnableMorphling.option_true, OPTION_NAME[TakeOverAIAllies]: TakeOverAIAllies.option_false, diff --git a/worlds/shapez/regions.py b/worlds/shapez/regions.py index b5835461d875..e4d755a5386c 100644 --- a/worlds/shapez/regions.py +++ b/worlds/shapez/regions.py @@ -101,7 +101,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) -> def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int, - includeuseful: bool) -> bool: + includeuseful: bool, floating: bool) -> bool: # Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres if includeuseful and not (state.has(ITEMS.trash, player) and has_balancer(state, player) and @@ -109,7 +109,7 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: list return False if buildings[index] == ITEMS.cutter: - if buildings.index(ITEMS.stacker) < index: + if buildings.index(ITEMS.stacker) < index and not floating: return state.has_any((ITEMS.cutter, ITEMS.cutter_quad), player) else: return can_cut_half(state, player) @@ -195,38 +195,38 @@ def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool, # Progressively connect level and upgrade regions regions[REGIONS.main].connect( regions[REGIONS.levels_1], "Using first level building", - lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False)) + lambda state: has_logic_list_building(state, player, level_logic_buildings, 0, False, floating)) regions[REGIONS.levels_1].connect( regions[REGIONS.levels_2], "Using second level building", - lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False)) + lambda state: has_logic_list_building(state, player, level_logic_buildings, 1, False, floating)) regions[REGIONS.levels_2].connect( regions[REGIONS.levels_3], "Using third level building", lambda state: has_logic_list_building(state, player, level_logic_buildings, 2, - early_useful == OPTIONS.buildings_3)) + early_useful == OPTIONS.buildings_3, floating)) regions[REGIONS.levels_3].connect( regions[REGIONS.levels_4], "Using fourth level building", - lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False)) + lambda state: has_logic_list_building(state, player, level_logic_buildings, 3, False, floating)) regions[REGIONS.levels_4].connect( regions[REGIONS.levels_5], "Using fifth level building", lambda state: has_logic_list_building(state, player, level_logic_buildings, 4, - early_useful == OPTIONS.buildings_5)) + early_useful == OPTIONS.buildings_5, floating)) regions[REGIONS.main].connect( regions[REGIONS.upgrades_1], "Using first upgrade building", - lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False)) + lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 0, False, floating)) regions[REGIONS.upgrades_1].connect( regions[REGIONS.upgrades_2], "Using second upgrade building", - lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False)) + lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 1, False, floating)) regions[REGIONS.upgrades_2].connect( regions[REGIONS.upgrades_3], "Using third upgrade building", lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 2, - early_useful == OPTIONS.buildings_3)) + early_useful == OPTIONS.buildings_3, floating)) regions[REGIONS.upgrades_3].connect( regions[REGIONS.upgrades_4], "Using fourth upgrade building", - lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False)) + lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 3, False, floating)) regions[REGIONS.upgrades_4].connect( regions[REGIONS.upgrades_5], "Using fifth upgrade building", lambda state: has_logic_list_building(state, player, upgrade_logic_buildings, 4, - early_useful == OPTIONS.buildings_5)) + early_useful == OPTIONS.buildings_5, floating)) # Connect Uncolored shapesanity regions to Main regions[REGIONS.main].connect( diff --git a/worlds/stardew_valley/archipelago.json b/worlds/stardew_valley/archipelago.json new file mode 100644 index 000000000000..e47d48324870 --- /dev/null +++ b/worlds/stardew_valley/archipelago.json @@ -0,0 +1,6 @@ +{ + "game": "Stardew Valley", + "authors": ["KaitoKid", "Jouramie", "Witchybun (Mod Support)", "Exempt-Medic (Proofreading)"], + "minimum_ap_version": "0.6.4", + "world_version": "6.0.0" +} diff --git a/worlds/terraria/docs/setup_en.md b/worlds/terraria/docs/setup_en.md index b41595533743..69f531901957 100644 --- a/worlds/terraria/docs/setup_en.md +++ b/worlds/terraria/docs/setup_en.md @@ -50,7 +50,6 @@ on the Archipelago website to generate a YAML using a graphical interface. significantly more difficult with this mod, so it is recommended to choose a lower difficulty than you normally would play on. 4. Open the world in single player or multiplayer. -5. When you're ready, open chat, and enter `/apstart` to start the game. ## Commands diff --git a/worlds/yugioh06/boosterpacks.py b/worlds/yugioh06/boosterpacks.py index 645977d28def..7fbb56a83895 100644 --- a/worlds/yugioh06/boosterpacks.py +++ b/worlds/yugioh06/boosterpacks.py @@ -423,7 +423,7 @@ "Kaiser Glider", "Horus the Black Flame Dragon LV6", "Luster Dragon", - "Luster Dragon #2" + "Luster Dragon #2", "Spear Dragon", "Armed Dragon LV3", "Armed Dragon LV5", @@ -634,7 +634,7 @@ "Mystic Swordsman LV6", "Horus the Black Flame Dragon LV6", "Horus the Black Flame Dragon LV4", - "Armed Dragon LV3" + "Armed Dragon LV3", "Armed Dragon LV5", "Silent Swordsman Lv3", "Silent Swordsman Lv5", @@ -750,7 +750,7 @@ "Formation Union", "Princess Pikeru", "Skull Zoma", - "Metal Reflect Slime" + "Metal Reflect Slime", "Level Up!", "Howling Insect", "Tribute Doll", diff --git a/worlds/yugioh06/rules.py b/worlds/yugioh06/rules.py index 0b46e0b5d0b0..ce61fa18d2aa 100644 --- a/worlds/yugioh06/rules.py +++ b/worlds/yugioh06/rules.py @@ -668,7 +668,7 @@ def only_dragon(state, player): ], player) and (state.count_from_list_unique([ "Luster Dragon", "Spear Dragon", - "Cave Dragon" + "Cave Dragon", "Armed Dragon LV3", "Masked Dragon", "Twin-Headed Behemoth",