From b30b2ecb07552521d61046844a62cdf54a9b0d53 Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Wed, 25 Feb 2026 12:52:34 -0700 Subject: [PATCH 01/11] Return new state man (Vi's note: I have chosen not to change this title) (#5978) --- docs/world api.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/world api.md b/docs/world api.md index 48e863fb26..2df7b12744 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -770,6 +770,7 @@ class MyGameState(LogicMixin): new_state.mygame_defeatable_enemies = { player: enemies.copy() for player, enemies in self.mygame_defeatable_enemies.items() } + return new_state ``` After doing this, you can now access `state.mygame_defeatable_enemies[player]` from your access rules. From eeb022fa0c69fa787cf6f771221d39232d646430 Mon Sep 17 00:00:00 2001 From: Aaron Wagener Date: Wed, 25 Feb 2026 19:24:50 -0600 Subject: [PATCH 02/11] The Messenger: minor maintenance (#5965) --- worlds/messenger/archipelago.json | 4 ++++ worlds/messenger/client_setup.py | 6 +++++- worlds/messenger/rules.py | 4 ++-- 3 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 worlds/messenger/archipelago.json diff --git a/worlds/messenger/archipelago.json b/worlds/messenger/archipelago.json new file mode 100644 index 0000000000..86aefc42a8 --- /dev/null +++ b/worlds/messenger/archipelago.json @@ -0,0 +1,4 @@ +{ + "game": "The Messenger", + "authors": ["alwaysintreble"] +} \ No newline at end of file diff --git a/worlds/messenger/client_setup.py b/worlds/messenger/client_setup.py index 3ef1df75cc..02fd299a6c 100644 --- a/worlds/messenger/client_setup.py +++ b/worlds/messenger/client_setup.py @@ -28,6 +28,8 @@ def create_yes_no_popup(title: str, text: str, callback: Callable[[str], None]) def launch_game(*args) -> None: """Check the game installation, then launch it""" + prompt: ButtonsPrompt | None = None + def courier_installed() -> bool: """Check if Courier is installed""" assembly_path = os.path.join(game_folder, "TheMessenger_Data", "Managed", "Assembly-CSharp.dll") @@ -190,7 +192,7 @@ def launch_game(*args) -> None: def launch(answer: str | None = None) -> None: """Launch the game.""" - nonlocal args + nonlocal args, prompt if prompt: prompt.dismiss() @@ -256,3 +258,5 @@ def launch_game(*args) -> None: prompt = create_yes_no_popup("Launch Game", "Mod installed and up to date. Would you like to launch the game now?", launch) + else: + launch() diff --git a/worlds/messenger/rules.py b/worlds/messenger/rules.py index 2d5ee1b8a9..7f17232cfb 100644 --- a/worlds/messenger/rules.py +++ b/worlds/messenger/rules.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING -from BaseClasses import CollectionState -from worlds.generic.Rules import CollectionRule, add_rule, allow_self_locking_items +from BaseClasses import CollectionState, CollectionRule +from worlds.generic.Rules import add_rule, allow_self_locking_items from .constants import NOTES, PHOBEKINS from .options import MessengerAccessibility From 2db5435474f3d722b60f8354a6b9c98fb0705840 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 26 Feb 2026 09:34:23 +0000 Subject: [PATCH 03/11] CI: upgrade InnoSetup to 6.7.0 (#5979) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9ebe42307d..772a6c0be3 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,7 +51,7 @@ jobs: run: | 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 - choco install innosetup --version=6.2.2 --allow-downgrade + choco install innosetup --version=6.7.0 --allow-downgrade - name: Build run: | python -m pip install --upgrade pip From fcccbfca65d4c86180748f036f1e46ae31da6b93 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Thu, 26 Feb 2026 18:31:39 +0000 Subject: [PATCH 04/11] MultiServer: don't keep multidata alive for race_mode (#5980) --- MultiServer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/MultiServer.py b/MultiServer.py index 52c80c5540..d317e7b8fa 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -496,7 +496,8 @@ class Context: self.read_data = {} # there might be a better place to put this. - self.read_data["race_mode"] = lambda: decoded_obj.get("race_mode", 0) + race_mode = decoded_obj.get("race_mode", 0) + self.read_data["race_mode"] = lambda: race_mode mdata_ver = decoded_obj["minimum_versions"]["server"] if mdata_ver > version_tuple: raise RuntimeError(f"Supplied Multidata (.archipelago) requires a server of at least version {mdata_ver}, " From ff5402c410b570592cff0b8a6d1bc70e1f841f24 Mon Sep 17 00:00:00 2001 From: Chris W Date: Sat, 28 Feb 2026 23:56:28 +0100 Subject: [PATCH 05/11] Fix(undertale): prevent massive bounce msg spam for position updates (#5990) * fix(undertale): prevent massive bounce msg spam for position updates * make sure player is removed on leaving / timing out * do not check for tags: online, as bounce evaluation is or'd --- UndertaleClient.py | 100 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 82 insertions(+), 18 deletions(-) diff --git a/UndertaleClient.py b/UndertaleClient.py index 1c522fac92..9dc1136b77 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -1,6 +1,7 @@ from __future__ import annotations import os import sys +import time import asyncio import typing import bsdiff4 @@ -15,6 +16,9 @@ from CommonClient import CommonContext, server_loop, \ gui_enabled, ClientCommandProcessor, logger, get_base_parser from Utils import async_start +# Heartbeat for position sharing via bounces, in seconds +UNDERTALE_STATUS_INTERVAL = 30.0 +UNDERTALE_ONLINE_TIMEOUT = 60.0 class UndertaleCommandProcessor(ClientCommandProcessor): def __init__(self, ctx): @@ -109,6 +113,11 @@ class UndertaleContext(CommonContext): self.completed_routes = {"pacifist": 0, "genocide": 0, "neutral": 0} # self.save_game_folder: files go in this path to pass data between us and the actual game self.save_game_folder = os.path.expandvars(r"%localappdata%/UNDERTALE") + self.last_sent_position: typing.Optional[tuple] = None + self.last_room: typing.Optional[str] = None + self.last_status_write: float = 0.0 + self.other_undertale_status: dict[int, dict] = {} + def patch_game(self): with open(Utils.user_path("Undertale", "data.win"), "rb") as f: @@ -219,6 +228,9 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): await ctx.send_msgs([{"cmd": "SetNotify", "keys": [str(ctx.slot)+" RoutesDone neutral", str(ctx.slot)+" RoutesDone pacifist", str(ctx.slot)+" RoutesDone genocide"]}]) + if any(info.game == "Undertale" and slot != ctx.slot + for slot, info in ctx.slot_info.items()): + ctx.set_notify("undertale_room_status") if args["slot_data"]["only_flakes"]: with open(os.path.join(ctx.save_game_folder, "GenoNoChest.flag"), "w") as f: f.close() @@ -263,6 +275,12 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): if str(ctx.slot)+" RoutesDone pacifist" in args["keys"]: if args["keys"][str(ctx.slot) + " RoutesDone pacifist"] is not None: ctx.completed_routes["pacifist"] = args["keys"][str(ctx.slot)+" RoutesDone pacifist"] + if "undertale_room_status" in args["keys"] and args["keys"]["undertale_room_status"]: + status = args["keys"]["undertale_room_status"] + ctx.other_undertale_status = { + int(key): val for key, val in status.items() + if int(key) != ctx.slot + } elif cmd == "SetReply": if args["value"] is not None: if str(ctx.slot)+" RoutesDone pacifist" == args["key"]: @@ -271,6 +289,11 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): ctx.completed_routes["genocide"] = args["value"] elif str(ctx.slot)+" RoutesDone neutral" == args["key"]: ctx.completed_routes["neutral"] = args["value"] + if args.get("key") == "undertale_room_status" and args.get("value"): + ctx.other_undertale_status = { + int(key): val for key, val in args["value"].items() + if int(key) != ctx.slot + } elif cmd == "ReceivedItems": start_index = args["index"] @@ -368,9 +391,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): f.close() elif cmd == "Bounced": - tags = args.get("tags", []) - if "Online" in tags: - data = args.get("data", {}) + data = args.get("data", {}) + if "x" in data and "room" in data: if data["player"] != ctx.slot and data["player"] is not None: filename = f"FRISK" + str(data["player"]) + ".playerspot" with open(os.path.join(ctx.save_game_folder, filename), "w") as f: @@ -381,21 +403,63 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): async def multi_watcher(ctx: UndertaleContext): while not ctx.exit_event.is_set(): - path = ctx.save_game_folder - for root, dirs, files in os.walk(path): - for file in files: - if "spots.mine" in file and "Online" in ctx.tags: - with open(os.path.join(root, file), "r") as mine: - this_x = mine.readline() - this_y = mine.readline() - this_room = mine.readline() - this_sprite = mine.readline() - this_frame = mine.readline() - mine.close() - message = [{"cmd": "Bounce", "tags": ["Online"], - "data": {"player": ctx.slot, "x": this_x, "y": this_y, "room": this_room, - "spr": this_sprite, "frm": this_frame}}] - await ctx.send_msgs(message) + if "Online" in ctx.tags and any( + info.game == "Undertale" and slot != ctx.slot + for slot, info in ctx.slot_info.items()): + now = time.time() + path = ctx.save_game_folder + for root, dirs, files in os.walk(path): + for file in files: + if "spots.mine" in file: + with open(os.path.join(root, file), "r") as mine: + this_x = mine.readline() + this_y = mine.readline() + this_room = mine.readline() + this_sprite = mine.readline() + this_frame = mine.readline() + + if this_room != ctx.last_room or \ + now - ctx.last_status_write >= UNDERTALE_STATUS_INTERVAL: + ctx.last_room = this_room + ctx.last_status_write = now + await ctx.send_msgs([{ + "cmd": "Set", + "key": "undertale_room_status", + "default": {}, + "want_reply": False, + "operations": [{"operation": "update", + "value": {str(ctx.slot): {"room": this_room, + "time": now}}}] + }]) + + # If player was visible but timed out (heartbeat) or left the room, remove them. + for slot, entry in ctx.other_undertale_status.items(): + if entry.get("room") != this_room or \ + now - entry.get("time", now) > UNDERTALE_ONLINE_TIMEOUT: + playerspot = os.path.join(ctx.save_game_folder, + f"FRISK{slot}.playerspot") + if os.path.exists(playerspot): + os.remove(playerspot) + + current_position = (this_x, this_y, this_room, this_sprite, this_frame) + if current_position == ctx.last_sent_position: + continue + + # Empty status dict = no data yet → send to bootstrap. + online_in_room = any( + entry.get("room") == this_room and + now - entry.get("time", now) <= UNDERTALE_ONLINE_TIMEOUT + for entry in ctx.other_undertale_status.values() + ) + if ctx.other_undertale_status and not online_in_room: + continue + + message = [{"cmd": "Bounce", "games": ["Undertale"], + "data": {"player": ctx.slot, "x": this_x, "y": this_y, + "room": this_room, "spr": this_sprite, + "frm": this_frame}}] + await ctx.send_msgs(message) + ctx.last_sent_position = current_position await asyncio.sleep(0.1) From 61d5120f66b87a683c8099d4a7b9cf4a0b62a583 Mon Sep 17 00:00:00 2001 From: Doug Hoskisson Date: Sat, 28 Feb 2026 15:14:33 -0800 Subject: [PATCH 06/11] Core: use typing_extensions `deprecated` (#5989) --- Utils.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Utils.py b/Utils.py index bf46d0832d..c18298559a 100644 --- a/Utils.py +++ b/Utils.py @@ -23,6 +23,7 @@ from time import sleep from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union, TypeGuard from yaml import load, load_all, dump from pathspec import PathSpec, GitIgnoreSpec +from typing_extensions import deprecated try: from yaml import CLoader as UnsafeLoader, CSafeLoader as SafeLoader, CDumper as Dumper @@ -315,6 +316,7 @@ def get_public_ipv6() -> str: return ip +@deprecated("Utils.get_options() is deprecated. Use the settings API instead.") def get_options() -> Settings: deprecate("Utils.get_options() is deprecated. Use the settings API instead.") return get_settings() @@ -1003,6 +1005,7 @@ def async_start(co: Coroutine[None, None, typing.Any], name: Optional[str] = Non def deprecate(message: str, add_stacklevels: int = 0): + """also use typing_extensions.deprecated wherever you use this""" if __debug__: raise Exception(message) warnings.warn(message, stacklevel=2 + add_stacklevels) @@ -1067,6 +1070,7 @@ def _extend_freeze_support() -> None: multiprocessing.freeze_support = multiprocessing.spawn.freeze_support = _freeze_support if is_frozen() else _noop +@deprecated("Use multiprocessing.freeze_support() instead") def freeze_support() -> None: """This now only calls multiprocessing.freeze_support since we are patching freeze_support on module load.""" import multiprocessing From e49ba2ff6fc849952889e344927a35e8db1fd7b0 Mon Sep 17 00:00:00 2001 From: Duck <31627079+duckboycool@users.noreply.github.com> Date: Sat, 28 Feb 2026 17:30:26 -0700 Subject: [PATCH 07/11] Undertale: Use check_locations helper to avoid redundant sends (#5993) --- UndertaleClient.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/UndertaleClient.py b/UndertaleClient.py index 9dc1136b77..b0efce206a 100644 --- a/UndertaleClient.py +++ b/UndertaleClient.py @@ -300,11 +300,8 @@ async def process_undertale_cmd(ctx: UndertaleContext, cmd: str, args: dict): if start_index == 0: ctx.items_received = [] elif start_index != len(ctx.items_received): - sync_msg = [{"cmd": "Sync"}] - if ctx.locations_checked: - sync_msg.append({"cmd": "LocationChecks", - "locations": list(ctx.locations_checked)}) - await ctx.send_msgs(sync_msg) + await ctx.check_locations(ctx.locations_checked) + await ctx.send_msgs([{"cmd": "Sync"}]) if start_index == len(ctx.items_received): counter = -1 placedWeapon = 0 @@ -473,10 +470,9 @@ async def game_watcher(ctx: UndertaleContext): for file in files: if ".item" in file: os.remove(os.path.join(root, file)) - sync_msg = [{"cmd": "Sync"}] - if ctx.locations_checked: - sync_msg.append({"cmd": "LocationChecks", "locations": list(ctx.locations_checked)}) - await ctx.send_msgs(sync_msg) + await ctx.check_locations(ctx.locations_checked) + await ctx.send_msgs([{"cmd": "Sync"}]) + ctx.syncing = False if ctx.got_deathlink: ctx.got_deathlink = False @@ -511,7 +507,7 @@ async def game_watcher(ctx: UndertaleContext): for l in lines: sending = sending+[(int(l.rstrip('\n')))+12000] finally: - await ctx.send_msgs([{"cmd": "LocationChecks", "locations": sending}]) + await ctx.check_locations(sending) if "victory" in file and str(ctx.route) in file: victory = True if ".playerspot" in file and "Online" not in ctx.tags: From 922c7fe86aa580984584b8541e33b1a9a3962aca Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 1 Mar 2026 17:51:56 +0100 Subject: [PATCH 08/11] Core: allow async def functions as commands (#5859) --- MultiServer.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/MultiServer.py b/MultiServer.py index d317e7b8fa..ed50c98db6 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -1302,6 +1302,13 @@ class CommandMeta(type): commands.update(base.commands) commands.update({command_name[5:]: method for command_name, method in attrs.items() if command_name.startswith("_cmd_")}) + for command_name, method in commands.items(): + # wrap async def functions so they run on default asyncio loop + if inspect.iscoroutinefunction(method): + def _wrapper(self, *args, _method=method, **kwargs): + return async_start(_method(self, *args, **kwargs)) + functools.update_wrapper(_wrapper, method) + commands[command_name] = _wrapper return super(CommandMeta, cls).__new__(cls, name, bases, attrs) From a3e8f69909b3e77344033be88b2f87b7b54b939a Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 1 Mar 2026 17:53:41 +0100 Subject: [PATCH 09/11] Core: introduce finalize_multiworld and pre_output stages (#5700) Co-authored-by: Ishigh1 Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com> Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com> --- Main.py | 3 +++ test/bases.py | 1 + test/general/test_ids.py | 1 + test/general/test_implemented.py | 3 +++ test/general/test_items.py | 1 + test/multiworld/test_multiworlds.py | 2 ++ worlds/AutoWorld.py | 17 +++++++++++++++++ 7 files changed, 28 insertions(+) diff --git a/Main.py b/Main.py index 47a28813fc..924def653b 100644 --- a/Main.py +++ b/Main.py @@ -207,6 +207,9 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None) else: logger.info("Progression balancing skipped.") + AutoWorld.call_all(multiworld, "finalize_multiworld") + AutoWorld.call_all(multiworld, "pre_output") + # we're about to output using multithreading, so we're removing the global random state to prevent accidental use multiworld.random.passthrough = False diff --git a/test/bases.py b/test/bases.py index dd93ca6452..19b19bea67 100644 --- a/test/bases.py +++ b/test/bases.py @@ -248,6 +248,7 @@ class WorldTestBase(unittest.TestCase): with self.subTest("Game", game=self.game, seed=self.multiworld.seed): distribute_items_restrictive(self.multiworld) call_all(self.multiworld, "post_fill") + call_all(self.multiworld, "finalize_multiworld") self.assertTrue(fulfills_accessibility(), "Collected all locations, but can't beat the game.") placed_items = [loc.item for loc in self.multiworld.get_locations() if loc.item and loc.item.code] self.assertLessEqual(len(self.multiworld.itempool), len(placed_items), diff --git a/test/general/test_ids.py b/test/general/test_ids.py index ad8aad11d1..08b4d0aa49 100644 --- a/test/general/test_ids.py +++ b/test/general/test_ids.py @@ -88,6 +88,7 @@ class TestIDs(unittest.TestCase): multiworld = setup_solo_multiworld(world_type) distribute_items_restrictive(multiworld) call_all(multiworld, "post_fill") + call_all(multiworld, "finalize_multiworld") datapackage = world_type.get_data_package_data() for item_group, item_names in datapackage["item_name_groups"].items(): self.assertIsInstance(item_group, str, diff --git a/test/general/test_implemented.py b/test/general/test_implemented.py index de432e3690..add6e5321e 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -46,6 +46,8 @@ class TestImplemented(unittest.TestCase): with self.subTest(game=game_name, seed=multiworld.seed): distribute_items_restrictive(multiworld) call_all(multiworld, "post_fill") + call_all(multiworld, "finalize_multiworld") + call_all(multiworld, "pre_output") for key, data in multiworld.worlds[1].fill_slot_data().items(): self.assertIsInstance(key, str, "keys in slot data must be a string") convert_to_base_types(data) # only put base data types into slot data @@ -93,6 +95,7 @@ class TestImplemented(unittest.TestCase): with self.subTest(game=game_name, seed=multiworld.seed): distribute_items_restrictive(multiworld) call_all(multiworld, "post_fill") + call_all(multiworld, "finalize_multiworld") # Note: `multiworld.get_spheres()` iterates a set of locations, so the order that locations are checked # is nondeterministic and may vary between runs with the same seed. diff --git a/test/general/test_items.py b/test/general/test_items.py index 694e0db406..9c300cf94e 100644 --- a/test/general/test_items.py +++ b/test/general/test_items.py @@ -123,6 +123,7 @@ class TestBase(unittest.TestCase): call_all(multiworld, "pre_fill") distribute_items_restrictive(multiworld) call_all(multiworld, "post_fill") + call_all(multiworld, "finalize_multiworld") self.assertTrue(multiworld.can_beat_game(CollectionState(multiworld)), f"seed = {multiworld.seed}") for game_name, world_type in AutoWorldRegister.world_types.items(): diff --git a/test/multiworld/test_multiworlds.py b/test/multiworld/test_multiworlds.py index 203af8b63a..d22013b4e0 100644 --- a/test/multiworld/test_multiworlds.py +++ b/test/multiworld/test_multiworlds.py @@ -61,6 +61,7 @@ class TestAllGamesMultiworld(MultiworldTestBase): with self.subTest("filling multiworld", seed=self.multiworld.seed): distribute_items_restrictive(self.multiworld) call_all(self.multiworld, "post_fill") + call_all(self.multiworld, "finalize_multiworld") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") @@ -78,4 +79,5 @@ class TestTwoPlayerMulti(MultiworldTestBase): with self.subTest("filling multiworld", games=world_type.game, seed=self.multiworld.seed): distribute_items_restrictive(self.multiworld) call_all(self.multiworld, "post_fill") + call_all(self.multiworld, "finalize_multiworld") self.assertTrue(self.fulfills_accessibility(), "Collected all locations, but can't beat the game") diff --git a/worlds/AutoWorld.py b/worlds/AutoWorld.py index 327e386c05..327746f1ce 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -430,6 +430,23 @@ class World(metaclass=AutoWorldRegister): This happens before progression balancing, so the items may not be in their final locations yet. """ + def finalize_multiworld(self) -> None: + """ + Optional Method that is called after fill and progression balancing. + This is the last stage of generation where worlds may change logically relevant data, + such as item placements and connections. To not break assumptions, + only ever increase accessibility, never decrease it. + """ + pass + + def pre_output(self): + """ + Optional method that is called before output generation. + Items and connections are not meant to be moved anymore, + anything that would affect logical spheres is forbidden at this point. + """ + pass + def generate_output(self, output_directory: str) -> None: """ This method gets called from a threadpool, do not use multiworld.random here. From f26313367e55ebf7555dfc97aad40682dfc71d13 Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Tue, 3 Mar 2026 23:02:12 +0000 Subject: [PATCH 10/11] MultiServer: graceful shutdown for ctrl+c and sigterm (#5996) --- MultiServer.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/MultiServer.py b/MultiServer.py index ed50c98db6..0ba5adaf40 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -21,6 +21,7 @@ import time import typing import weakref import zlib +from signal import SIGINT, SIGTERM import ModuleUpdate @@ -2571,6 +2572,8 @@ async def console(ctx: Context): input_text = await queue.get() queue.task_done() ctx.commandprocessor(input_text) + except asyncio.exceptions.CancelledError: + ctx.logger.info("ConsoleTask cancelled") except: import traceback traceback.print_exc() @@ -2737,6 +2740,15 @@ async def main(args: argparse.Namespace): console_task = asyncio.create_task(console(ctx)) if ctx.auto_shutdown: ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, [console_task])) + + def stop(): + for remove_signal in [SIGINT, SIGTERM]: + asyncio.get_event_loop().remove_signal_handler(remove_signal) + ctx.commandprocessor._cmd_exit() + + for signal in [SIGINT, SIGTERM]: + asyncio.get_event_loop().add_signal_handler(signal, stop) + await ctx.exit_event.wait() console_task.cancel() if ctx.shutdown_task: From b372b02273436874dd7c5ce145387f96339eb5ed Mon Sep 17 00:00:00 2001 From: Silvris <58583688+Silvris@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:47:30 -0600 Subject: [PATCH 11/11] OptionCreator: 0.6.6 reported issues (#5949) --- OptionsCreator.py | 58 ++++++++++++++++++++++++++++------------------- 1 file changed, 35 insertions(+), 23 deletions(-) diff --git a/OptionsCreator.py b/OptionsCreator.py index 4e56b680b8..94ca8ba7ac 100644 --- a/OptionsCreator.py +++ b/OptionsCreator.py @@ -29,7 +29,7 @@ import webbrowser import re from urllib.parse import urlparse from worlds.AutoWorld import AutoWorldRegister, World -from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, Removed, +from Options import (Option, Toggle, TextChoice, Choice, FreeText, NamedRange, Range, OptionSet, OptionList, OptionCounter, Visibility) @@ -318,26 +318,28 @@ class OptionsCreator(ThemedApp): else: self.show_result_snack("Name cannot be longer than 16 characters.") - def create_range(self, option: typing.Type[Range], name: str): + def create_range(self, option: typing.Type[Range], name: str, bind=True): def update_text(range_box: VisualRange): self.options[name] = int(range_box.slider.value) range_box.tag.text = str(int(range_box.slider.value)) return box = VisualRange(option=option, name=name) - box.slider.bind(on_touch_move=lambda _, _1: update_text(box)) + if bind: + box.slider.bind(value=lambda _, _1: update_text(box)) self.options[name] = option.default return box def create_named_range(self, option: typing.Type[NamedRange], name: str): def set_to_custom(range_box: VisualNamedRange): - if (not self.options[name] == range_box.range.slider.value) \ - and (not self.options[name] in option.special_range_names or - range_box.range.slider.value != option.special_range_names[self.options[name]]): - # we should validate the touch here, - # but this is much cheaper + range_box.range.tag.text = str(int(range_box.range.slider.value)) + if range_box.range.slider.value in option.special_range_names.values(): + value = next(key for key, val in option.special_range_names.items() + if val == range_box.range.slider.value) + self.options[name] = value + set_button_text(box.choice, value.title()) + else: self.options[name] = int(range_box.range.slider.value) - range_box.range.tag.text = str(int(range_box.range.slider.value)) set_button_text(range_box.choice, "Custom") def set_button_text(button: MDButton, text: str): @@ -346,7 +348,7 @@ class OptionsCreator(ThemedApp): def set_value(text: str, range_box: VisualNamedRange): range_box.range.slider.value = min(max(option.special_range_names[text.lower()], option.range_start), option.range_end) - range_box.range.tag.text = str(int(range_box.range.slider.value)) + range_box.range.tag.text = str(option.special_range_names[text.lower()]) set_button_text(range_box.choice, text) self.options[name] = text.lower() range_box.range.slider.dropdown.dismiss() @@ -355,13 +357,18 @@ class OptionsCreator(ThemedApp): # for some reason this fixes an issue causing some to not open box.range.slider.dropdown.open() - box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name)) - if option.default in option.special_range_names: + box = VisualNamedRange(option=option, name=name, range_widget=self.create_range(option, name, bind=False)) + default: int | str = option.default + if default in option.special_range_names: # value can get mismatched in this case - box.range.slider.value = min(max(option.special_range_names[option.default], option.range_start), + box.range.slider.value = min(max(option.special_range_names[default], option.range_start), option.range_end) box.range.tag.text = str(int(box.range.slider.value)) - box.range.slider.bind(on_touch_move=lambda _, _2: set_to_custom(box)) + elif default in option.special_range_names.values(): + # better visual + default = next(key for key, val in option.special_range_names.items() if val == option.default) + set_button_text(box.choice, default.title()) + box.range.slider.bind(value=lambda _, _2: set_to_custom(box)) items = [ { "text": choice.title(), @@ -371,7 +378,7 @@ class OptionsCreator(ThemedApp): ] box.range.slider.dropdown = MDDropdownMenu(caller=box.choice, items=items) box.choice.bind(on_release=open_dropdown) - self.options[name] = option.default + self.options[name] = default return box def create_free_text(self, option: typing.Type[FreeText] | typing.Type[TextChoice], name: str): @@ -447,8 +454,12 @@ class OptionsCreator(ThemedApp): valid_keys = sorted(option.valid_keys) if option.verify_item_name: valid_keys += list(world.item_name_to_id.keys()) + if option.convert_name_groups: + valid_keys += list(world.item_name_groups.keys()) if option.verify_location_name: valid_keys += list(world.location_name_to_id.keys()) + if option.convert_name_groups: + valid_keys += list(world.location_name_groups.keys()) if not issubclass(option, OptionCounter): def apply_changes(button): @@ -470,14 +481,6 @@ class OptionsCreator(ThemedApp): dialog.scrollbox.layout.spacing = dp(5) dialog.scrollbox.layout.padding = [0, dp(5), 0, 0] - if name not in self.options: - # convert from non-mutable to mutable - # We use list syntax even for sets, set behavior is enforced through GUI - if issubclass(option, OptionCounter): - self.options[name] = deepcopy(option.default) - else: - self.options[name] = sorted(option.default) - if issubclass(option, OptionCounter): for value in sorted(self.options[name]): dialog.add_set_item(value, self.options[name].get(value, None)) @@ -491,6 +494,15 @@ class OptionsCreator(ThemedApp): def create_option_set_list_counter(self, option: typing.Type[OptionList] | typing.Type[OptionSet] | typing.Type[OptionCounter], name: str, world: typing.Type[World]): main_button = MDButton(MDButtonText(text="Edit"), on_release=lambda x: self.create_popup(option, name, world)) + + if name not in self.options: + # convert from non-mutable to mutable + # We use list syntax even for sets, set behavior is enforced through GUI + if issubclass(option, OptionCounter): + self.options[name] = deepcopy(option.default) + else: + self.options[name] = sorted(option.default) + return main_button def create_option(self, option: typing.Type[Option], name: str, world: typing.Type[World]) -> Widget: