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:
black-sliver 2023-07-05 22:39:35 +02:00 committed by GitHub
parent d8a8997684
commit 827444f5a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1455 additions and 412 deletions

View File

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

1
.gitignore vendored
View File

@ -37,6 +37,7 @@ README.html
EnemizerCLI/
/Players/
/SNI/
/host.yaml
/options.yaml
/config.yaml
/logs/

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

187
docs/settings api.md Normal file
View File

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

View File

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

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

View File

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

772
settings.py Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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]]] = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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