mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-26 20:53:26 -07:00
Compare commits
7 Commits
e49ba2ff6f
...
core_manif
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df76c26fbb | ||
|
|
9a900e29e5 | ||
|
|
41eba5a2f6 | ||
|
|
b372b02273 | ||
|
|
f26313367e | ||
|
|
a3e8f69909 | ||
|
|
922c7fe86a |
3
Main.py
3
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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -32,6 +32,8 @@ If the APWorld is a folder, the only required field is "game":
|
||||
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.
|
||||
* `platforms` - a list of strings indicating the `sys.platform`(s) the world can run on.
|
||||
If empty or not set, it is assumed to be any that python itself can run on.
|
||||
* `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
|
||||
(**Must** use exactly the format `"major.minor.build"`, e.g. `1.0.0`)
|
||||
|
||||
1
setup.py
1
setup.py
@@ -409,6 +409,7 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
apworld = APWorldContainer(str(zip_path))
|
||||
apworld.minimum_ap_version = version_tuple
|
||||
apworld.maximum_ap_version = version_tuple
|
||||
apworld.platforms = [sys.platform]
|
||||
apworld.game = worldtype.game
|
||||
manifest.update(apworld.get_manifest())
|
||||
apworld.manifest_path = f"{file_name}/archipelago.json"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -353,6 +353,8 @@ class World(metaclass=AutoWorldRegister):
|
||||
"""path it was loaded from"""
|
||||
world_version: ClassVar[Version] = Version(0, 0, 0)
|
||||
"""Optional world version loaded from archipelago.json"""
|
||||
platforms: ClassVar[Optional[List[str]]] = None
|
||||
"""Optional platforms loaded from archipelago.json"""
|
||||
|
||||
def __init__(self, multiworld: "MultiWorld", player: int):
|
||||
assert multiworld is not None
|
||||
@@ -430,6 +432,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.
|
||||
|
||||
@@ -197,6 +197,7 @@ class APWorldContainer(APContainer):
|
||||
world_version: "Version | None" = None
|
||||
minimum_ap_version: "Version | None" = None
|
||||
maximum_ap_version: "Version | None" = None
|
||||
platforms: Optional[List[str]] = None
|
||||
|
||||
def read_contents(self, opened_zipfile: zipfile.ZipFile) -> Dict[str, Any]:
|
||||
from Utils import tuplize_version
|
||||
@@ -205,6 +206,7 @@ class APWorldContainer(APContainer):
|
||||
for version_key in ("world_version", "minimum_ap_version", "maximum_ap_version"):
|
||||
if version_key in manifest:
|
||||
setattr(self, version_key, tuplize_version(manifest[version_key]))
|
||||
self.platforms = manifest.get("platforms")
|
||||
return manifest
|
||||
|
||||
def get_manifest(self) -> Dict[str, Any]:
|
||||
@@ -215,6 +217,8 @@ class APWorldContainer(APContainer):
|
||||
version = getattr(self, version_key)
|
||||
if version:
|
||||
manifest[version_key] = version.as_simple_string()
|
||||
if self.platforms:
|
||||
manifest["platforms"] = self.platforms
|
||||
return manifest
|
||||
|
||||
|
||||
|
||||
@@ -289,6 +289,12 @@ if not is_frozen():
|
||||
if not worldtype:
|
||||
logging.error(f"Requested APWorld \"{worldname}\" does not exist.")
|
||||
continue
|
||||
|
||||
assert worldtype.platforms != [], (
|
||||
f"World {worldname} has an empty list for platforms. "
|
||||
"Use None or omit the attribute for 'any platform'."
|
||||
)
|
||||
|
||||
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")):
|
||||
|
||||
@@ -118,6 +118,7 @@ for world_source in world_sources:
|
||||
game = manifest.get("game")
|
||||
if game in AutoWorldRegister.world_types:
|
||||
AutoWorldRegister.world_types[game].world_version = tuplize_version(manifest.get("world_version", "0.0.0"))
|
||||
AutoWorldRegister.world_types[game].platforms = manifest.get("platforms")
|
||||
|
||||
if apworlds:
|
||||
# encapsulation for namespace / gc purposes
|
||||
@@ -165,6 +166,11 @@ if apworlds:
|
||||
f"Did not load {apworld_source.path} "
|
||||
f"as its maximum core version {apworld.maximum_ap_version} "
|
||||
f"is lower than current core version {version_tuple}.")
|
||||
elif apworld.platforms and sys.platform not in apworld.platforms:
|
||||
fail_world(apworld.game,
|
||||
f"Did not load {apworld_source.path} "
|
||||
f"as it is not compatible with current platform {sys.platform}. "
|
||||
f"Supported platforms: {', '.join(apworld.platforms)}")
|
||||
else:
|
||||
core_compatible.append((apworld_source, apworld))
|
||||
# load highest version first
|
||||
@@ -199,6 +205,8 @@ if apworlds:
|
||||
# world could fail to load at this point
|
||||
if apworld.world_version:
|
||||
AutoWorldRegister.world_types[apworld.game].world_version = apworld.world_version
|
||||
if apworld.platforms:
|
||||
AutoWorldRegister.world_types[apworld.game].platforms = apworld.platforms
|
||||
load_apworlds()
|
||||
del load_apworlds
|
||||
|
||||
|
||||
Reference in New Issue
Block a user