From 827444f5a4e065bc310c889892580eb1f97c73cb Mon Sep 17 00:00:00 2001 From: black-sliver <59490463+black-sliver@users.noreply.github.com> Date: Wed, 5 Jul 2023 22:39:35 +0200 Subject: [PATCH] Core: Add settings API ("auto settings") for host.yaml (#1871) * Add settings API ("auto settings") for host.yaml * settings: no BOM when saving * settings: fix saving / groups resetting themselves * settings: fix AutoWorldRegister import Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> * Lufia2: settings: clean up imports * settings: more consistent class naming * Docs: update world api for settings api refactor * settings: fix access from World instance * settings: update migration timeline * Docs: Apply suggestions from code review Co-authored-by: Zach Parks * Settings: correctly resolve .exe in UserPath and LocalPath --------- Co-authored-by: el-u <109771707+el-u@users.noreply.github.com> Co-authored-by: Zach Parks --- .github/workflows/unittests.yml | 1 + .gitignore | 1 + Generate.py | 42 +- Launcher.py | 23 +- Main.py | 7 +- MinecraftClient.py | 2 +- OoTClient.py | 2 - Utils.py | 208 +++------ WebHost.py | 2 + docs/settings api.md | 187 ++++++++ docs/world api.md | 24 +- host.yaml | 190 -------- inno_setup.iss | 1 + settings.py | 772 ++++++++++++++++++++++++++++++++ setup.py | 10 +- test/programs/TestGenerate.py | 24 +- worlds/AutoWorld.py | 26 ++ worlds/adventure/__init__.py | 40 +- worlds/alttp/__init__.py | 26 +- worlds/dkc3/__init__.py | 12 + worlds/factorio/__init__.py | 26 ++ worlds/ff1/__init__.py | 9 + worlds/ladx/__init__.py | 15 + worlds/lufia2ac/__init__.py | 13 + worlds/minecraft/__init__.py | 19 + worlds/mmbn3/__init__.py | 13 + worlds/oot/__init__.py | 25 ++ worlds/pokemon_rb/__init__.py | 33 +- worlds/sm/__init__.py | 18 +- worlds/smw/__init__.py | 12 + worlds/soe/__init__.py | 13 + worlds/tloz/__init__.py | 29 +- worlds/wargroove/__init__.py | 16 +- worlds/zillion/__init__.py | 26 +- 34 files changed, 1455 insertions(+), 412 deletions(-) create mode 100644 docs/settings api.md delete mode 100644 host.yaml create mode 100644 settings.py diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index 4358c803..8ff0f8bb 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -65,6 +65,7 @@ jobs: python -m pip install --upgrade pip pip install pytest pytest-subtests python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt" + python Launcher.py --update_settings # make sure host.yaml exists for tests - name: Unittests run: | pytest diff --git a/.gitignore b/.gitignore index 3e242d89..8e4cc866 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ README.html EnemizerCLI/ /Players/ /SNI/ +/host.yaml /options.yaml /config.yaml /logs/ diff --git a/Generate.py b/Generate.py index bd265879..bd1c4aa6 100644 --- a/Generate.py +++ b/Generate.py @@ -14,44 +14,42 @@ import ModuleUpdate ModuleUpdate.update() +import copy import Utils -from worlds.alttp import Options as LttPOptions -from worlds.generic import PlandoConnection -from Utils import parse_yamls, version_tuple, __version__, tuplize_version, get_options, user_path -from worlds.alttp.EntranceRandomizer import parse_arguments -from Main import main as ERmain -from BaseClasses import seeddigits, get_seed, PlandoOptions import Options +from BaseClasses import seeddigits, get_seed, PlandoOptions +from Main import main as ERmain +from settings import get_settings +from Utils import parse_yamls, version_tuple, __version__, tuplize_version, user_path +from worlds.alttp import Options as LttPOptions +from worlds.alttp.EntranceRandomizer import parse_arguments from worlds.alttp.Text import TextTable from worlds.AutoWorld import AutoWorldRegister -import copy +from worlds.generic import PlandoConnection def mystery_argparse(): - options = get_options() - defaults = options["generator"] - - def resolve_path(path: str, resolver: Callable[[str], str]) -> str: - return path if os.path.isabs(path) else resolver(path) + options = get_settings() + defaults = options.generator parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.") - parser.add_argument('--weights_file_path', default=defaults["weights_file_path"], + parser.add_argument('--weights_file_path', default=defaults.weights_file_path, help='Path to the weights file to use for rolling game settings, urls are also valid') parser.add_argument('--samesettings', help='Rolls settings per weights file rather than per player', action='store_true') - parser.add_argument('--player_files_path', default=resolve_path(defaults["player_files_path"], user_path), + parser.add_argument('--player_files_path', default=defaults.player_files_path, help="Input directory for player files.") parser.add_argument('--seed', help='Define seed number to generate.', type=int) - parser.add_argument('--multi', default=defaults["players"], type=lambda value: max(int(value), 1)) - parser.add_argument('--spoiler', type=int, default=defaults["spoiler"]) - parser.add_argument('--outputpath', default=resolve_path(options["general_options"]["output_path"], user_path), + parser.add_argument('--multi', default=defaults.players, type=lambda value: max(int(value), 1)) + parser.add_argument('--spoiler', type=int, default=defaults.spoiler) + parser.add_argument('--outputpath', default=options.general_options.output_path, help="Path to output folder. Absolute or relative to cwd.") # absolute or relative to cwd - parser.add_argument('--race', action='store_true', default=defaults["race"]) - parser.add_argument('--meta_file_path', default=defaults["meta_file_path"]) + parser.add_argument('--race', action='store_true', default=defaults.race) + parser.add_argument('--meta_file_path', default=defaults.meta_file_path) parser.add_argument('--log_level', default='info', help='Sets log level') parser.add_argument('--yaml_output', default=0, type=lambda value: max(int(value), 0), help='Output rolled mystery results to yaml up to specified number (made for async multiworld)') - parser.add_argument('--plando', default=defaults["plando_options"], + parser.add_argument('--plando', default=defaults.plando_options, help='List of options that can be set manually. Can be combined, for example "bosses, items"') parser.add_argument("--skip_prog_balancing", action="store_true", help="Skip progression balancing step during generation.") @@ -71,6 +69,8 @@ def get_seed_name(random_source) -> str: def main(args=None, callback=ERmain): if not args: args, options = mystery_argparse() + else: + options = get_settings() seed = get_seed(args.seed) Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) @@ -137,7 +137,7 @@ def main(args=None, callback=ERmain): erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.plando_options = args.plando - erargs.glitch_triforce = options["generator"]["glitch_triforce_room"] + erargs.glitch_triforce = options.generator.glitch_triforce_room erargs.spoiler = args.spoiler erargs.race = args.race erargs.outputname = seed_name diff --git a/Launcher.py b/Launcher.py index 84bdeeb7..a1548d59 100644 --- a/Launcher.py +++ b/Launcher.py @@ -22,6 +22,7 @@ from shutil import which from typing import Sequence, Union, Optional import Utils +import settings from worlds.LauncherComponents import Component, components, Type, SuffixIdentifier, icon_paths if __name__ == "__main__": @@ -33,7 +34,8 @@ from Utils import is_frozen, user_path, local_path, init_logging, open_filename, def open_host_yaml(): - file = user_path('host.yaml') + file = settings.get_settings().filename + assert file, "host.yaml missing" if is_linux: exe = which('sensible-editor') or which('gedit') or \ which('xdg-open') or which('gnome-open') or which('kde-open') @@ -84,6 +86,11 @@ def open_folder(folder_path): webbrowser.open(folder_path) +def update_settings(): + from settings import get_settings + get_settings().save() + + components.extend([ # Functions Component("Open host.yaml", func=open_host_yaml), @@ -256,11 +263,13 @@ def main(args: Optional[Union[argparse.Namespace, dict]] = None): if not component: logging.warning(f"Could not identify Component responsible for {args['Patch|Game|Component']}") + if args["update_settings"]: + update_settings() if 'file' in args: run_component(args["component"], args["file"], *args["args"]) elif 'component' in args: run_component(args["component"], *args["args"]) - else: + elif not args["update_settings"]: run_gui() @@ -269,9 +278,13 @@ if __name__ == '__main__': Utils.freeze_support() multiprocessing.set_start_method("spawn") # if launched process uses kivy, fork won't work parser = argparse.ArgumentParser(description='Archipelago Launcher') - parser.add_argument('Patch|Game|Component', type=str, nargs='?', - help="Pass either a patch file, a generated game or the name of a component to run.") - parser.add_argument('args', nargs="*", help="Arguments to pass to component.") + run_group = parser.add_argument_group("Run") + run_group.add_argument("--update_settings", action="store_true", + help="Update host.yaml and exit.") + run_group.add_argument("Patch|Game|Component", type=str, nargs="?", + help="Pass either a patch file, a generated game or the name of a component to run.") + run_group.add_argument("args", nargs="*", + help="Arguments to pass to component.") main(parser.parse_args()) from worlds.LauncherComponents import processes diff --git a/Main.py b/Main.py index 63173601..6dfd6114 100644 --- a/Main.py +++ b/Main.py @@ -13,7 +13,8 @@ import worlds from BaseClasses import CollectionState, Item, Location, LocationProgressType, MultiWorld, Region from Fill import balance_multiworld_progression, distribute_items_restrictive, distribute_planned, flood_items from Options import StartInventoryPool -from Utils import __version__, get_options, output_path, version_tuple +from settings import get_settings +from Utils import __version__, output_path, version_tuple from worlds import AutoWorld from worlds.generic.Rules import exclusion_rules, locality_rules @@ -22,7 +23,7 @@ __all__ = ["main"] def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = None): if not baked_server_options: - baked_server_options = get_options()["server_options"] + baked_server_options = get_settings().server_options if args.outputpath: os.makedirs(args.outputpath, exist_ok=True) output_path.cached_path = args.outputpath @@ -371,7 +372,7 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No "connect_names": {name: (0, player) for player, name in world.player_name.items()}, "locations": locations_data, "checks_in_area": checks_in_area, - "server_options": baked_server_options, + "server_options": baked_server_options.as_dict(), "er_hint_data": er_hint_data, "precollected_items": precollected_items, "precollected_hints": precollected_hints, diff --git a/MinecraftClient.py b/MinecraftClient.py index dd7a5cfd..93385ec5 100644 --- a/MinecraftClient.py +++ b/MinecraftClient.py @@ -299,7 +299,7 @@ if __name__ == '__main__': versions = get_minecraft_versions(data_version, channel) - forge_dir = Utils.user_path(options["minecraft_options"]["forge_directory"]) + forge_dir = options["minecraft_options"]["forge_directory"] max_heap = options["minecraft_options"]["max_heap_size"] forge_version = args.forge or versions["forge"] java_version = args.java or versions["java"] diff --git a/OoTClient.py b/OoTClient.py index fd93c093..11549041 100644 --- a/OoTClient.py +++ b/OoTClient.py @@ -296,8 +296,6 @@ async def patch_and_run_game(apz5_file): comp_path = base_name + '.z64' # Load vanilla ROM, patch file, compress ROM rom_file_name = Utils.get_options()["oot_options"]["rom_file"] - if not os.path.exists(rom_file_name): - rom_file_name = Utils.user_path(rom_file_name) rom = Rom(rom_file_name) sub_file = None diff --git a/Utils.py b/Utils.py index 1acd5651..f3e748d1 100644 --- a/Utils.py +++ b/Utils.py @@ -13,8 +13,9 @@ import io import collections import importlib import logging -from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union +from settings import Settings, get_settings +from typing import BinaryIO, Coroutine, Optional, Set, Dict, Any, Union from yaml import load, load_all, dump, SafeLoader try: @@ -138,13 +139,16 @@ def user_path(*path: str) -> str: user_path.cached_path = local_path() else: user_path.cached_path = home_path() - # populate home from local - TODO: upgrade feature - if user_path.cached_path != local_path() and not os.path.exists(user_path("host.yaml")): - import shutil - for dn in ("Players", "data/sprites"): - shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) - for fn in ("manifest.json", "host.yaml"): - shutil.copy2(local_path(fn), user_path(fn)) + # populate home from local + if user_path.cached_path != local_path(): + import filecmp + if not os.path.exists(user_path("manifest.json")) or \ + not filecmp.cmp(local_path("manifest.json"), user_path("manifest.json"), shallow=True): + import shutil + for dn in ("Players", "data/sprites"): + shutil.copytree(local_path(dn), user_path(dn), dirs_exist_ok=True) + for fn in ("manifest.json",): + shutil.copy2(local_path(fn), user_path(fn)) return os.path.join(user_path.cached_path, *path) @@ -238,155 +242,15 @@ def get_public_ipv6() -> str: return ip -OptionsType = typing.Dict[str, typing.Dict[str, typing.Any]] +OptionsType = Settings # TODO: remove ~2 versions after 0.4.1 @cache_argsless -def get_default_options() -> OptionsType: - # Refer to host.yaml for comments as to what all these options mean. - options = { - "general_options": { - "output_path": "output", - }, - "factorio_options": { - "executable": os.path.join("factorio", "bin", "x64", "factorio"), - "filter_item_sends": False, - "bridge_chat_out": True, - }, - "sni_options": { - "sni_path": "SNI", - "snes_rom_start": True, - }, - "sm_options": { - "rom_file": "Super Metroid (JU).sfc", - }, - "soe_options": { - "rom_file": "Secret of Evermore (USA).sfc", - }, - "lttp_options": { - "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", - }, - "ladx_options": { - "rom_file": "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc", - }, - "server_options": { - "host": None, - "port": 38281, - "password": None, - "multidata": None, - "savefile": None, - "disable_save": False, - "loglevel": "info", - "server_password": None, - "disable_item_cheat": False, - "location_check_points": 1, - "hint_cost": 10, - "release_mode": "goal", - "collect_mode": "disabled", - "remaining_mode": "goal", - "auto_shutdown": 0, - "compatibility": 2, - "log_network": 0 - }, - "generator": { - "enemizer_path": os.path.join("EnemizerCLI", "EnemizerCLI.Core"), - "player_files_path": "Players", - "players": 0, - "weights_file_path": "weights.yaml", - "meta_file_path": "meta.yaml", - "spoiler": 3, - "glitch_triforce_room": 1, - "race": 0, - "plando_options": "bosses", - }, - "minecraft_options": { - "forge_directory": "Minecraft Forge server", - "max_heap_size": "2G", - "release_channel": "release" - }, - "oot_options": { - "rom_file": "The Legend of Zelda - Ocarina of Time.z64", - "rom_start": True - }, - "dkc3_options": { - "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - }, - "smw_options": { - "rom_file": "Super Mario World (USA).sfc", - }, - "zillion_options": { - "rom_file": "Zillion (UE) [!].sms", - # RetroArch doesn't make it easy to launch a game from the command line. - # You have to know the path to the emulator core library on the user's computer. - "rom_start": "retroarch", - }, - "pokemon_rb_options": { - "red_rom_file": "Pokemon Red (UE) [S][!].gb", - "blue_rom_file": "Pokemon Blue (UE) [S][!].gb", - "rom_start": True - }, - "ffr_options": { - "display_msgs": True, - }, - "lufia2ac_options": { - "rom_file": "Lufia II - Rise of the Sinistrals (USA).sfc", - }, - "tloz_options": { - "rom_file": "Legend of Zelda, The (U) (PRG0) [!].nes", - "rom_start": True, - "display_msgs": True, - }, - "wargroove_options": { - "root_directory": "C:/Program Files (x86)/Steam/steamapps/common/Wargroove" - }, - "mmbn3_options": { - "rom_file": "Mega Man Battle Network 3 - Blue Version (USA).gba", - "rom_start": True - }, - "adventure_options": { - "rom_file": "ADVNTURE.BIN", - "display_msgs": True, - "rom_start": True, - "rom_args": "" - }, - } - return options +def get_default_options() -> Settings: # TODO: remove ~2 versions after 0.4.1 + return Settings(None) -def update_options(src: dict, dest: dict, filename: str, keys: list) -> OptionsType: - for key, value in src.items(): - new_keys = keys.copy() - new_keys.append(key) - option_name = '.'.join(new_keys) - if key not in dest: - dest[key] = value - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} is missing {option_name}") - elif isinstance(value, dict): - if not isinstance(dest.get(key, None), dict): - if filename.endswith("options.yaml"): - logging.info(f"Warning: {filename} has {option_name}, but it is not a dictionary. overwriting.") - dest[key] = value - else: - dest[key] = update_options(value, dest[key], filename, new_keys) - return dest - - -@cache_argsless -def get_options() -> OptionsType: - filenames = ("options.yaml", "host.yaml") - locations: typing.List[str] = [] - if os.path.join(os.getcwd()) != local_path(): - locations += filenames # use files from cwd only if it's not the local_path - locations += [user_path(filename) for filename in filenames] - - for location in locations: - if os.path.exists(location): - with open(location) as f: - options = parse_yaml(f.read()) - return update_options(get_default_options(), options, location, list()) - - raise FileNotFoundError(f"Could not find {filenames[1]} to load options.") +get_options = get_settings # TODO: add a warning ~2 versions after 0.4.1 and remove once all games are ported def persistent_store(category: str, key: typing.Any, value: typing.Any): @@ -677,7 +541,7 @@ def get_fuzzy_results(input_word: str, wordlist: typing.Sequence[str], limit: ty ) -def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]) \ +def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typing.Sequence[str]]], suggest: str = "") \ -> typing.Optional[str]: def run(*args: str): return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None @@ -688,11 +552,12 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin kdialog = which("kdialog") if kdialog: k_filters = '|'.join((f'{text} (*{" *".join(ext)})' for (text, ext) in filetypes)) - return run(kdialog, f"--title={title}", "--getopenfilename", ".", k_filters) + return run(kdialog, f"--title={title}", "--getopenfilename", suggest or ".", k_filters) zenity = which("zenity") if zenity: z_filters = (f'--file-filter={text} ({", ".join(ext)}) | *{" *".join(ext)}' for (text, ext) in filetypes) - return run(zenity, f"--title={title}", "--file-selection", *z_filters) + selection = (f'--filename="{suggest}',) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) # fall back to tk try: @@ -705,7 +570,38 @@ def open_filename(title: str, filetypes: typing.Sequence[typing.Tuple[str, typin else: root = tkinter.Tk() root.withdraw() - return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes)) + return tkinter.filedialog.askopenfilename(title=title, filetypes=((t[0], ' '.join(t[1])) for t in filetypes), + initialfile=suggest or None) + + +def open_directory(title: str, suggest: str = "") -> typing.Optional[str]: + def run(*args: str): + return subprocess.run(args, capture_output=True, text=True).stdout.split("\n", 1)[0] or None + + if is_linux: + # prefer native dialog + from shutil import which + kdialog = None#which("kdialog") + if kdialog: + return run(kdialog, f"--title={title}", "--getexistingdirectory", suggest or ".") + zenity = None#which("zenity") + if zenity: + z_filters = ("--directory",) + selection = (f'--filename="{suggest}',) if suggest else () + return run(zenity, f"--title={title}", "--file-selection", *z_filters, *selection) + + # fall back to tk + try: + import tkinter + import tkinter.filedialog + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed. ' + f'This attempt was made because open_filename was used for "{title}".') + raise e + else: + root = tkinter.Tk() + root.withdraw() + return tkinter.filedialog.askdirectory(title=title, mustexist=True, initialdir=suggest or None) def messagebox(title: str, text: str, error: bool = False) -> None: diff --git a/WebHost.py b/WebHost.py index 40d366a0..eb859ec5 100644 --- a/WebHost.py +++ b/WebHost.py @@ -10,6 +10,7 @@ ModuleUpdate.update() # in case app gets imported by something like gunicorn import Utils +import settings Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8 @@ -21,6 +22,7 @@ from WebHostLib.autolauncher import autohost, autogen from WebHostLib.lttpsprites import update_sprites_lttp from WebHostLib.options import create as create_options_files +settings.no_gui = True configpath = os.path.abspath("config.yaml") if not os.path.exists(configpath): # fall back to config.yaml in home configpath = os.path.abspath(Utils.user_path('config.yaml')) diff --git a/docs/settings api.md b/docs/settings api.md new file mode 100644 index 00000000..f9cbe5e0 --- /dev/null +++ b/docs/settings api.md @@ -0,0 +1,187 @@ +# Archipelago Settings API + +The settings API describes how to use installation-wide config and let the user configure them, like paths, etc. using +host.yaml. For the player settings / player yamls see [options api.md](options api.md). + +The settings API replaces `Utils.get_options()` and `Utils.get_default_options()` +as well as the predefined `host.yaml` in the repository. + +For backwards compatibility with APWorlds, some interfaces are kept for now and will produce a warning when being used. + + +## Config File + +Settings use options.yaml (manual override), if that exists, or host.yaml (the default) otherwise. +The files are searched for in the current working directory, if different from install directory, and in `user_path`, +which either points to the installation directory, if writable, or to %home%/Archipelago otherwise. + +**Examples:** +* C:\Program Data\Archipelago\options.yaml +* C:\Program Data\Archipelago\host.yaml +* path\to\code\repository\host.yaml +* ~/Archipelago/host.yaml + +Using the settings API, AP can update the config file or create a new one with default values and comments, +if it does not exist. + + +## Global Settings + +All non-world-specific settings are defined directly in settings.py. +Each value needs to have a default. If the default should be `None`, define it as `typing.Optional` and assign `None`. + +To access a "global" config value, with correct typing, use one of +```python +from settings import get_settings, GeneralOptions, FolderPath +from typing import cast + +x = get_settings().general_options.output_path +y = cast(GeneralOptions, get_settings()["general_options"]).output_path +z = cast(FolderPath, get_settings()["general_options"]["output_path"]) +``` + + +## World Settings + +Worlds can define the top level key to use by defining `settings_key: ClassVar[str]` in their World class. +It defaults to `{folder_name}_options` if undefined, i.e. `worlds/factorio/...` defaults to `factorio_options`. + +Worlds define the layout of their config section using type annotation of the variable `settings` in the class. +The type has to inherit from `settings.Group`. Each value in the config can have a comment by subclassing a built-in +type. Some helper types are defined in `settings.py`, see [Types](#Types) for a list.``` + +Inside the class code, you can then simply use `self.settings.rom_file` to get the value. +In case of paths they will automatically be read as absolute file paths. No need to use user_path or local_path. + +```python +import settings +from worlds.AutoWorld import World + + +class MyGameSettings(settings.Group): + class RomFile(settings.SNESRomPath): + """Description that is put into host.yaml""" + description = "My Game US v1.0 ROM File" # displayed in the file browser + copy_to = "MyGame.sfc" # instead of storing the path, copy to AP dir + md5s = ["..."] + + rom_file: RomFile = RomFile("MyGame.sfc") # definition and default value + + +class MyGameWorld(World): + ... + settings: MyGameSettings + ... + + def something(self): + pass # use self.settings.rom_file here +``` + + +## Types + +When writing the host.yaml, the code will down cast the values to builtins. +When reading the host.yaml, the code will upcast the values to what is defined in the type annotations. +E.g. an IntEnum becomes int when saving and will construct the IntEnum when loading. + +Types that can not be down cast to / up cast from a builtin can not be used except for Group, which will be converted +to/from a dict. +`bool` is a special case, see settings.py: ServerOptions.disable_item_cheat for an example. + +Below are some predefined types that can be used if they match your requirements: + + +### Group + +A section / dict in the config file. Behaves similar to a dataclass. +Type annotation and default assignment define how loading, saving and default values behave. +It can be accessed using attributes or as a dict: `group["a"]` is equivalent to `group.a`. + +In worlds, this should only be used for the top level to avoid issues when upgrading/migrating. + + +### Bool + +Since `bool` can not be subclassed, use the `settings.Bool` helper in a `typing.Union` to get a comment in host.yaml. + +```python +import settings +import typing + +class MySettings(settings.Group): + class MyBool(settings.Bool): + """Doc string""" + + my_value: typing.Union[MyBool, bool] = True +``` + +### UserFilePath + +Path to a single file. Automatically resolves as user_path: +Source folder or AP install path on Windows. ~/Archipelago for the AppImage. +Will open a file browser if the file is missing when in GUI mode. + +#### class method validate(cls, path: str) + +Override this and raise ValueError if validation fails. +Checks the file against [md5s](#md5s) by default. + +#### is_exe: bool + +Resolves to an executable (varying file extension based on platform) + +#### description: Optional\[str\] + +Human-readable name to use in file browser + +#### copy_to: Optional\[str\] + +Instead of storing the path, copy the file. + +#### md5s: List[Union[str, bytes]] + +Provide md5 hashes as hex digests or raw bytes for automatic validation. + + +### UserFolderPath + +Same as [UserFilePath](#UserFilePath), but for a folder instead of a file. + + +### LocalFilePath + +Same as [UserFilePath](#UserFilePath), but resolves as local_path: +path inside the AP dir or Appimage even if read-only. + + +### LocalFolderPath + +Same as [LocalFilePath](#LocalFilePath), but for a folder instead of a file. + + +### OptionalUserFilePath, OptionalUserFolderPath, OptionalLocalFilePath, OptionalLocalFolderPath + +Same as UserFilePath, UserFolderPath, LocalFilePath, LocalFolderPath but does not open a file browser if missing. + + +### SNESRomPath + +Specialized [UserFilePath](#UserFilePath) that ignores an optional 512 byte header when validating. + + +## Caveats + +### Circular Imports + +Because the settings are defined on import, code that runs on import can not use settings since that would result in +circular / partial imports. Instead, the code should fetch from settings on demand during generation. + +"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary, +"global" settings could be used in global scope of worlds. + + +### APWorld Backwards Compatibility + +APWorlds that want to be compatible with both stable and dev versions, have two options: +1. use the old Utils.get_options() API until Archipelago 0.4.2 is out +2. add some sort of compatibility code to your world that mimics the new API diff --git a/docs/world api.md b/docs/world api.md index cc764ba3..b866549a 100644 --- a/docs/world api.md +++ b/docs/world api.md @@ -91,10 +91,13 @@ added to the `World` object for easy access. ### World Options -Any AP installation can provide settings for a world, for example a ROM file, -accessible through `Utils.get_options()['_options']['