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 <zach@alliware.com> * 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 <zach@alliware.com>
This commit is contained in:
parent
d8a8997684
commit
827444f5a4
|
@ -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
|
||||
|
|
|
@ -37,6 +37,7 @@ README.html
|
|||
EnemizerCLI/
|
||||
/Players/
|
||||
/SNI/
|
||||
/host.yaml
|
||||
/options.yaml
|
||||
/config.yaml
|
||||
/logs/
|
||||
|
|
42
Generate.py
42
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
|
||||
|
|
23
Launcher.py
23
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
|
||||
|
|
7
Main.py
7
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,
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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
|
||||
|
|
208
Utils.py
208
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:
|
||||
|
|
|
@ -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'))
|
||||
|
|
|
@ -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
|
|
@ -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()['<world>_options']['<option>']`.
|
||||
Any AP installation can provide settings for a world, for example a ROM file, accessible through `self.settings.option`
|
||||
or `cls.settings.option` (new API) or `Utils.get_options()["<world>_options"]["<option>"]` (deprecated).
|
||||
|
||||
Users can set those in their `host.yaml` file.
|
||||
Users can set those in their `host.yaml` file. Some options may automatically open a file browser if a file is missing.
|
||||
|
||||
Refer to [settings api.md](https://github.com/ArchipelagoMW/Archipelago/blob/main/docs/settings%20api.md)
|
||||
for details.
|
||||
|
||||
### Locations
|
||||
|
||||
|
@ -349,6 +352,8 @@ class MyGameWorld(World):
|
|||
```python
|
||||
# world/mygame/__init__.py
|
||||
|
||||
import settings
|
||||
import typing
|
||||
from .Options import mygame_options # the options we defined earlier
|
||||
from .Items import mygame_items # data used below to add items to the World
|
||||
from .Locations import mygame_locations # same as above
|
||||
|
@ -356,16 +361,27 @@ from worlds.AutoWorld import World
|
|||
from BaseClasses import Region, Location, Entrance, Item, RegionType, ItemClassification
|
||||
from Utils import get_options, output_path
|
||||
|
||||
|
||||
class MyGameItem(Item): # or from Items import MyGameItem
|
||||
game = "My Game" # name of the game/world this item is from
|
||||
|
||||
|
||||
class MyGameLocation(Location): # or from Locations import MyGameLocation
|
||||
game = "My Game" # name of the game/world this location is in
|
||||
|
||||
|
||||
class MyGameSettings(settings.Group):
|
||||
class RomFile(settings.SNESRomPath):
|
||||
"""Insert help text for host.yaml here."""
|
||||
|
||||
rom_file: RomFile = RomFile("MyGame.sfc")
|
||||
|
||||
|
||||
class MyGameWorld(World):
|
||||
"""Insert description of the world/game here."""
|
||||
game = "My Game" # name of the game/world
|
||||
option_definitions = mygame_options # options the player can set
|
||||
settings: typing.ClassVar[MyGameSettings] # will be automatically assigned from type hint
|
||||
topology_present = True # show path to required location checks in spoiler
|
||||
|
||||
# ID of first item and location, could be hard-coded but code may be easier
|
||||
|
@ -668,7 +684,7 @@ def generate_output(self, output_directory: str):
|
|||
"fix_xyz_glitch": self.multiworld.fix_xyz_glitch[self.player].value
|
||||
}
|
||||
# point to a ROM specified by the installation
|
||||
src = Utils.get_options()["mygame_options"]["rom_file"]
|
||||
src = self.settings.rom_file
|
||||
# or point to worlds/mygame/data/mod_template
|
||||
src = os.path.join(os.path.dirname(__file__), "data", "mod_template")
|
||||
# generate output path
|
||||
|
|
190
host.yaml
190
host.yaml
|
@ -1,190 +0,0 @@
|
|||
general_options:
|
||||
# Where to place output files
|
||||
output_path: "output"
|
||||
# Options for MultiServer
|
||||
# Null means nothing, for the server this means to default the value
|
||||
# These overwrite command line arguments!
|
||||
server_options:
|
||||
host: null
|
||||
port: 38281
|
||||
password: null
|
||||
multidata: null
|
||||
savefile: null
|
||||
disable_save: false
|
||||
loglevel: "info"
|
||||
# Allows for clients to log on and manage the server. If this is null, no remote administration is possible.
|
||||
server_password: null
|
||||
# Disallow !getitem.
|
||||
disable_item_cheat: false
|
||||
# Client hint system
|
||||
# Points given to a player for each acquired item in their world
|
||||
location_check_points: 1
|
||||
# Relative point cost to receive a hint via !hint for players
|
||||
# so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint, for a total of 5
|
||||
hint_cost: 10 # Set to 0 if you want free hints
|
||||
# Release modes
|
||||
# A Release sends out the remaining items *from* a world that releases
|
||||
# "disabled" -> clients can't release,
|
||||
# "enabled" -> clients can always release
|
||||
# "auto" -> automatic release on goal completion
|
||||
# "auto-enabled" -> automatic release on goal completion and manual release is also enabled
|
||||
# "goal" -> release is allowed after goal completion
|
||||
release_mode: "goal"
|
||||
# Collect modes
|
||||
# A Collect sends the remaining items *to* a world that collects
|
||||
# "disabled" -> clients can't collect,
|
||||
# "enabled" -> clients can always collect
|
||||
# "auto" -> automatic collect on goal completion
|
||||
# "auto-enabled" -> automatic collect on goal completion and manual collect is also enabled
|
||||
# "goal" -> collect is allowed after goal completion
|
||||
collect_mode: "goal"
|
||||
# Remaining modes
|
||||
# !remaining handling, that tells a client which items remain in their pool
|
||||
# "enabled" -> Client can always ask for remaining items
|
||||
# "disabled" -> Client can never ask for remaining items
|
||||
# "goal" -> Client can ask for remaining items after goal completion
|
||||
remaining_mode: "goal"
|
||||
# Automatically shut down the server after this many seconds without new location checks, 0 to keep running
|
||||
auto_shutdown: 0
|
||||
# Compatibility handling
|
||||
# 2 -> Recommended for casual/cooperative play, attempt to be compatible with everything across all versions
|
||||
# 1 -> No longer in use, kept reserved in case of future use
|
||||
# 0 -> Recommended for tournaments to force a level playing field, only allow an exact version match
|
||||
compatibility: 2
|
||||
# log all server traffic, mostly for dev use
|
||||
log_network: 0
|
||||
# Options for Generation
|
||||
generator:
|
||||
# Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases
|
||||
enemizer_path: "EnemizerCLI/EnemizerCLI.Core" # + ".exe" is implied on Windows
|
||||
# Folder from which the player yaml files are pulled from
|
||||
player_files_path: "Players"
|
||||
#amount of players, 0 to infer from player files
|
||||
players: 0
|
||||
# general weights file, within the stated player_files_path location
|
||||
# gets used if players is higher than the amount of per-player files found to fill remaining slots
|
||||
weights_file_path: "weights.yaml"
|
||||
# Meta file name, within the stated player_files_path location
|
||||
meta_file_path: "meta.yaml"
|
||||
# Create a spoiler file
|
||||
# 0 -> None
|
||||
# 1 -> Spoiler without playthrough or paths to playthrough required items
|
||||
# 2 -> Spoiler with playthrough (viable solution to goals)
|
||||
# 3 -> Spoiler with playthrough and traversal paths towards items
|
||||
spoiler: 3
|
||||
# Glitch to Triforce room from Ganon
|
||||
# When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality + hammer)
|
||||
# and have completed the goal required for killing ganon to be able to access the triforce room.
|
||||
# 1 -> Enabled.
|
||||
# 0 -> Disabled (except in no-logic)
|
||||
glitch_triforce_room: 1
|
||||
# Create encrypted race roms and flag games as race mode
|
||||
race: 0
|
||||
# List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||
# Available options: bosses, items, texts, connections
|
||||
plando_options: "bosses"
|
||||
sni_options:
|
||||
# Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found
|
||||
sni_path: "SNI"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
snes_rom_start: true
|
||||
lttp_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
ladx_options:
|
||||
# File name of the Link's Awakening DX rom
|
||||
rom_file: "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
|
||||
|
||||
lufia2ac_options:
|
||||
# File name of the US rom
|
||||
rom_file: "Lufia II - Rise of the Sinistrals (USA).sfc"
|
||||
sm_options:
|
||||
# File name of the v1.0 J rom
|
||||
rom_file: "Super Metroid (JU).sfc"
|
||||
factorio_options:
|
||||
executable: "factorio/bin/x64/factorio"
|
||||
# by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used.
|
||||
# server_settings: "factorio\\data\\server-settings.json"
|
||||
# Whether to filter item send messages displayed in-game to only those that involve you.
|
||||
filter_item_sends: false
|
||||
# Whether to send chat messages from players on the Factorio server to Archipelago.
|
||||
bridge_chat_out: true
|
||||
minecraft_options:
|
||||
forge_directory: "Minecraft Forge server"
|
||||
max_heap_size: "2G"
|
||||
# release channel, currently "release", or "beta"
|
||||
# any games played on the "beta" channel have a high likelihood of no longer working on the "release" channel.
|
||||
release_channel: "release"
|
||||
oot_options:
|
||||
# File name of the OoT v1.0 ROM
|
||||
rom_file: "The Legend of Zelda - Ocarina of Time.z64"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .z64 file with
|
||||
rom_start: true
|
||||
soe_options:
|
||||
# File name of the SoE US ROM
|
||||
rom_file: "Secret of Evermore (USA).sfc"
|
||||
ffr_options:
|
||||
display_msgs: true
|
||||
tloz_options:
|
||||
# File name of the Zelda 1
|
||||
rom_file: "Legend of Zelda, The (U) (PRG0) [!].nes"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# true for operating system default program
|
||||
# Alternatively, a path to a program to open the .nes file with
|
||||
rom_start: true
|
||||
# Display message inside of EmuHawk
|
||||
display_msgs: true
|
||||
dkc3_options:
|
||||
# File name of the DKC3 US rom
|
||||
rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||
smw_options:
|
||||
# File name of the SMW US rom
|
||||
rom_file: "Super Mario World (USA).sfc"
|
||||
pokemon_rb_options:
|
||||
# File names of the Pokemon Red and Blue roms
|
||||
red_rom_file: "Pokemon Red (UE) [S][!].gb"
|
||||
blue_rom_file: "Pokemon Blue (UE) [S][!].gb"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .gb file with
|
||||
rom_start: true
|
||||
|
||||
wargroove_options:
|
||||
# Locate the Wargroove root directory on your system.
|
||||
# This is used by the Wargroove client, so it knows where to send communication files to
|
||||
root_directory: "C:/Program Files (x86)/Steam/steamapps/common/Wargroove"
|
||||
|
||||
zillion_options:
|
||||
# File name of the Zillion US rom
|
||||
rom_file: "Zillion (UE) [!].sms"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program
|
||||
# Alternatively, a path to a program to open the .sfc file with
|
||||
# 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"
|
||||
mmbn3_options:
|
||||
# File name of the MMBN3 Blue US rom
|
||||
rom_file: "Mega Man Battle Network 3 - Blue Version (USA).gba"
|
||||
rom_start: true
|
||||
adventure_options:
|
||||
# File name of the standard NTSC Adventure rom.
|
||||
# The licensed "The 80 Classic Games" CD-ROM contains this.
|
||||
# It may also have a .a26 extension
|
||||
rom_file: "ADVNTURE.BIN"
|
||||
# Set this to false to never autostart a rom (such as after patching)
|
||||
# True for operating system default program for '.a26'
|
||||
# Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
|
||||
rom_start: true
|
||||
# Optional, additional args passed into rom_start before the .bin file
|
||||
# For example, this can be used to autoload the connector script in EmuHawk
|
||||
# (see EmuHawk --lua= option)
|
||||
# Windows example:
|
||||
# rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"
|
||||
rom_args: " "
|
||||
# Set this to true to display item received messages in Emuhawk
|
||||
display_msgs: true
|
|
@ -182,6 +182,7 @@ Name: "{commondesktop}\{#MyAppName} Undertale Client"; Filename: "{app}\Archipel
|
|||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
|
||||
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: client/sni/lttp or generator/lttp
|
||||
Filename: "{app}\ArchipelagoMinecraftClient.exe"; Parameters: "--install"; StatusMsg: "Installing Forge Server..."; Components: client/minecraft
|
||||
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
|
||||
|
||||
[UninstallDelete]
|
||||
Type: dirifempty; Name: "{app}"
|
||||
|
|
|
@ -0,0 +1,772 @@
|
|||
"""
|
||||
Application settings / host.yaml interface using type hints.
|
||||
This is different from player settings.
|
||||
"""
|
||||
|
||||
import os.path
|
||||
import shutil
|
||||
import sys
|
||||
import typing
|
||||
import warnings
|
||||
from enum import IntEnum
|
||||
from threading import Lock
|
||||
from typing import cast, Any, BinaryIO, ClassVar, Dict, Iterator, List, Optional, TextIO, Tuple, Union, TypeVar
|
||||
import os
|
||||
|
||||
__all__ = [
|
||||
"get_settings", "fmt_doc", "no_gui",
|
||||
"Group", "Bool", "Path", "UserFilePath", "UserFolderPath", "LocalFilePath", "LocalFolderPath",
|
||||
"OptionalUserFilePath", "OptionalUserFolderPath", "OptionalLocalFilePath", "OptionalLocalFolderPath",
|
||||
"GeneralOptions", "ServerOptions", "GeneratorOptions", "SNIOptions", "Settings"
|
||||
]
|
||||
|
||||
no_gui = False
|
||||
_world_settings_name_cache: Dict[str, str] = {} # TODO: cache on disk and update when worlds change
|
||||
_world_settings_name_cache_updated = False
|
||||
_lock = Lock()
|
||||
|
||||
|
||||
def _update_cache() -> None:
|
||||
"""Load all worlds and update world_settings_name_cache"""
|
||||
global _world_settings_name_cache_updated
|
||||
if _world_settings_name_cache_updated:
|
||||
return
|
||||
|
||||
try:
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
for world in AutoWorldRegister.world_types.values():
|
||||
annotation = world.__annotations__.get("settings", None)
|
||||
if annotation is None or annotation == "ClassVar[Optional['Group']]":
|
||||
continue
|
||||
_world_settings_name_cache[world.settings_key] = f"{world.__module__}.{world.__name__}"
|
||||
finally:
|
||||
_world_settings_name_cache_updated = True
|
||||
|
||||
|
||||
def fmt_doc(cls: type, level: int) -> str:
|
||||
comment = cls.__doc__
|
||||
assert comment, f"{cls} has no __doc__"
|
||||
indent = level * 2 * " "
|
||||
return "\n".join(map(lambda s: f"{indent}# {s}", filter(None, map(lambda s: s.strip(), comment.split("\n")))))
|
||||
|
||||
|
||||
class Group:
|
||||
_type_cache: ClassVar[Optional[Dict[str, Any]]] = None
|
||||
_dumping: bool = False
|
||||
_has_attr: bool = False
|
||||
_changed: bool = False
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
try:
|
||||
return getattr(self, key)
|
||||
except NameError:
|
||||
raise KeyError(key)
|
||||
|
||||
def __iter__(self) -> Iterator[str]:
|
||||
cls_members = dir(self.__class__)
|
||||
members = filter(lambda k: not k.startswith("_") and (k not in cls_members or k in self.__annotations__),
|
||||
list(self.__annotations__) +
|
||||
[name for name in dir(self) if name not in self.__annotations__])
|
||||
return members.__iter__()
|
||||
|
||||
def __contains__(self, key: str) -> bool:
|
||||
try:
|
||||
self._has_attr = True
|
||||
return hasattr(self, key)
|
||||
finally:
|
||||
self._has_attr = False
|
||||
|
||||
def __setitem__(self, key: str, value: Any) -> None:
|
||||
setattr(self, key, value)
|
||||
|
||||
def __getattribute__(self, item: str) -> Any:
|
||||
attr = super().__getattribute__(item)
|
||||
if isinstance(attr, Path) and not super().__getattribute__("_dumping"):
|
||||
if attr.required and not attr.exists() and not super().__getattribute__("_has_attr"):
|
||||
# if a file is required, and the one from settings does not exist, ask the user to provide it
|
||||
# unless we are dumping the settings, because that would ask for each entry
|
||||
with _lock: # lock to avoid opening multiple
|
||||
new = None if no_gui else attr.browse()
|
||||
if new is None:
|
||||
raise FileNotFoundError(f"{attr} does not exist, but "
|
||||
f"{self.__class__.__name__}.{item} is required")
|
||||
setattr(self, item, new)
|
||||
self._changed = True
|
||||
attr = new
|
||||
# resolve the path immediately when accessing it
|
||||
return attr.__class__(attr.resolve())
|
||||
return attr
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return self._changed or any(map(lambda v: isinstance(v, Group) and v.changed,
|
||||
self.__dict__.values()))
|
||||
|
||||
@classmethod
|
||||
def get_type_hints(cls) -> Dict[str, Any]:
|
||||
"""Returns resolved type hints for the class"""
|
||||
if cls._type_cache is None:
|
||||
if not isinstance(next(iter(cls.__annotations__.values())), str):
|
||||
# non-str: assume already resolved
|
||||
cls._type_cache = cls.__annotations__
|
||||
else:
|
||||
# str: build dicts and resolve with eval
|
||||
mod = sys.modules[cls.__module__] # assume the module wasn't deleted
|
||||
mod_dict = {k: getattr(mod, k) for k in dir(mod)}
|
||||
cls._type_cache = typing.get_type_hints(cls, globalns=mod_dict, localns=cls.__dict__)
|
||||
return cls._type_cache
|
||||
|
||||
def get(self, key: str, default: Any) -> Any:
|
||||
if key in self:
|
||||
return self[key]
|
||||
return default
|
||||
|
||||
def items(self) -> List[Tuple[str, Any]]:
|
||||
return [(key, getattr(self, key)) for key in self]
|
||||
|
||||
def update(self, dct: Dict[str, Any]) -> None:
|
||||
assert isinstance(dct, dict), f"{self.__class__.__name__}.update called with " \
|
||||
f"{dct.__class__.__name__} instead of dict."
|
||||
|
||||
for k in self.__annotations__:
|
||||
if not k.startswith("_") and k not in dct:
|
||||
self._changed = True # key missing from host.yaml
|
||||
|
||||
for k, v in dct.items():
|
||||
# don't do getattr to stay lazy with world group init/loading
|
||||
# instead we assign unknown groups as dicts and a later getattr will upcast them
|
||||
attr = self.__dict__[k] if k in self.__dict__ else \
|
||||
self.__class__.__dict__[k] if k in self.__class__.__dict__ else None
|
||||
if isinstance(attr, Group):
|
||||
# update group
|
||||
if k not in self.__dict__:
|
||||
attr = attr.__class__() # make a copy of default
|
||||
setattr(self, k, attr)
|
||||
attr.update(v)
|
||||
elif isinstance(attr, dict):
|
||||
# update dict
|
||||
if k not in self.__dict__:
|
||||
attr = attr.copy() # make a copy of default
|
||||
setattr(self, k, attr)
|
||||
attr.update(v)
|
||||
else:
|
||||
# assign value, try to upcast to type hint
|
||||
annotation = self.get_type_hints().get(k, None)
|
||||
candidates = [] if annotation is None else \
|
||||
typing.get_args(annotation) if typing.get_origin(annotation) is Union else [annotation]
|
||||
none_type = type(None)
|
||||
for cls in candidates:
|
||||
assert isinstance(cls, type), f"{self.__class__.__name__}.{k}: type {cls} not supported in settings"
|
||||
if v is None and cls is none_type:
|
||||
# assign None, i.e. from Optional
|
||||
setattr(self, k, v)
|
||||
break
|
||||
if cls is bool and isinstance(v, bool):
|
||||
# assign bool - special handling because issubclass(int, bool) is True
|
||||
setattr(self, k, v)
|
||||
break
|
||||
if cls is not bool and issubclass(cls, type(v)):
|
||||
# upcast, i.e. int -> IntEnum, str -> Path
|
||||
setattr(self, k, cls.__call__(v))
|
||||
break
|
||||
else:
|
||||
# assign scalar and hope for the best
|
||||
setattr(self, k, v)
|
||||
if annotation:
|
||||
warnings.warn(f"{self.__class__.__name__}.{k} "
|
||||
f"assigned from incompatible type {type(v).__name__}")
|
||||
|
||||
def as_dict(self, *args: str, downcast: bool = True) -> Dict[str, Any]:
|
||||
return {
|
||||
name: _to_builtin(cast(object, getattr(self, name))) if downcast else getattr(self, name)
|
||||
for name in self if not args or name in args
|
||||
}
|
||||
|
||||
def dump(self, f: TextIO, level: int = 0) -> None:
|
||||
from Utils import dump, Dumper as BaseDumper
|
||||
from yaml import ScalarNode, MappingNode
|
||||
|
||||
class Dumper(BaseDumper):
|
||||
def represent_mapping(self, tag: str, mapping: Any, flow_style: Any = None) -> MappingNode:
|
||||
res: MappingNode = super().represent_mapping(tag, mapping, flow_style)
|
||||
pairs = cast(List[Tuple[ScalarNode, Any]], res.value)
|
||||
for k, v in pairs:
|
||||
k.style = None # remove quotes from keys
|
||||
return res
|
||||
|
||||
def represent_str(self, data: str) -> ScalarNode:
|
||||
# default double quote all strings
|
||||
return self.represent_scalar("tag:yaml.org,2002:str", data, style='"')
|
||||
|
||||
Dumper.add_representer(str, Dumper.represent_str)
|
||||
|
||||
self._dumping = True
|
||||
try:
|
||||
# fetch class to avoid going through getattr
|
||||
cls = self.__class__
|
||||
type_hints = cls.get_type_hints()
|
||||
# validate group
|
||||
for name in cls.__annotations__.keys():
|
||||
assert hasattr(cls, name), f"{cls}.{name} is missing a default value"
|
||||
# dump ordered members
|
||||
for name in self:
|
||||
attr = cast(object, getattr(self, name))
|
||||
attr_cls = type_hints[name] if name in type_hints else attr.__class__
|
||||
attr_cls_origin = typing.get_origin(attr_cls)
|
||||
while attr_cls_origin is Union: # resolve to first type for doc string
|
||||
attr_cls = typing.get_args(attr_cls)[0]
|
||||
attr_cls_origin = typing.get_origin(attr_cls)
|
||||
if attr_cls.__doc__ and attr_cls.__module__ != "builtins":
|
||||
f.write(fmt_doc(attr_cls, level=level) + "\n")
|
||||
indent = ' ' * level
|
||||
if isinstance(attr, Group):
|
||||
f.write(f"{indent}{name}:\n")
|
||||
attr.dump(f, level=level+1)
|
||||
elif isinstance(attr, (dict, list, tuple, set)):
|
||||
# TODO: special handling for dicts and iterables
|
||||
raise NotImplementedError()
|
||||
else:
|
||||
yaml_line = dump({name: _to_builtin(attr)}, Dumper=Dumper)
|
||||
f.write(f"{indent}{yaml_line}")
|
||||
self._changed = False
|
||||
finally:
|
||||
self._dumping = False
|
||||
|
||||
|
||||
class Bool:
|
||||
# can't subclass bool, so we use this and Union or type: ignore
|
||||
def __bool__(self) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
# Types for generic settings
|
||||
T = TypeVar("T", bound="Path")
|
||||
|
||||
|
||||
def _resolve_exe(s: str) -> str:
|
||||
"""Append exe file extension if the file is an executable"""
|
||||
if isinstance(s, Path):
|
||||
from Utils import is_windows
|
||||
if s.is_exe and is_windows and not s.lower().endswith(".exe"):
|
||||
return str(s + ".exe")
|
||||
return str(s)
|
||||
|
||||
|
||||
def _to_builtin(o: object) -> Any:
|
||||
"""Downcast object to a builtin type for output"""
|
||||
if o is None:
|
||||
return None
|
||||
c = o.__class__
|
||||
while c.__module__ != "builtins":
|
||||
c = c.__base__
|
||||
return c.__call__(o)
|
||||
|
||||
|
||||
class Path(str):
|
||||
# paths in host.yaml are str
|
||||
required: bool = True
|
||||
"""Marks the file as required and opens a file browser when missing"""
|
||||
is_exe: bool = False
|
||||
"""Special cross-platform handling for executables"""
|
||||
description: Optional[str] = None
|
||||
"""Title to display when browsing for the file"""
|
||||
copy_to: Optional[str] = None
|
||||
"""If not None, copy to AP folder instead of linking it"""
|
||||
|
||||
@classmethod
|
||||
def validate(cls, path: str) -> None:
|
||||
"""Overload and raise to validate input files from browse"""
|
||||
pass
|
||||
|
||||
def browse(self: T, **kwargs: Any) -> Optional[T]:
|
||||
"""Opens a file browser to search for the file"""
|
||||
raise NotImplementedError(f"Please use a subclass of Path for {self.__class__.__name__}")
|
||||
|
||||
def resolve(self) -> str:
|
||||
return _resolve_exe(self)
|
||||
|
||||
def exists(self) -> bool:
|
||||
return os.path.exists(self.resolve())
|
||||
|
||||
|
||||
class _UserPath(str):
|
||||
def resolve(self) -> str:
|
||||
if os.path.isabs(self):
|
||||
return str(self)
|
||||
from Utils import user_path
|
||||
return user_path(_resolve_exe(self))
|
||||
|
||||
|
||||
class _LocalPath(str):
|
||||
def resolve(self) -> str:
|
||||
if os.path.isabs(self):
|
||||
return str(self)
|
||||
from Utils import local_path
|
||||
return local_path(_resolve_exe(self))
|
||||
|
||||
|
||||
class FilePath(Path):
|
||||
# path to a file
|
||||
|
||||
md5s: ClassVar[List[Union[str, bytes]]] = []
|
||||
"""MD5 hashes for default validator."""
|
||||
|
||||
def browse(self: T,
|
||||
filetypes: Optional[typing.Sequence[typing.Tuple[str, typing.Sequence[str]]]] = None, **kwargs: Any)\
|
||||
-> Optional[T]:
|
||||
from Utils import open_filename, is_windows
|
||||
if not filetypes:
|
||||
if self.is_exe:
|
||||
name, ext = "Program", ".exe" if is_windows else ""
|
||||
else:
|
||||
ext = os.path.splitext(self)[1]
|
||||
name = ext[1:] if ext else "File"
|
||||
filetypes = [(name, [ext])]
|
||||
res = open_filename(f"Select {self.description or self.__class__.__name__}", filetypes, self)
|
||||
if res:
|
||||
self.validate(res)
|
||||
if self.copy_to:
|
||||
# instead of linking the file, copy it
|
||||
dst = self.__class__(self.copy_to).resolve()
|
||||
shutil.copy(res, dst, follow_symlinks=True)
|
||||
res = dst
|
||||
try:
|
||||
rel = os.path.relpath(res, self.__class__("").resolve())
|
||||
if not rel.startswith(".."):
|
||||
res = rel
|
||||
except ValueError:
|
||||
pass
|
||||
return self.__class__(res)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _validate_stream_hashes(cls, f: BinaryIO) -> None:
|
||||
"""Helper to efficiently validate stream against hashes"""
|
||||
if not cls.md5s:
|
||||
return # no hashes to validate against
|
||||
|
||||
pos = f.tell()
|
||||
try:
|
||||
from hashlib import md5
|
||||
file_md5 = md5()
|
||||
block = bytearray(64*1024)
|
||||
view = memoryview(block)
|
||||
while n := f.readinto(view): # type: ignore
|
||||
file_md5.update(view[:n])
|
||||
file_md5_hex = file_md5.hexdigest()
|
||||
for valid_md5 in cls.md5s:
|
||||
if isinstance(valid_md5, str):
|
||||
if valid_md5.lower() == file_md5_hex:
|
||||
break
|
||||
elif valid_md5 == file_md5.digest():
|
||||
break
|
||||
else:
|
||||
raise ValueError(f"Hashes do not match for {cls.__name__}")
|
||||
finally:
|
||||
f.seek(pos)
|
||||
|
||||
@classmethod
|
||||
def validate(cls, path: str) -> None:
|
||||
"""Try to open and validate file against hashes"""
|
||||
with open(path, "rb", buffering=0) as f:
|
||||
try:
|
||||
cls._validate_stream_hashes(f)
|
||||
except ValueError:
|
||||
raise ValueError(f"File hash does not match for {path}")
|
||||
|
||||
|
||||
class FolderPath(Path):
|
||||
# path to a folder
|
||||
|
||||
def browse(self: T, **kwargs: Any) -> Optional[T]:
|
||||
from Utils import open_directory
|
||||
res = open_directory(f"Select {self.description or self.__class__.__name__}", self)
|
||||
if res:
|
||||
try:
|
||||
rel = os.path.relpath(res, self.__class__("").resolve())
|
||||
if not rel.startswith(".."):
|
||||
res = rel
|
||||
except ValueError:
|
||||
pass
|
||||
return self.__class__(res)
|
||||
return None
|
||||
|
||||
|
||||
class UserFilePath(_UserPath, FilePath):
|
||||
pass
|
||||
|
||||
|
||||
class UserFolderPath(_UserPath, FolderPath):
|
||||
pass
|
||||
|
||||
|
||||
class OptionalUserFilePath(UserFilePath):
|
||||
required = False
|
||||
|
||||
|
||||
class OptionalUserFolderPath(UserFolderPath):
|
||||
required = False
|
||||
|
||||
|
||||
class LocalFilePath(_LocalPath, FilePath):
|
||||
pass
|
||||
|
||||
|
||||
class LocalFolderPath(_LocalPath, FolderPath):
|
||||
pass
|
||||
|
||||
|
||||
class OptionalLocalFilePath(LocalFilePath):
|
||||
required = False
|
||||
|
||||
|
||||
class OptionalLocalFolderPath(LocalFolderPath):
|
||||
required = False
|
||||
|
||||
|
||||
class SNESRomPath(UserFilePath):
|
||||
# Special UserFilePath that ignores an optional header when validating
|
||||
|
||||
@classmethod
|
||||
def validate(cls, path: str) -> None:
|
||||
"""Try to open and validate file against hashes"""
|
||||
with open(path, "rb", buffering=0) as f:
|
||||
f.seek(0, os.SEEK_END)
|
||||
size = f.tell()
|
||||
if size % 1024 == 512:
|
||||
f.seek(512) # skip header
|
||||
elif size % 1024 == 0:
|
||||
f.seek(0) # header-less
|
||||
else:
|
||||
raise ValueError(f"Unexpected file size for {path}")
|
||||
|
||||
try:
|
||||
cls._validate_stream_hashes(f)
|
||||
except ValueError:
|
||||
raise ValueError(f"File hash does not match for {path}")
|
||||
|
||||
|
||||
# World-independent setting groups
|
||||
|
||||
class GeneralOptions(Group):
|
||||
class OutputPath(OptionalUserFolderPath):
|
||||
"""
|
||||
Where to place output files
|
||||
"""
|
||||
# created on demand, so marked as optional
|
||||
|
||||
output_path: OutputPath = OutputPath("output")
|
||||
|
||||
|
||||
class ServerOptions(Group):
|
||||
"""
|
||||
Options for MultiServer
|
||||
Null means nothing, for the server this means to default the value
|
||||
These overwrite command line arguments!
|
||||
"""
|
||||
|
||||
class ServerPassword(str):
|
||||
"""
|
||||
Allows for clients to log on and manage the server. If this is null, no remote administration is possible.
|
||||
"""
|
||||
|
||||
class DisableItemCheat(Bool):
|
||||
"""Disallow !getitem"""
|
||||
|
||||
class LocationCheckPoints(int):
|
||||
"""
|
||||
Client hint system
|
||||
Points given to a player for each acquired item in their world
|
||||
"""
|
||||
|
||||
class HintCost(int):
|
||||
"""
|
||||
Relative point cost to receive a hint via !hint for players
|
||||
so for example hint_cost: 20 would mean that for every 20% of available checks, you get the ability to hint,
|
||||
for a total of 5
|
||||
"""
|
||||
|
||||
class ReleaseMode(str):
|
||||
"""
|
||||
Release modes
|
||||
A Release sends out the remaining items *from* a world that releases
|
||||
"disabled" -> clients can't release,
|
||||
"enabled" -> clients can always release
|
||||
"auto" -> automatic release on goal completion
|
||||
"auto-enabled" -> automatic release on goal completion and manual release is also enabled
|
||||
"goal" -> release is allowed after goal completion
|
||||
"""
|
||||
|
||||
class CollectMode(str):
|
||||
"""
|
||||
Collect modes
|
||||
A Collect sends the remaining items *to* a world that collects
|
||||
"disabled" -> clients can't collect,
|
||||
"enabled" -> clients can always collect
|
||||
"auto" -> automatic collect on goal completion
|
||||
"auto-enabled" -> automatic collect on goal completion and manual collect is also enabled
|
||||
"goal" -> collect is allowed after goal completion
|
||||
"""
|
||||
|
||||
class RemainingMode(str):
|
||||
"""
|
||||
Remaining modes
|
||||
!remaining handling, that tells a client which items remain in their pool
|
||||
"enabled" -> Client can always ask for remaining items
|
||||
"disabled" -> Client can never ask for remaining items
|
||||
"goal" -> Client can ask for remaining items after goal completion
|
||||
"""
|
||||
|
||||
class AutoShutdown(int):
|
||||
"""Automatically shut down the server after this many seconds without new location checks, 0 to keep running"""
|
||||
|
||||
class Compatibility(IntEnum):
|
||||
"""
|
||||
Compatibility handling
|
||||
2 -> Recommended for casual/cooperative play, attempt to be compatible with everything across all versions
|
||||
1 -> No longer in use, kept reserved in case of future use
|
||||
0 -> Recommended for tournaments to force a level playing field, only allow an exact version match
|
||||
"""
|
||||
OFF = 0
|
||||
ON = 1
|
||||
FULL = 2
|
||||
|
||||
class LogNetwork(IntEnum):
|
||||
"""log all server traffic, mostly for dev use"""
|
||||
OFF = 0
|
||||
ON = 1
|
||||
|
||||
host: Optional[str] = None
|
||||
port: int = 38281
|
||||
password: Optional[str] = None
|
||||
multidata: Optional[str] = None
|
||||
savefile: Optional[str] = None
|
||||
disable_save: bool = False
|
||||
loglevel: str = "info"
|
||||
server_password: Optional[ServerPassword] = None
|
||||
disable_item_cheat: Union[DisableItemCheat, bool] = False
|
||||
location_check_points: LocationCheckPoints = LocationCheckPoints(1)
|
||||
hint_cost: HintCost = HintCost(10)
|
||||
release_mode: ReleaseMode = ReleaseMode("goal")
|
||||
collect_mode: CollectMode = CollectMode("goal")
|
||||
remaining_mode: RemainingMode = RemainingMode("goal")
|
||||
auto_shutdown: AutoShutdown = AutoShutdown(0)
|
||||
compatibility: Compatibility = Compatibility(2)
|
||||
log_network: LogNetwork = LogNetwork(0)
|
||||
|
||||
|
||||
class GeneratorOptions(Group):
|
||||
"""Options for Generation"""
|
||||
|
||||
class EnemizerPath(LocalFilePath):
|
||||
"""Location of your Enemizer CLI, available here: https://github.com/Ijwu/Enemizer/releases"""
|
||||
is_exe = True
|
||||
|
||||
class PlayerFilesPath(OptionalUserFolderPath):
|
||||
"""Folder from which the player yaml files are pulled from"""
|
||||
# created on demand, so marked as optional
|
||||
|
||||
class Players(int):
|
||||
"""amount of players, 0 to infer from player files"""
|
||||
|
||||
class WeightsFilePath(str):
|
||||
"""
|
||||
general weights file, within the stated player_files_path location
|
||||
gets used if players is higher than the amount of per-player files found to fill remaining slots
|
||||
"""
|
||||
# this is special because the path is relative to player_files_path
|
||||
|
||||
class MetaFilePath(str):
|
||||
"""Meta file name, within the stated player_files_path location"""
|
||||
# this is special because the path is relative to player_files_path
|
||||
|
||||
class Spoiler(IntEnum):
|
||||
"""
|
||||
Create a spoiler file
|
||||
0 -> None
|
||||
1 -> Spoiler without playthrough or paths to playthrough required items
|
||||
2 -> Spoiler with playthrough (viable solution to goals)
|
||||
3 -> Spoiler with playthrough and traversal paths towards items
|
||||
"""
|
||||
NONE = 0
|
||||
BASIC = 1
|
||||
PLAYTHROUGH = 2
|
||||
FULL = 3
|
||||
|
||||
class GlitchTriforceRoom(IntEnum):
|
||||
"""
|
||||
Glitch to Triforce room from Ganon
|
||||
When disabled, you have to have a weapon that can hurt ganon (master sword or swordless/easy item functionality
|
||||
+ hammer) and have completed the goal required for killing ganon to be able to access the triforce room.
|
||||
1 -> Enabled.
|
||||
0 -> Disabled (except in no-logic)
|
||||
"""
|
||||
OFF = 0
|
||||
ON = 1
|
||||
|
||||
class PlandoOptions(str):
|
||||
"""
|
||||
List of options that can be plando'd. Can be combined, for example "bosses, items"
|
||||
Available options: bosses, items, texts, connections
|
||||
"""
|
||||
|
||||
class Race(IntEnum):
|
||||
"""Create encrypted race roms and flag games as race mode"""
|
||||
OFF = 0
|
||||
ON = 1
|
||||
|
||||
enemizer_path: EnemizerPath = EnemizerPath("EnemizerCLI/EnemizerCLI.Core") # + ".exe" is implied on Windows
|
||||
player_files_path: PlayerFilesPath = PlayerFilesPath("Players")
|
||||
players: Players = Players(0)
|
||||
weights_file_path: WeightsFilePath = WeightsFilePath("weights.yaml")
|
||||
meta_file_path: MetaFilePath = MetaFilePath("meta.yaml")
|
||||
spoiler: Spoiler = Spoiler(3)
|
||||
glitch_triforce_room: GlitchTriforceRoom = GlitchTriforceRoom(1) # why is this here?
|
||||
race: Race = Race(0)
|
||||
plando_options: PlandoOptions = PlandoOptions("bosses")
|
||||
|
||||
|
||||
class SNIOptions(Group):
|
||||
class SNIPath(LocalFolderPath):
|
||||
"""
|
||||
Set this to your SNI folder location if you want the MultiClient to attempt an auto start, \
|
||||
does nothing if not found
|
||||
"""
|
||||
|
||||
class SnesRomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching)
|
||||
True for operating system default program
|
||||
Alternatively, a path to a program to open the .sfc file with
|
||||
"""
|
||||
|
||||
sni_path: SNIPath = SNIPath("SNI")
|
||||
snes_rom_start: Union[SnesRomStart, bool] = True
|
||||
|
||||
|
||||
# Top-level group with lazy loading of worlds
|
||||
|
||||
class Settings(Group):
|
||||
general_options: GeneralOptions = GeneralOptions()
|
||||
server_options: ServerOptions = ServerOptions()
|
||||
generator: GeneratorOptions = GeneratorOptions()
|
||||
sni_options: SNIOptions = SNIOptions()
|
||||
|
||||
_filename: Optional[str] = None
|
||||
|
||||
def __getattribute__(self, key: str) -> Any:
|
||||
if key.startswith("_") or key in self.__class__.__dict__:
|
||||
# not a group or a hard-coded group
|
||||
pass
|
||||
elif key not in dir(self) or isinstance(super().__getattribute__(key), dict):
|
||||
# settings class not loaded yet
|
||||
if key not in _world_settings_name_cache:
|
||||
# find world that provides the settings class
|
||||
_update_cache()
|
||||
# check for missing keys to update _changed
|
||||
for world_settings_name in _world_settings_name_cache:
|
||||
if world_settings_name not in dir(self):
|
||||
self._changed = True
|
||||
if key not in _world_settings_name_cache:
|
||||
# not a world group
|
||||
return super().__getattribute__(key)
|
||||
# directly import world and grab settings class
|
||||
world_mod, world_cls_name = _world_settings_name_cache[key].rsplit(".", 1)
|
||||
world = cast(type, getattr(__import__(world_mod, fromlist=[world_cls_name]), world_cls_name))
|
||||
assert getattr(world, "settings_key") == key
|
||||
try:
|
||||
cls_or_name = world.__annotations__["settings"]
|
||||
except KeyError:
|
||||
import warnings
|
||||
warnings.warn(f"World {world_cls_name} does not define settings. Please consider upgrading the world.")
|
||||
return super().__getattribute__(key)
|
||||
if isinstance(cls_or_name, str):
|
||||
# Try to resolve type. Sadly we can't use get_type_hints, see https://bugs.python.org/issue43463
|
||||
cls_name = cls_or_name
|
||||
if "[" in cls_name: # resolve ClassVar[]
|
||||
cls_name = cls_name.split("[", 1)[1].rsplit("]", 1)[0]
|
||||
cls = cast(type, getattr(__import__(world_mod, fromlist=[cls_name]), cls_name))
|
||||
else:
|
||||
type_args = typing.get_args(cls_or_name) # resolve ClassVar[]
|
||||
cls = type_args[0] if type_args else cast(type, cls_or_name)
|
||||
impl: Group = cast(Group, cls())
|
||||
assert isinstance(impl, Group), f"{world_cls_name}.settings has to inherit from settings.Group. " \
|
||||
"If that's already the case, please avoid recursive partial imports."
|
||||
# above assert fails for recursive partial imports
|
||||
# upcast loaded data to settings class
|
||||
try:
|
||||
dct = super().__getattribute__(key)
|
||||
if isinstance(dct, dict):
|
||||
impl.update(dct)
|
||||
else:
|
||||
self._changed = True # key is a class var -> new section
|
||||
except AttributeError:
|
||||
self._changed = True # key is unknown -> new section
|
||||
setattr(self, key, impl)
|
||||
|
||||
return super().__getattribute__(key)
|
||||
|
||||
def __init__(self, location: Optional[str]): # change to PathLike[str] once we drop 3.8?
|
||||
super().__init__()
|
||||
if location:
|
||||
from Utils import parse_yaml
|
||||
with open(location, encoding="utf-8-sig") as f:
|
||||
options = parse_yaml(f.read())
|
||||
# TODO: detect if upgrade is required
|
||||
# TODO: once we have a cache for _world_settings_name_cache, detect if any game section is missing
|
||||
self.update(options or {})
|
||||
self._filename = location
|
||||
|
||||
def autosave() -> None:
|
||||
if self._filename and self.changed:
|
||||
self.save()
|
||||
|
||||
import atexit
|
||||
atexit.register(autosave)
|
||||
|
||||
def save(self, location: Optional[str] = None) -> None: # as above
|
||||
location = location or self._filename
|
||||
assert location, "No file specified"
|
||||
# can't use utf-8-sig because it breaks backward compat: pyyaml on Windows with bytes does not strip the BOM
|
||||
with open(location, "w", encoding="utf-8") as f:
|
||||
self.dump(f)
|
||||
self._filename = location
|
||||
|
||||
def dump(self, f: TextIO, level: int = 0) -> None:
|
||||
# load all world setting classes
|
||||
_update_cache()
|
||||
for key in _world_settings_name_cache:
|
||||
self.__getattribute__(key) # load all worlds
|
||||
super().dump(f, level)
|
||||
|
||||
@property
|
||||
def filename(self) -> Optional[str]:
|
||||
return self._filename
|
||||
|
||||
|
||||
# host.yaml loader
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Returns settings from the default host.yaml"""
|
||||
with _lock: # make sure we only have one instance
|
||||
res = getattr(get_settings, "_cache", None)
|
||||
if not res:
|
||||
import os
|
||||
from Utils import user_path, local_path
|
||||
filenames = ("options.yaml", "host.yaml")
|
||||
locations: 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:
|
||||
try:
|
||||
res = Settings(location)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
else:
|
||||
warnings.warn(f"Could not find {filenames[1]} to load options. Creating a new one.")
|
||||
res = Settings(None)
|
||||
res.save(user_path(filenames[1]))
|
||||
setattr(get_settings, "_cache", res)
|
||||
return res
|
10
setup.py
10
setup.py
|
@ -192,7 +192,7 @@ exes = [
|
|||
) for c in components if c.script_name and c.frozen_name
|
||||
]
|
||||
|
||||
extra_data = ["LICENSE", "data", "EnemizerCLI", "host.yaml", "SNI"]
|
||||
extra_data = ["LICENSE", "data", "EnemizerCLI", "SNI"]
|
||||
extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
|
||||
|
||||
|
||||
|
@ -418,14 +418,6 @@ class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE):
|
|||
for extra_exe in extra_exes:
|
||||
if extra_exe.is_file():
|
||||
extra_exe.chmod(0o755)
|
||||
# rewrite windows-specific things in host.yaml
|
||||
host_yaml = self.buildfolder / 'host.yaml'
|
||||
with host_yaml.open('r+b') as f:
|
||||
data = f.read()
|
||||
data = data.replace(b'factorio\\\\bin\\\\x64\\\\factorio', b'factorio/bin/x64/factorio')
|
||||
f.seek(0, os.SEEK_SET)
|
||||
f.write(data)
|
||||
f.truncate()
|
||||
|
||||
|
||||
class AppImageCommand(setuptools.Command):
|
||||
|
|
|
@ -73,13 +73,21 @@ class TestGenerateMain(unittest.TestCase):
|
|||
|
||||
def test_generate_yaml(self):
|
||||
# override host.yaml
|
||||
defaults = Generate.Utils.get_options()["generator"]
|
||||
defaults["player_files_path"] = str(self.yaml_input_dir)
|
||||
defaults["players"] = 0
|
||||
|
||||
sys.argv = [sys.argv[0], '--seed', '0',
|
||||
'--outputpath', self.output_tempdir.name]
|
||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
|
||||
Generate.main()
|
||||
from settings import get_settings
|
||||
from Utils import user_path, local_path
|
||||
settings = get_settings()
|
||||
# NOTE: until/unless we override settings.Group's setattr, we have to upcast the input dir here
|
||||
settings.generator.player_files_path = settings.generator.PlayerFilesPath(self.yaml_input_dir)
|
||||
settings.generator.players = 0
|
||||
settings._filename = None # don't write to disk
|
||||
user_path_backup = user_path.cached_path
|
||||
user_path.cached_path = local_path() # test yaml is actually in local_path
|
||||
try:
|
||||
sys.argv = [sys.argv[0], '--seed', '0',
|
||||
'--outputpath', self.output_tempdir.name]
|
||||
print(f'Testing Generate.py {sys.argv} in {os.getcwd()}, player_files_path={self.yaml_input_dir}')
|
||||
Generate.main()
|
||||
finally:
|
||||
user_path.cached_path = user_path_backup
|
||||
|
||||
self.assertOutput(self.output_tempdir.name)
|
||||
|
|
|
@ -14,12 +14,23 @@ if TYPE_CHECKING:
|
|||
import random
|
||||
from BaseClasses import MultiWorld, Item, Location, Tutorial
|
||||
from . import GamesPackage
|
||||
from settings import Group
|
||||
|
||||
|
||||
class AutoWorldRegister(type):
|
||||
world_types: Dict[str, Type[World]] = {}
|
||||
__file__: str
|
||||
zip_path: Optional[str]
|
||||
settings_key: str
|
||||
__settings: Any
|
||||
|
||||
@property
|
||||
def settings(cls) -> Any: # actual type is defined in World
|
||||
# lazy loading + caching to minimize runtime cost
|
||||
if cls.__settings is None:
|
||||
from settings import get_settings
|
||||
cls.__settings = get_settings()[cls.settings_key]
|
||||
return cls.__settings
|
||||
|
||||
def __new__(mcs, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoWorldRegister:
|
||||
if "web" in dct:
|
||||
|
@ -61,6 +72,11 @@ class AutoWorldRegister(type):
|
|||
new_class.__file__ = sys.modules[new_class.__module__].__file__
|
||||
if ".apworld" in new_class.__file__:
|
||||
new_class.zip_path = pathlib.Path(new_class.__file__).parents[1]
|
||||
if "settings_key" not in dct:
|
||||
mod_name = new_class.__module__
|
||||
world_folder_name = mod_name[7:].lower() if mod_name.startswith("worlds.") else mod_name.lower()
|
||||
new_class.settings_key = world_folder_name + "_options"
|
||||
new_class.__settings = None
|
||||
return new_class
|
||||
|
||||
|
||||
|
@ -207,6 +223,11 @@ class World(metaclass=AutoWorldRegister):
|
|||
random: random.Random
|
||||
"""This world's random object. Should be used for any randomization needed in world for this player slot."""
|
||||
|
||||
settings_key: ClassVar[str]
|
||||
"""name of the section in host.yaml for world-specific settings, will default to {folder}_options"""
|
||||
settings: ClassVar[Optional["Group"]]
|
||||
"""loaded settings from host.yaml"""
|
||||
|
||||
zip_path: ClassVar[Optional[pathlib.Path]] = None
|
||||
"""If loaded from a .apworld, this is the Path to it."""
|
||||
__file__: ClassVar[str]
|
||||
|
@ -216,6 +237,11 @@ class World(metaclass=AutoWorldRegister):
|
|||
self.multiworld = multiworld
|
||||
self.player = player
|
||||
|
||||
def __getattr__(self, item: str) -> Any:
|
||||
if item == "settings":
|
||||
return self.__class__.settings
|
||||
raise AttributeError
|
||||
|
||||
# overridable methods that get called by Main.py, sorted by execution order
|
||||
# can also be implemented as a classmethod and called "stage_<original_name>",
|
||||
# in that case the MultiWorld object is passed as an argument and it gets called once for the entire multiworld.
|
||||
|
|
|
@ -3,6 +3,8 @@ import copy
|
|||
import itertools
|
||||
import math
|
||||
import os
|
||||
import settings
|
||||
import typing
|
||||
from enum import IntFlag
|
||||
from typing import Any, ClassVar, Dict, List, Optional, Set, Tuple
|
||||
|
||||
|
@ -31,6 +33,42 @@ from worlds.LauncherComponents import Component, components, SuffixIdentifier
|
|||
components.append(Component('Adventure Client', 'AdventureClient', file_identifier=SuffixIdentifier('.apadvn')))
|
||||
|
||||
|
||||
class AdventureSettings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""
|
||||
File name of the standard NTSC Adventure rom.
|
||||
The licensed "The 80 Classic Games" CD-ROM contains this.
|
||||
It may also have a .a26 extension
|
||||
"""
|
||||
copy_to = "ADVNTURE.BIN"
|
||||
description = "Adventure ROM File"
|
||||
md5s = [AdventureDeltaPatch.hash]
|
||||
|
||||
class RomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching)
|
||||
True for operating system default program for '.a26'
|
||||
Alternatively, a path to a program to open the .a26 file with (generally EmuHawk for multiworld)
|
||||
"""
|
||||
|
||||
class RomArgs(str):
|
||||
"""
|
||||
Optional, additional args passed into rom_start before the .bin file
|
||||
For example, this can be used to autoload the connector script in BizHawk
|
||||
(see BizHawk --lua= option)
|
||||
Windows example:
|
||||
rom_args: "--lua=C:/ProgramData/Archipelago/data/lua/connector_adventure.lua"
|
||||
"""
|
||||
|
||||
class DisplayMsgs(settings.Bool):
|
||||
"""Set this to true to display item received messages in EmuHawk"""
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: typing.Union[RomStart, bool] = True
|
||||
rom_args: Optional[RomArgs] = " "
|
||||
display_msgs: typing.Union[DisplayMsgs, bool] = True
|
||||
|
||||
|
||||
class AdventureWeb(WebWorld):
|
||||
theme = "dirt"
|
||||
|
||||
|
@ -53,7 +91,6 @@ class AdventureWeb(WebWorld):
|
|||
)
|
||||
|
||||
tutorials = [setup, setup_fr]
|
||||
|
||||
|
||||
|
||||
def get_item_position_data_start(table_index: int):
|
||||
|
@ -73,6 +110,7 @@ class AdventureWorld(World):
|
|||
web: ClassVar[WebWorld] = AdventureWeb()
|
||||
|
||||
option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
|
||||
settings: ClassVar[AdventureSettings]
|
||||
item_name_to_id: ClassVar[Dict[str, int]] = {name: data.id for name, data in item_table.items()}
|
||||
location_name_to_id: ClassVar[Dict[str, int]] = {name: data.location_id for name, data in location_table.items()}
|
||||
data_version: ClassVar[int] = 1
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import logging
|
||||
import os
|
||||
import random
|
||||
import settings
|
||||
import threading
|
||||
import typing
|
||||
|
||||
|
@ -29,6 +30,16 @@ lttp_logger = logging.getLogger("A Link to the Past")
|
|||
extras_list = sum(difficulties['normal'].extras[0:5], [])
|
||||
|
||||
|
||||
class ALTTPSettings(settings.Group):
|
||||
class RomFile(settings.SNESRomPath):
|
||||
"""File name of the v1.0 J rom"""
|
||||
description = "ALTTP v1.0 J ROM File"
|
||||
copy_to = "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"
|
||||
md5s = [LttPDeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class ALTTPWeb(WebWorld):
|
||||
setup_en = Tutorial(
|
||||
"Multiworld Setup Tutorial",
|
||||
|
@ -123,6 +134,8 @@ class ALTTPWorld(World):
|
|||
"""
|
||||
game = "A Link to the Past"
|
||||
option_definitions = alttp_options
|
||||
settings_key = "lttp_options"
|
||||
settings: typing.ClassVar[ALTTPSettings]
|
||||
topology_present = True
|
||||
item_name_groups = item_name_groups
|
||||
location_name_groups = {
|
||||
|
@ -219,9 +232,16 @@ class ALTTPWorld(World):
|
|||
|
||||
create_items = generate_itempool
|
||||
|
||||
enemizer_path: str = Utils.get_options()["generator"]["enemizer_path"] \
|
||||
if os.path.isabs(Utils.get_options()["generator"]["enemizer_path"]) \
|
||||
else Utils.local_path(Utils.get_options()["generator"]["enemizer_path"])
|
||||
_enemizer_path: typing.ClassVar[typing.Optional[str]] = None
|
||||
|
||||
@property
|
||||
def enemizer_path(self) -> str:
|
||||
# TODO: directly use settings
|
||||
cls = self.__class__
|
||||
if cls._enemizer_path is None:
|
||||
cls._enemizer_path = settings.get_settings().generator.enemizer_path
|
||||
assert isinstance(cls._enemizer_path, str)
|
||||
return cls._enemizer_path
|
||||
|
||||
# custom instance vars
|
||||
dungeon_local_item_names: typing.Set[str]
|
||||
|
|
|
@ -3,6 +3,7 @@ import typing
|
|||
import math
|
||||
import threading
|
||||
|
||||
import settings
|
||||
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
|
||||
from .Items import DKC3Item, ItemData, item_table, inventory_table, junk_table
|
||||
from .Locations import DKC3Location, all_locations, setup_locations
|
||||
|
@ -17,6 +18,16 @@ from .Rom import LocalRom, patch_rom, get_base_rom_path, DKC3DeltaPatch
|
|||
import Patch
|
||||
|
||||
|
||||
class DK3Settings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the DKC3 US rom"""
|
||||
copy_to = "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc"
|
||||
description = "DKC3 (US) ROM File"
|
||||
md5s = [DKC3DeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class DKC3Web(WebWorld):
|
||||
theme = "jungle"
|
||||
|
||||
|
@ -40,6 +51,7 @@ class DKC3World(World):
|
|||
"""
|
||||
game: str = "Donkey Kong Country 3"
|
||||
option_definitions = dkc3_options
|
||||
settings: typing.ClassVar[DK3Settings]
|
||||
topology_present = False
|
||||
data_version = 2
|
||||
#hint_blacklist = {LocationName.rocket_rush_flag}
|
||||
|
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
|||
|
||||
import collections
|
||||
import logging
|
||||
import settings
|
||||
import typing
|
||||
|
||||
from BaseClasses import Region, Entrance, Location, Item, Tutorial, ItemClassification
|
||||
|
@ -27,6 +28,29 @@ def launch_client():
|
|||
components.append(Component("Factorio Client", "FactorioClient", func=launch_client, component_type=Type.CLIENT))
|
||||
|
||||
|
||||
class FactorioSettings(settings.Group):
|
||||
class Executable(settings.UserFilePath):
|
||||
is_exe = True
|
||||
|
||||
class ServerSettings(settings.OptionalUserFilePath):
|
||||
"""
|
||||
by default, no settings are loaded if this file does not exist. \
|
||||
If this file does exist, then it will be used.
|
||||
server_settings: "factorio\\\\data\\\\server-settings.json"
|
||||
"""
|
||||
|
||||
class FilterItemSends(settings.Bool):
|
||||
"""Whether to filter item send messages displayed in-game to only those that involve you."""
|
||||
|
||||
class BridgeChatOut(settings.Bool):
|
||||
"""Whether to send chat messages from players on the Factorio server to Archipelago."""
|
||||
|
||||
executable: Executable = Executable("factorio/bin/x64/factorio")
|
||||
server_settings: typing.Optional[FactorioSettings.ServerSettings] = None
|
||||
filter_item_sends: typing.Union[FilterItemSends, bool] = False
|
||||
bridge_chat_out: typing.Union[BridgeChatOut, bool] = True
|
||||
|
||||
|
||||
class FactorioWeb(WebWorld):
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Tutorial",
|
||||
|
@ -80,6 +104,8 @@ class Factorio(World):
|
|||
skip_silo: bool = False
|
||||
science_locations: typing.List[FactorioScienceLocation]
|
||||
|
||||
settings: typing.ClassVar[FactorioSettings]
|
||||
|
||||
def __init__(self, world, player: int):
|
||||
super(Factorio, self).__init__(world, player)
|
||||
self.advancement_technologies = set()
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
import settings
|
||||
import typing
|
||||
|
||||
from typing import Dict
|
||||
from BaseClasses import Item, Location, MultiWorld, Tutorial, ItemClassification
|
||||
from .Items import ItemData, FF1Items, FF1_STARTER_ITEMS, FF1_PROGRESSION_LIST, FF1_BRIDGE
|
||||
|
@ -6,6 +9,10 @@ from .Options import ff1_options
|
|||
from ..AutoWorld import World, WebWorld
|
||||
|
||||
|
||||
class FF1Settings(settings.Group):
|
||||
display_msgs: bool = True
|
||||
|
||||
|
||||
class FF1Web(WebWorld):
|
||||
settings_page = "https://finalfantasyrandomizer.com/"
|
||||
tutorials = [Tutorial(
|
||||
|
@ -28,6 +35,8 @@ class FF1World(World):
|
|||
"""
|
||||
|
||||
option_definitions = ff1_options
|
||||
settings: typing.ClassVar[FF1Settings]
|
||||
settings_key = "ffr_options"
|
||||
game = "Final Fantasy"
|
||||
topology_present = False
|
||||
data_version = 2
|
||||
|
|
|
@ -2,8 +2,11 @@ import binascii
|
|||
import bsdiff4
|
||||
import os
|
||||
import pkgutil
|
||||
import settings
|
||||
import typing
|
||||
import tempfile
|
||||
|
||||
|
||||
from BaseClasses import Entrance, Item, ItemClassification, Location, Tutorial
|
||||
from Fill import fill_restrictive
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
|
@ -29,6 +32,16 @@ from .Rom import LADXDeltaPatch
|
|||
DEVELOPER_MODE = False
|
||||
|
||||
|
||||
class LinksAwakeningSettings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the Link's Awakening DX rom"""
|
||||
copy_to = "Legend of Zelda, The - Link's Awakening DX (USA, Europe) (SGB Enhanced).gbc"
|
||||
description = "LADX ROM File"
|
||||
md5s = [LADXDeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class LinksAwakeningWebWorld(WebWorld):
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
|
@ -40,6 +53,7 @@ class LinksAwakeningWebWorld(WebWorld):
|
|||
)]
|
||||
theme = "dirt"
|
||||
|
||||
|
||||
class LinksAwakeningWorld(World):
|
||||
"""
|
||||
After a previous adventure, Link is stranded on Koholint Island, full of mystery and familiar faces.
|
||||
|
@ -49,6 +63,7 @@ class LinksAwakeningWorld(World):
|
|||
web = LinksAwakeningWebWorld()
|
||||
|
||||
option_definitions = links_awakening_options # options the player can set
|
||||
settings: typing.ClassVar[LinksAwakeningSettings]
|
||||
topology_present = True # show path to required location checks in spoiler
|
||||
|
||||
# data_version is used to signal that items, locations or their names
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import base64
|
||||
import itertools
|
||||
import os
|
||||
import settings
|
||||
|
||||
from enum import IntFlag
|
||||
from random import Random
|
||||
from typing import Any, ClassVar, Dict, get_type_hints, Iterator, List, Set, Tuple
|
||||
|
@ -22,6 +24,16 @@ from .basepatch import apply_basepatch
|
|||
CHESTS_PER_SPHERE: int = 5
|
||||
|
||||
|
||||
class L2ACSettings(settings.Group):
|
||||
class RomFile(settings.SNESRomPath):
|
||||
"""File name of the US rom"""
|
||||
description = "Lufia II ROM File"
|
||||
copy_to = "Lufia II - Rise of the Sinistrals (USA).sfc"
|
||||
md5s = [L2ACDeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class L2ACWeb(WebWorld):
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
|
@ -45,6 +57,7 @@ class L2ACWorld(World):
|
|||
web: ClassVar[WebWorld] = L2ACWeb()
|
||||
|
||||
option_definitions: ClassVar[Dict[str, AssembleOptions]] = get_type_hints(L2ACOptions)
|
||||
settings: ClassVar[L2ACSettings]
|
||||
item_name_to_id: ClassVar[Dict[str, int]] = l2ac_item_name_to_id
|
||||
location_name_to_id: ClassVar[Dict[str, int]] = l2ac_location_name_to_id
|
||||
item_name_groups: ClassVar[Dict[str, Set[str]]] = {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import os
|
||||
import json
|
||||
import settings
|
||||
import typing
|
||||
from base64 import b64encode, b64decode
|
||||
from typing import Dict, Any
|
||||
|
||||
|
@ -14,6 +16,22 @@ from .Rules import set_rules
|
|||
|
||||
client_version = 9
|
||||
|
||||
|
||||
class MinecraftSettings(settings.Group):
|
||||
class ForgeDirectory(settings.OptionalUserFolderPath):
|
||||
pass
|
||||
|
||||
class ReleaseChannel(str):
|
||||
"""
|
||||
release channel, currently "release", or "beta"
|
||||
any games played on the "beta" channel have a high likelihood of no longer working on the "release" channel.
|
||||
"""
|
||||
|
||||
forge_directory: ForgeDirectory = ForgeDirectory("Minecraft Forge server")
|
||||
max_heap_size: str = "2G"
|
||||
release_channel: ReleaseChannel = ReleaseChannel("release")
|
||||
|
||||
|
||||
class MinecraftWebWorld(WebWorld):
|
||||
theme = "jungle"
|
||||
bug_report_page = "https://github.com/KonoTyran/Minecraft_AP_Randomizer/issues/new?assignees=&labels=bug&template=bug_report.yaml&title=%5BBug%5D%3A+Brief+Description+of+bug+here"
|
||||
|
@ -67,6 +85,7 @@ class MinecraftWorld(World):
|
|||
"""
|
||||
game: str = "Minecraft"
|
||||
option_definitions = minecraft_options
|
||||
settings: typing.ClassVar[MinecraftSettings]
|
||||
topology_present = True
|
||||
web = MinecraftWebWorld()
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import os
|
||||
import settings
|
||||
import typing
|
||||
import threading
|
||||
|
||||
|
@ -16,6 +17,17 @@ from .Names.ItemName import ItemName
|
|||
from .Names.LocationName import LocationName
|
||||
|
||||
|
||||
class MMBN3Settings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the MMBN3 Blue US rom"""
|
||||
copy_to = "Mega Man Battle Network 3 - Blue Version (USA).gba"
|
||||
description = "MMBN3 ROM File"
|
||||
md5s = [MMBN3DeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: bool = True
|
||||
|
||||
|
||||
class MMBN3Web(WebWorld):
|
||||
theme = "ice"
|
||||
|
||||
|
@ -39,6 +51,7 @@ class MMBN3World(World):
|
|||
"""
|
||||
game = "MegaMan Battle Network 3"
|
||||
option_definitions = MMBN3Options
|
||||
settings: typing.ClassVar[MMBN3Settings]
|
||||
topology_present = False
|
||||
|
||||
data_version = 1
|
||||
|
|
|
@ -2,6 +2,8 @@ import logging
|
|||
import threading
|
||||
import copy
|
||||
import functools
|
||||
import settings
|
||||
import typing
|
||||
from typing import Optional, List, AbstractSet, Union # remove when 3.8 support is dropped
|
||||
from collections import Counter, deque
|
||||
from string import printable
|
||||
|
@ -66,6 +68,28 @@ class OOTCollectionState(metaclass=AutoLogicRegister):
|
|||
return ret
|
||||
|
||||
|
||||
class OOTSettings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the OoT v1.0 ROM"""
|
||||
description = "Ocarina of Time ROM File"
|
||||
copy_to = "The Legend of Zelda - Ocarina of Time.z64"
|
||||
md5s = [
|
||||
"5bd1fe107bf8106b2ab6650abecd54d6", # normal
|
||||
"6697768a7a7df2dd27a692a2638ea90b", # byte-swapped
|
||||
"05f0f3ebacbc8df9243b6148ffe4792f", # decompressed
|
||||
]
|
||||
|
||||
class RomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching),
|
||||
true for operating system default program
|
||||
Alternatively, a path to a program to open the .z64 file with
|
||||
"""
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: typing.Union[RomStart, bool] = True
|
||||
|
||||
|
||||
class OOTWeb(WebWorld):
|
||||
setup = Tutorial(
|
||||
"Multiworld Setup Tutorial",
|
||||
|
@ -105,6 +129,7 @@ class OOTWorld(World):
|
|||
"""
|
||||
game: str = "Ocarina of Time"
|
||||
option_definitions: dict = oot_options
|
||||
settings: typing.ClassVar[OOTSettings]
|
||||
topology_present: bool = True
|
||||
item_name_to_id = {item_name: oot_data_to_ap_id(data, False) for item_name, data in item_table.items() if
|
||||
data[2] is not None and item_name not in {
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from typing import TextIO
|
||||
import os
|
||||
import logging
|
||||
import settings
|
||||
import typing
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import TextIO
|
||||
|
||||
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
|
||||
from Fill import fill_restrictive, FillError, sweep_from_pool
|
||||
|
@ -15,12 +17,36 @@ from .options import pokemon_rb_options
|
|||
from .rom_addresses import rom_addresses
|
||||
from .text import encode_text
|
||||
from .rom import generate_output, get_base_rom_bytes, get_base_rom_path, process_pokemon_data, process_wild_pokemon,\
|
||||
process_static_pokemon, process_move_data
|
||||
process_static_pokemon, process_move_data, RedDeltaPatch, BlueDeltaPatch
|
||||
from .rules import set_rules
|
||||
|
||||
import worlds.pokemon_rb.poke_data as poke_data
|
||||
|
||||
|
||||
class PokemonSettings(settings.Group):
|
||||
class RedRomFile(settings.UserFilePath):
|
||||
"""File names of the Pokemon Red and Blue roms"""
|
||||
description = "Pokemon Red (UE) ROM File"
|
||||
copy_to = "Pokemon Red (UE) [S][!].gb"
|
||||
md5s = [RedDeltaPatch.hash]
|
||||
|
||||
class BlueRomFile(settings.UserFilePath):
|
||||
description = "Pokemon Blue (UE) ROM File"
|
||||
copy_to = "Pokemon Blue (UE) [S][!].gb"
|
||||
md5s = [BlueDeltaPatch.hash]
|
||||
|
||||
class RomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching)
|
||||
True for operating system default program
|
||||
Alternatively, a path to a program to open the .gb file with
|
||||
"""
|
||||
|
||||
red_rom_file: RedRomFile = RedRomFile(RedRomFile.copy_to)
|
||||
blue_rom_file: BlueRomFile = BlueRomFile(BlueRomFile.copy_to)
|
||||
rom_start: typing.Union[RomStart, bool] = True
|
||||
|
||||
|
||||
class PokemonWebWorld(WebWorld):
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
|
@ -39,6 +65,7 @@ class PokemonRedBlueWorld(World):
|
|||
# -MuffinJets#4559
|
||||
game = "Pokemon Red and Blue"
|
||||
option_definitions = pokemon_rb_options
|
||||
settings: typing.ClassVar[PokemonSettings]
|
||||
|
||||
data_version = 8
|
||||
required_client_version = (0, 3, 9)
|
||||
|
|
|
@ -5,6 +5,8 @@ import copy
|
|||
import os
|
||||
import threading
|
||||
import base64
|
||||
import settings
|
||||
import typing
|
||||
from typing import Any, Dict, Iterable, List, Set, TextIO, TypedDict
|
||||
|
||||
from BaseClasses import Region, Entrance, Location, MultiWorld, Item, ItemClassification, CollectionState, Tutorial
|
||||
|
@ -34,6 +36,16 @@ from .variaRandomizer.rom.rom_patches import RomPatches
|
|||
from .variaRandomizer.graph.graph_utils import GraphUtils
|
||||
|
||||
|
||||
class SMSettings(settings.Group):
|
||||
class RomFile(settings.SNESRomPath):
|
||||
"""File name of the v1.0 J rom"""
|
||||
description = "Super Metroid (JU) ROM"
|
||||
copy_to = "Super Metroid (JU).sfc"
|
||||
md5s = [SMDeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class SMCollectionState(metaclass=AutoLogicRegister):
|
||||
def init_mixin(self, parent: MultiWorld):
|
||||
|
||||
|
@ -78,6 +90,7 @@ class ByteEdit(TypedDict):
|
|||
locations_start_id = 82000
|
||||
items_start_id = 83000
|
||||
|
||||
|
||||
class SMWorld(World):
|
||||
"""
|
||||
This is Very Adaptive Randomizer of Items and Areas for Super Metroid (VARIA SM). It supports
|
||||
|
@ -89,6 +102,7 @@ class SMWorld(World):
|
|||
topology_present = True
|
||||
data_version = 3
|
||||
option_definitions = sm_options
|
||||
settings: typing.ClassVar[SMSettings]
|
||||
|
||||
item_name_to_id = {value.Name: items_start_id + value.Id for key, value in ItemManager.Items.items() if value.Id != None}
|
||||
location_name_to_id = {key: locations_start_id + value.Id for key, value in locationsDict.items() if value.Id != None}
|
||||
|
@ -119,7 +133,8 @@ class SMWorld(World):
|
|||
def generate_early(self):
|
||||
Logic.factory('vanilla')
|
||||
|
||||
self.variaRando = VariaRandomizer(self.multiworld, get_base_rom_path(), self.player)
|
||||
dummy_rom_file = Utils.user_path(SMSettings.RomFile.copy_to) # actual rom set in generate_output
|
||||
self.variaRando = VariaRandomizer(self.multiworld, dummy_rom_file, self.player)
|
||||
self.multiworld.state.smbm[self.player] = SMBoolManager(self.player, self.variaRando.maxDifficulty)
|
||||
|
||||
# keeps Nothing items local so no player will ever pickup Nothing
|
||||
|
@ -777,6 +792,7 @@ class SMWorld(World):
|
|||
romPatcher.end()
|
||||
|
||||
def generate_output(self, output_directory: str):
|
||||
self.variaRando.args.rom = get_base_rom_path()
|
||||
outfilebase = self.multiworld.get_out_file_name_base(self.player)
|
||||
outputFilename = os.path.join(output_directory, f"{outfilebase}.sfc")
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import os
|
||||
import typing
|
||||
import math
|
||||
import settings
|
||||
import threading
|
||||
|
||||
from BaseClasses import Item, MultiWorld, Tutorial, ItemClassification
|
||||
|
@ -17,6 +18,16 @@ from worlds.AutoWorld import WebWorld, World
|
|||
from .Rom import LocalRom, patch_rom, get_base_rom_path, SMWDeltaPatch
|
||||
|
||||
|
||||
class SMWSettings(settings.Group):
|
||||
class RomFile(settings.SNESRomPath):
|
||||
"""File name of the SMW US rom"""
|
||||
description = "Super Mario World (USA) ROM File"
|
||||
copy_to = "Super Mario World (USA).sfc"
|
||||
md5s = [SMWDeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class SMWWeb(WebWorld):
|
||||
theme = "grass"
|
||||
|
||||
|
@ -40,6 +51,7 @@ class SMWWorld(World):
|
|||
"""
|
||||
game: str = "Super Mario World"
|
||||
option_definitions = smw_options
|
||||
settings: typing.ClassVar[SMWSettings]
|
||||
topology_present = False
|
||||
data_version = 3
|
||||
required_client_version = (0, 3, 5)
|
||||
|
|
|
@ -3,6 +3,8 @@ import os
|
|||
import os.path
|
||||
import threading
|
||||
import typing
|
||||
|
||||
import settings
|
||||
from worlds.AutoWorld import WebWorld, World
|
||||
from worlds.generic.Rules import add_item_rule, set_rule
|
||||
from BaseClasses import Entrance, Item, ItemClassification, Location, LocationProgressType, Region, Tutorial
|
||||
|
@ -145,6 +147,16 @@ class SoEWebWorld(WebWorld):
|
|||
)]
|
||||
|
||||
|
||||
class SoESettings(settings.Group):
|
||||
class RomFile(settings.SNESRomPath):
|
||||
"""File name of the SoE US ROM"""
|
||||
description = "Secret of Evermore (USA) ROM"
|
||||
copy_to = "Secret of Evermore (USA).sfc"
|
||||
md5s = [SoEDeltaPatch.hash]
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
|
||||
|
||||
class SoEWorld(World):
|
||||
"""
|
||||
Secret of Evermore is a SNES action RPG. You learn alchemy spells, fight bosses and gather rocket parts to visit a
|
||||
|
@ -152,6 +164,7 @@ class SoEWorld(World):
|
|||
"""
|
||||
game: str = "Secret of Evermore"
|
||||
option_definitions = soe_options
|
||||
settings: typing.ClassVar[SoESettings]
|
||||
topology_present = False
|
||||
data_version = 4
|
||||
web = SoEWebWorld()
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import os
|
||||
import threading
|
||||
from pkgutil import get_data
|
||||
from typing import Dict, Any
|
||||
|
||||
import bsdiff4
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
import typing
|
||||
|
||||
from typing import NamedTuple, Union, Dict, Any
|
||||
from BaseClasses import Item, Location, Region, Entrance, MultiWorld, ItemClassification, Tutorial
|
||||
from .ItemPool import generate_itempool, starting_weapons, dangerous_weapon_locations
|
||||
from .Items import item_table, item_prices, item_game_ids
|
||||
|
@ -18,6 +20,28 @@ from worlds.AutoWorld import World, WebWorld
|
|||
from worlds.generic.Rules import add_rule
|
||||
|
||||
|
||||
class TLoZSettings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the Zelda 1"""
|
||||
description = "The Legend of Zelda (U) ROM File"
|
||||
copy_to = "Legend of Zelda, The (U) (PRG0) [!].nes"
|
||||
md5s = [TLoZDeltaPatch.hash]
|
||||
|
||||
class RomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching)
|
||||
true for operating system default program
|
||||
Alternatively, a path to a program to open the .nes file with
|
||||
"""
|
||||
|
||||
class DisplayMsgs(settings.Bool):
|
||||
"""Display message inside of Bizhawk"""
|
||||
|
||||
rom_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: typing.Union[RomStart, bool] = True
|
||||
display_msgs: typing.Union[DisplayMsgs, bool] = True
|
||||
|
||||
|
||||
class TLoZWeb(WebWorld):
|
||||
theme = "stone"
|
||||
setup = Tutorial(
|
||||
|
@ -40,6 +64,7 @@ class TLoZWorld(World):
|
|||
every time.
|
||||
"""
|
||||
option_definitions = tloz_options
|
||||
settings: typing.ClassVar[TLoZSettings]
|
||||
game = "The Legend of Zelda"
|
||||
topology_present = False
|
||||
data_version = 1
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import os
|
||||
import settings
|
||||
import string
|
||||
import json
|
||||
import typing
|
||||
|
||||
from BaseClasses import Item, MultiWorld, Region, Location, Entrance, Tutorial, ItemClassification
|
||||
from .Items import item_table, faction_table
|
||||
|
@ -11,6 +11,17 @@ from ..AutoWorld import World, WebWorld
|
|||
from .Options import wargroove_options
|
||||
|
||||
|
||||
class WargrooveSettings(settings.Group):
|
||||
class RootDirectory(settings.UserFolderPath):
|
||||
"""
|
||||
Locate the Wargroove root directory on your system.
|
||||
This is used by the Wargroove client, so it knows where to send communication files to
|
||||
"""
|
||||
description = "Wargroove root directory"
|
||||
|
||||
root_directory: RootDirectory = RootDirectory("C:/Program Files (x86)/Steam/steamapps/common/Wargroove")
|
||||
|
||||
|
||||
class WargrooveWeb(WebWorld):
|
||||
tutorials = [Tutorial(
|
||||
"Multiworld Setup Guide",
|
||||
|
@ -28,6 +39,7 @@ class WargrooveWorld(World):
|
|||
"""
|
||||
|
||||
option_definitions = wargroove_options
|
||||
settings: typing.ClassVar[WargrooveSettings]
|
||||
game = "Wargroove"
|
||||
topology_present = True
|
||||
data_version = 1
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
from collections import deque, Counter
|
||||
from contextlib import redirect_stdout
|
||||
import functools
|
||||
import settings
|
||||
import threading
|
||||
import typing
|
||||
from typing import Any, Dict, List, Literal, Set, Tuple, Optional, cast
|
||||
import os
|
||||
import logging
|
||||
|
@ -26,6 +28,26 @@ from zilliandomizer.options import Chars
|
|||
from ..AutoWorld import World, WebWorld
|
||||
|
||||
|
||||
class ZillionSettings(settings.Group):
|
||||
class RomFile(settings.UserFilePath):
|
||||
"""File name of the Zillion US rom"""
|
||||
description = "Zillion US ROM File"
|
||||
copy_to = "Zillion (UE) [!].sms"
|
||||
md5s = [ZillionDeltaPatch.hash]
|
||||
|
||||
class RomStart(str):
|
||||
"""
|
||||
Set this to false to never autostart a rom (such as after patching)
|
||||
True for operating system default program
|
||||
Alternatively, a path to a program to open the .sfc file with
|
||||
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_file: RomFile = RomFile(RomFile.copy_to)
|
||||
rom_start: typing.Union[RomStart, bool] = RomStart("retroarch")
|
||||
|
||||
|
||||
class ZillionWebWorld(WebWorld):
|
||||
theme = "stone"
|
||||
tutorials = [Tutorial(
|
||||
|
@ -48,6 +70,7 @@ class ZillionWorld(World):
|
|||
web = ZillionWebWorld()
|
||||
|
||||
option_definitions = zillion_options
|
||||
settings: typing.ClassVar[ZillionSettings]
|
||||
topology_present = True # indicate if world type has any meaningful layout/pathing
|
||||
|
||||
# map names to their IDs
|
||||
|
@ -122,7 +145,8 @@ class ZillionWorld(World):
|
|||
|
||||
self._item_counts = item_counts
|
||||
|
||||
rom_dir_name = os.path.dirname(get_base_rom_path())
|
||||
import __main__
|
||||
rom_dir_name = "" if "test" in __main__.__file__ else os.path.dirname(get_base_rom_path())
|
||||
with redirect_stdout(self.lsi): # type: ignore
|
||||
self.zz_system.make_patcher(rom_dir_name)
|
||||
self.zz_system.make_randomizer(zz_op)
|
||||
|
|
Loading…
Reference in New Issue