diff --git a/Main.py b/Main.py index 47a28813fce4..924def653b27 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/MultiServer.py b/MultiServer.py index d317e7b8fa5c..ed50c98db617 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -1302,6 +1302,13 @@ def __new__(cls, name, bases, attrs): 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) diff --git a/test/bases.py b/test/bases.py index dd93ca6452dd..19b19bea67ec 100644 --- a/test/bases.py +++ b/test/bases.py @@ -248,6 +248,7 @@ def fulfills_accessibility() -> bool: 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 ad8aad11d15c..08b4d0aa494d 100644 --- a/test/general/test_ids.py +++ b/test/general/test_ids.py @@ -88,6 +88,7 @@ def test_postgen_datapackage(self): 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 de432e369099..add6e5321e7f 100644 --- a/test/general/test_implemented.py +++ b/test/general/test_implemented.py @@ -46,6 +46,8 @@ def test_slot_data(self): 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 @@ def test_explicit_indirect_conditions_spheres(self): 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 694e0db406ca..9c300cf94ed6 100644 --- a/test/general/test_items.py +++ b/test/general/test_items.py @@ -123,6 +123,7 @@ def setup_link_multiworld(world: Type[World], link_replace: bool) -> None: 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 203af8b63a8b..d22013b4e078 100644 --- a/test/multiworld/test_multiworlds.py +++ b/test/multiworld/test_multiworlds.py @@ -61,6 +61,7 @@ def test_fills(self) -> None: 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 @@ def test_two_player_single_game_fills(self) -> None: 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 327e386c05f1..327746f1ce52 100644 --- a/worlds/AutoWorld.py +++ b/worlds/AutoWorld.py @@ -430,6 +430,23 @@ def post_fill(self) -> None: 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.