Compare commits

...

5 Commits

Author SHA1 Message Date
Berserker
efe48fb432 WebHost: make the all games charts bigger 2026-03-04 21:42:08 +01:00
Silvris
b372b02273 OptionCreator: 0.6.6 reported issues (#5949) 2026-03-04 19:47:30 +01:00
black-sliver
f26313367e MultiServer: graceful shutdown for ctrl+c and sigterm (#5996) 2026-03-04 00:02:12 +01:00
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
12 changed files with 89 additions and 26 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

@@ -21,6 +21,7 @@ import time
import typing
import weakref
import zlib
from signal import SIGINT, SIGTERM
import ModuleUpdate
@@ -1302,6 +1303,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)
@@ -2564,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()
@@ -2730,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:

View File

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

View File

@@ -13,3 +13,7 @@
margin-bottom: 30px;
}
.full-width {
width: 100%;
}

View File

@@ -78,7 +78,7 @@ def stats():
from worlds import network_data_package
known_games = set(network_data_package["games"])
plot = figure(title="Games Played Per Day", x_axis_type='datetime', x_axis_label="Date",
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH, height=500)
y_axis_label="Games Played", sizing_mode="scale_both", width=PLOT_WIDTH * 2, height=1000)
total_games, games_played = get_db_data(known_games)
days = sorted(games_played)
@@ -96,7 +96,7 @@ def stats():
total = sum(total_games.values())
pie = figure(title=f"Games Played in the Last 30 Days (Total: {total})", toolbar_location=None,
tools="hover", tooltips=[("Game:", "@games"), ("Played:", "@count")],
sizing_mode="scale_both", width=PLOT_WIDTH, height=500, x_range=(-0.5, 1.2))
sizing_mode="scale_both", width=PLOT_WIDTH * 2, height=1000, x_range=(-0.5, 1.2))
pie.axis.visible = False
pie.xgrid.visible = False
pie.ygrid.visible = False

View File

@@ -19,7 +19,7 @@
<div id="charts-wrapper">
{% for chart in charts %}
<div class="chart-container">
<div class="chart-container{% if loop.index0 < 2 %} full-width{% endif %}">
{{ chart|indent(16)|safe }}
</div>
{% endfor %}

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.