Compare commits

..

2 Commits

Author SHA1 Message Date
Fabian Dill
a3e8f69909 Core: introduce finalize_multiworld and pre_output stages (#5700)
Co-authored-by: Ishigh1 <bonjour940@yahoo.fr>
Co-authored-by: Duck <31627079+duckboycool@users.noreply.github.com>
Co-authored-by: black-sliver <59490463+black-sliver@users.noreply.github.com>
2026-03-01 17:53:41 +01:00
Fabian Dill
922c7fe86a Core: allow async def functions as commands (#5859) 2026-03-01 17:51:56 +01:00
8 changed files with 35 additions and 0 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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),

View File

@@ -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,

View File

@@ -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.

View File

@@ -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():

View File

@@ -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")

View File

@@ -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.