Compare commits

..

5 Commits

Author SHA1 Message Date
Fabian Dill
37b77398a7 Merge branch 'main' into multiserver_data_store_datapackage 2024-06-03 15:27:37 +02:00
Fabian Dill
27ae7e081f Update MultiServer.py
Co-authored-by: Doug Hoskisson <beauxq@users.noreply.github.com>
2024-04-09 00:26:07 +02:00
Fabian Dill
add68329e6 MultiServer: make name groups also public 2023-12-04 22:00:10 +01:00
Fabian Dill
0768bc066a MultiServer: alternative data store based DataPackage retrieval 2023-12-04 21:51:26 +01:00
Fabian Dill
001fdfbe9f MultiServer: introduce Get keys that don't need auth 2023-12-04 21:50:10 +01:00
111 changed files with 618 additions and 2590 deletions

View File

@@ -40,10 +40,6 @@ jobs:
run: |
python -m pip install --upgrade pip
python setup.py build_exe --yes
if ( $? -eq $false ) {
Write-Error "setup.py failed!"
exit 1
}
$NAME="$(ls build | Select-String -Pattern 'exe')".Split('.',2)[1]
$ZIP_NAME="Archipelago_$NAME.7z"
echo "$NAME -> $ZIP_NAME"
@@ -53,6 +49,12 @@ jobs:
Rename-Item "exe.$NAME" Archipelago
7z a -mx=9 -mhe=on -ms "../dist/$ZIP_NAME" Archipelago
Rename-Item Archipelago "exe.$NAME" # inno_setup.iss expects the original name
- name: Store 7z
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
retention-days: 7 # keep for 7 days, should be enough
- name: Build Setup
run: |
& "${env:ProgramFiles(x86)}\Inno Setup 6\iscc.exe" inno_setup.iss /DNO_SIGNTOOL
@@ -63,38 +65,11 @@ jobs:
$contents = Get-ChildItem -Path setups/*.exe -Force -Recurse
$SETUP_NAME=$contents[0].Name
echo "SETUP_NAME=$SETUP_NAME" >> $Env:GITHUB_ENV
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store 7z
uses: actions/upload-artifact@v4
with:
name: ${{ env.ZIP_NAME }}
path: dist/${{ env.ZIP_NAME }}
compression-level: 0 # .7z is incompressible by zip
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
- name: Store Setup
uses: actions/upload-artifact@v4
with:
name: ${{ env.SETUP_NAME }}
path: setups/${{ env.SETUP_NAME }}
if-no-files-found: error
retention-days: 7 # keep for 7 days, should be enough
build-ubuntu2004:
@@ -135,7 +110,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - copy code above to release.yml -
@@ -143,36 +118,15 @@ jobs:
run: |
source venv/bin/activate
python setup.py build_exe --yes
- name: Check build loads expected worlds
shell: bash
run: |
cd build/exe*
mv Players/Templates/meta.yaml .
ls -1 Players/Templates | sort > setup-player-templates.txt
rm -R Players/Templates
timeout 30 ./ArchipelagoLauncher "Generate Template Options" || true
ls -1 Players/Templates | sort > generated-player-templates.txt
cmp setup-player-templates.txt generated-player-templates.txt \
|| diff setup-player-templates.txt generated-player-templates.txt
mv meta.yaml Players/Templates/
- name: Test Generate
shell: bash
run: |
cd build/exe*
cp Players/Templates/Clique.yaml Players/
timeout 30 ./ArchipelagoGenerate
- name: Store AppImage
uses: actions/upload-artifact@v4
with:
name: ${{ env.APPIMAGE_NAME }}
path: dist/${{ env.APPIMAGE_NAME }}
if-no-files-found: error
retention-days: 7
- name: Store .tar.gz
uses: actions/upload-artifact@v4
with:
name: ${{ env.TAR_NAME }}
path: dist/${{ env.TAR_NAME }}
compression-level: 0 # .gz is incompressible by zip
if-no-files-found: error
retention-days: 7

View File

@@ -1,54 +0,0 @@
# Run CMake / CTest C++ unit tests
name: ctest
on:
push:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
pull_request:
paths:
- '**.cc?'
- '**.cpp'
- '**.cxx'
- '**.hh?'
- '**.hpp'
- '**.hxx'
- '**.CMakeLists'
- '.github/workflows/ctest.yml'
jobs:
ctest:
runs-on: ${{ matrix.os }}
name: Test C++ ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: ilammy/msvc-dev-cmd@v1
if: startsWith(matrix.os,'windows')
- uses: Bacondish2023/setup-googletest@v1
with:
build-type: 'Release'
- name: Build tests
run: |
cd test/cpp
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release
ls
- name: Run tests
run: |
cd test/cpp
ctest --test-dir build/ -C Release --output-on-failure

View File

@@ -69,7 +69,7 @@ jobs:
echo -e "setup.py dist output:\n `ls dist`"
cd dist && export APPIMAGE_NAME="`ls *.AppImage`" && cd ..
export TAR_NAME="${APPIMAGE_NAME%.AppImage}.tar.gz"
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
# - code above copied from build.yml -

View File

@@ -24,7 +24,7 @@ on:
- '.github/workflows/unittests.yml'
jobs:
unit:
build:
runs-on: ${{ matrix.os }}
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
@@ -60,32 +60,3 @@ jobs:
- name: Unittests
run: |
pytest -n auto
hosting:
runs-on: ${{ matrix.os }}
name: Test hosting with ${{ matrix.python.version }} on ${{ matrix.os }}
strategy:
matrix:
os:
- ubuntu-latest
python:
- {version: '3.11'} # current
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python.version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python.version }}
- name: Install dependencies
run: |
python -m venv venv
source venv/bin/activate
python -m pip install --upgrade pip
python ModuleUpdate.py --yes --force --append "WebHostLib/requirements.txt"
- name: Test hosting
run: |
source venv/bin/activate
export PYTHONPATH=$(pwd)
python test/hosting/__main__.py

2
.gitignore vendored
View File

@@ -62,7 +62,6 @@ Output Logs/
/installdelete.iss
/data/user.kv
/datapackage
/custom_worlds
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -178,7 +177,6 @@ dmypy.json
cython_debug/
# Cython intermediates
_speedups.c
_speedups.cpp
_speedups.html

View File

@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
self.local_item_locations = {}
self.dragon_speed_info = {}
options = Utils.get_settings()
options = Utils.get_options()
self.display_msgs = options["adventure_options"]["display_msgs"]
async def server_auth(self, password_requested: bool = False):
@@ -102,7 +102,7 @@ class AdventureContext(CommonContext):
def on_package(self, cmd: str, args: dict):
if cmd == 'Connected':
self.locations_array = None
if Utils.get_settings()["adventure_options"].get("death_link", False):
if Utils.get_options()["adventure_options"].get("death_link", False):
self.set_deathlink = True
async_start(self.get_freeincarnates_used())
elif cmd == "RoomInfo":
@@ -415,8 +415,8 @@ async def atari_sync_task(ctx: AdventureContext):
async def run_game(romfile):
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
auto_start = Utils.get_options()["adventure_options"].get("rom_start", True)
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
if auto_start is True:
import webbrowser
webbrowser.open(romfile)

View File

@@ -493,11 +493,6 @@ class CommonContext:
"""Gets called before sending a Say to the server from the user.
Returned text is sent, or sending is aborted if None is returned."""
return text
def on_ui_command(self, text: str) -> None:
"""Gets called by kivy when the user executes a command starting with `/` or `!`.
The command processor is still called; this is just intended for command echoing."""
self.ui.print_json([{"text": text, "type": "color", "color": "orange"}])
def update_permissions(self, permissions: typing.Dict[str, int]):
for permission_name, permission_flag in permissions.items():

View File

@@ -483,15 +483,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
if panic_method == "swap":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=True,
name="Progression", single_player_placement=multiworld.players == 1)
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "raise":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False,
name="Progression", single_player_placement=multiworld.players == 1)
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
elif panic_method == "start_inventory":
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
swap=False, allow_partial=True,
name="Progression", single_player_placement=multiworld.players == 1)
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
if progitempool:
for item in progitempool:
logging.debug(f"Moved {item} to start_inventory to prevent fill failure.")

View File

@@ -1,12 +1,10 @@
from __future__ import annotations
import argparse
import copy
import logging
import os
import random
import string
import sys
import urllib.parse
import urllib.request
from collections import Counter
@@ -17,16 +15,21 @@ import ModuleUpdate
ModuleUpdate.update()
import copy
import Utils
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
from worlds.alttp.EntranceRandomizer import parse_arguments
from worlds.AutoWorld import AutoWorldRegister
from worlds import failed_world_loads
def mystery_argparse():
from settings import get_settings
settings = get_settings()
defaults = settings.generator
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,
@@ -38,7 +41,7 @@ def mystery_argparse():
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=settings.general_options.output_path,
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)
@@ -58,23 +61,20 @@ def mystery_argparse():
if not os.path.isabs(args.meta_file_path):
args.meta_file_path = os.path.join(args.player_files_path, args.meta_file_path)
args.plando: PlandoOptions = PlandoOptions.from_option_string(args.plando)
return args
return args, options
def get_seed_name(random_source) -> str:
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
def main(args=None):
# __name__ == "__main__" check so unittests that already imported worlds don't trip this.
if __name__ == "__main__" and "worlds" in sys.modules:
raise Exception("Worlds system should not be loaded before logging init.")
def main(args=None, callback=ERmain):
if not args:
args = mystery_argparse()
args, options = mystery_argparse()
else:
options = get_settings()
seed = get_seed(args.seed)
Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level)
random.seed(seed)
seed_name = get_seed_name(random)
@@ -143,9 +143,6 @@ def main(args=None):
raise Exception(f"No weights found. "
f"Provide a general weights file ({args.weights_file_path}) or individual player files. "
f"A mix is also permitted.")
from worlds.AutoWorld import AutoWorldRegister
from worlds.alttp.EntranceRandomizer import parse_arguments
erargs = parse_arguments(['--multi', str(args.multi)])
erargs.seed = seed
erargs.plando_options = args.plando
@@ -237,8 +234,7 @@ def main(args=None):
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
yaml.dump(important, f)
from Main import main as ERmain
return ERmain(erargs, seed)
return callback(erargs, seed)
def read_weights_yamls(path) -> Tuple[Any, ...]:
@@ -363,8 +359,6 @@ def update_weights(weights: dict, new_weights: dict, update_type: str, name: str
def roll_meta_option(option_key, game: str, category_dict: Dict) -> Any:
from worlds import AutoWorldRegister
if not game:
return get_choice(option_key, category_dict)
if game in AutoWorldRegister.world_types:
@@ -442,13 +436,10 @@ def handle_option(ret: argparse.Namespace, game_weights: dict, option_key: str,
except Exception as e:
raise Options.OptionError(f"Error generating option {option_key} in {ret.game}") from e
else:
from worlds import AutoWorldRegister
player_option.verify(AutoWorldRegister.world_types[ret.game], ret.name, plando_options)
def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.bosses):
from worlds import AutoWorldRegister
if "linked_options" in weights:
weights = roll_linked_options(weights)
@@ -475,7 +466,6 @@ def roll_settings(weights: dict, plando_options: PlandoOptions = PlandoOptions.b
ret.game = get_choice("game", weights)
if ret.game not in AutoWorldRegister.world_types:
from worlds import failed_world_loads
picks = Utils.get_fuzzy_results(ret.game, list(AutoWorldRegister.world_types) + failed_world_loads, limit=1)[0]
if picks[0] in failed_world_loads:
raise Exception(f"No functional world found to handle game {ret.game}. "

View File

@@ -19,7 +19,7 @@ import sys
import webbrowser
from os.path import isfile
from shutil import which
from typing import Callable, Sequence, Union, Optional
from typing import Sequence, Union, Optional
import Utils
import settings
@@ -160,12 +160,8 @@ def launch(exe, in_terminal=False):
subprocess.Popen(exe)
refresh_components: Optional[Callable[[], None]] = None
def run_gui():
from kvui import App, ContainerLayout, GridLayout, Button, Label, ScrollBox, Widget
from kivy.core.window import Window
from kivy.uix.image import AsyncImage
from kivy.uix.relativelayout import RelativeLayout
@@ -173,8 +169,11 @@ def run_gui():
base_title: str = "Archipelago Launcher"
container: ContainerLayout
grid: GridLayout
_tool_layout: Optional[ScrollBox] = None
_client_layout: Optional[ScrollBox] = None
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
def __init__(self, ctx=None):
self.title = self.base_title
@@ -182,7 +181,18 @@ def run_gui():
self.icon = r"data/icon.png"
super().__init__()
def _refresh_components(self) -> None:
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
tool_layout = ScrollBox()
tool_layout.layout.orientation = "vertical"
self.grid.add_widget(tool_layout)
client_layout = ScrollBox()
client_layout.layout.orientation = "vertical"
self.grid.add_widget(client_layout)
def build_button(component: Component) -> Widget:
"""
@@ -207,49 +217,14 @@ def run_gui():
return box_layout
return button
# clear before repopulating
assert self._tool_layout and self._client_layout, "must call `build` first"
tool_children = reversed(self._tool_layout.layout.children)
for child in tool_children:
self._tool_layout.layout.remove_widget(child)
client_children = reversed(self._client_layout.layout.children)
for child in client_children:
self._client_layout.layout.remove_widget(child)
_tools = {c.display_name: c for c in components if c.type == Type.TOOL}
_clients = {c.display_name: c for c in components if c.type == Type.CLIENT}
_adjusters = {c.display_name: c for c in components if c.type == Type.ADJUSTER}
_miscs = {c.display_name: c for c in components if c.type == Type.MISC}
for (tool, client) in itertools.zip_longest(itertools.chain(
_tools.items(), _miscs.items(), _adjusters.items()
), _clients.items()):
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
# column 1
if tool:
self._tool_layout.layout.add_widget(build_button(tool[1]))
tool_layout.layout.add_widget(build_button(tool[1]))
# column 2
if client:
self._client_layout.layout.add_widget(build_button(client[1]))
def build(self):
self.container = ContainerLayout()
self.grid = GridLayout(cols=2)
self.container.add_widget(self.grid)
self.grid.add_widget(Label(text="General", size_hint_y=None, height=40))
self.grid.add_widget(Label(text="Clients", size_hint_y=None, height=40))
self._tool_layout = ScrollBox()
self._tool_layout.layout.orientation = "vertical"
self.grid.add_widget(self._tool_layout)
self._client_layout = ScrollBox()
self._client_layout.layout.orientation = "vertical"
self.grid.add_widget(self._client_layout)
self._refresh_components()
global refresh_components
refresh_components = self._refresh_components
Window.bind(on_drop_file=self._on_drop_file)
client_layout.layout.add_widget(build_button(client[1]))
return self.container
@@ -260,14 +235,6 @@ def run_gui():
else:
launch(get_exe(button.component), button.component.cli)
def _on_drop_file(self, window: Window, filename: bytes, x: int, y: int) -> None:
""" When a patch file is dropped into the window, run the associated component. """
file, component = identify(filename.decode())
if file and component:
run_component(component, file)
else:
logging.warning(f"unable to identify component for {filename}")
def _stop(self, *largs):
# ran into what appears to be https://groups.google.com/g/kivy-users/c/saWDLoYCSZ4 with PyCharm.
# Closing the window explicitly cleans it up.
@@ -276,17 +243,10 @@ def run_gui():
Launcher().run()
# avoiding Launcher reference leak
# and don't try to do something with widgets after window closed
global refresh_components
refresh_components = None
def run_component(component: Component, *args):
if component.func:
component.func(*args)
if refresh_components:
refresh_components()
elif component.script_name:
subprocess.run([*get_exe(component.script_name), *args])
else:

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import argparse
import asyncio
import collections
import contextlib
import copy
import datetime
import functools
@@ -177,12 +176,12 @@ class Context:
location_name_groups: typing.Dict[str, typing.Dict[str, typing.Set[str]]]
all_item_and_group_names: typing.Dict[str, typing.Set[str]]
all_location_and_group_names: typing.Dict[str, typing.Set[str]]
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
non_hintable_names: typing.Dict[str, typing.Set[str]]
public_stored_data_keys: typing.Set[str] # keys that can be retrieved by a client that has not reached "auth" yet
spheres: typing.List[typing.Dict[int, typing.Set[int]]]
""" each sphere is { player: { location_id, ... } } """
logger: logging.Logger
def __init__(self, host: str, port: int, server_password: str, password: str, location_check_points: int,
hint_cost: int, item_cheat: bool, release_mode: str = "disabled", collect_mode="disabled",
remaining_mode: str = "disabled", auto_shutdown: typing.SupportsFloat = 0, compatibility: int = 2,
@@ -232,7 +231,7 @@ class Context:
self.embedded_blacklist = {"host", "port"}
self.client_ids: typing.Dict[typing.Tuple[int, int], datetime.datetime] = {}
self.auto_save_interval = 60 # in seconds
self.auto_saver_thread: typing.Optional[threading.Thread] = None
self.auto_saver_thread = None
self.save_dirty = False
self.tags = ['AP']
self.games: typing.Dict[int, str] = {}
@@ -254,6 +253,7 @@ class Context:
self.all_item_and_group_names = {}
self.all_location_and_group_names = {}
self.non_hintable_names = collections.defaultdict(frozenset)
self.public_stored_data_keys = set()
self._load_game_data()
@@ -269,11 +269,6 @@ class Context:
for world_name, world in worlds.AutoWorldRegister.world_types.items():
self.non_hintable_names[world_name] = world.hint_blacklist
for game_package in self.gamespackage.values():
# remove groups from data sent to clients
del game_package["item_name_groups"]
del game_package["location_name_groups"]
def _init_game_data(self):
for game_name, game_package in self.gamespackage.items():
if "checksum" in game_package:
@@ -480,10 +475,26 @@ class Context:
del data["location_name_groups"]
del data["item_name_groups"] # remove from data package, but keep in self.item_name_groups
self._init_game_data()
def _add_public_data_store_key(key: str, retriever: typing.Callable[[], typing.Any]):
"""Add key to read_data and also public_stored_data_keys, to allow retrieval before auth."""
self.public_stored_data_keys.add(key)
self.read_data[key] = retriever
for game_name, game_package in self.gamespackage.items():
_add_public_data_store_key(f"datapackage_checksum_{game_name}",
lambda lgame=game_name: self.checksums.get(lgame, None))
_add_public_data_store_key(f"item_name_to_id_{game_name}",
lambda lgame=game_name: self.gamespackage[lgame]["item_name_to_id"])
_add_public_data_store_key(f"location_name_to_id_{game_name}",
lambda lgame=game_name: self.gamespackage[lgame]["location_name_to_id"])
for game_name, data in self.item_name_groups.items():
self.read_data[f"item_name_groups_{game_name}"] = lambda lgame=game_name: self.item_name_groups[lgame]
_add_public_data_store_key(f"item_name_groups_{game_name}",
lambda lgame=game_name: self.item_name_groups[lgame])
for game_name, data in self.location_name_groups.items():
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
_add_public_data_store_key(f"location_name_groups_{game_name}",
lambda lgame=game_name: self.location_name_groups[lgame])
# sorted access spheres
self.spheres = decoded_obj.get("spheres", [])
@@ -1634,10 +1645,25 @@ def get_slot_points(ctx: Context, team: int, slot: int) -> int:
ctx.get_hint_cost(slot) * ctx.hints_used[team, slot])
async def process_get(ctx: Context, client: Client, args: dict, cmd: dict):
if "keys" not in args or not isinstance(args["keys"], list):
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Retrieve', "original_cmd": cmd}])
return
args["cmd"] = "Retrieved"
keys = args["keys"]
args["keys"] = {
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
ctx.stored_data.get(key, None)
for key in keys
}
await ctx.send_msgs(client, [args])
async def process_client_cmd(ctx: Context, client: Client, args: dict):
try:
cmd: str = args["cmd"]
except:
except Exception:
ctx.logger.exception(f"Could not get command from {args}")
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "cmd", "original_cmd": None,
"text": f"Could not get command from {args} at `cmd`"}])
@@ -1740,6 +1766,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await ctx.send_msgs(client, [{"cmd": "DataPackage",
"data": {"games": ctx.gamespackage}}])
elif cmd == "Get" and args.get("keys", None) and all(key in ctx.public_stored_data_keys for key in args["keys"]):
await process_get(ctx, client, args, cmd)
elif client.auth:
if cmd == "ConnectUpdate":
if not args:
@@ -1835,18 +1864,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
await ctx.send_encoded_msgs(bounceclient, msg)
elif cmd == "Get":
if "keys" not in args or type(args["keys"]) != list:
await ctx.send_msgs(client, [{'cmd': 'InvalidPacket', "type": "arguments",
"text": 'Retrieve', "original_cmd": cmd}])
return
args["cmd"] = "Retrieved"
keys = args["keys"]
args["keys"] = {
key: ctx.read_data.get(key[6:], lambda: None)() if key.startswith("_read_") else
ctx.stored_data.get(key, None)
for key in keys
}
await ctx.send_msgs(client, [args])
await process_get(ctx, client, args, cmd)
elif cmd == "Set":
if "key" not in args or args["key"].startswith("_read_") or \
@@ -1932,6 +1950,8 @@ class ServerCommandProcessor(CommonCommandProcessor):
def _cmd_exit(self) -> bool:
"""Shutdown the server"""
self.ctx.server.ws_server.close()
if self.ctx.shutdown_task:
self.ctx.shutdown_task.cancel()
self.ctx.exit_event.set()
return True
@@ -2289,8 +2309,7 @@ def parse_args() -> argparse.Namespace:
async def auto_shutdown(ctx, to_cancel=None):
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
await asyncio.sleep(ctx.auto_shutdown)
def inactivity_shutdown():
ctx.server.ws_server.close()
@@ -2310,8 +2329,7 @@ async def auto_shutdown(ctx, to_cancel=None):
if seconds < 0:
inactivity_shutdown()
else:
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
await asyncio.sleep(seconds)
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":

View File

@@ -198,8 +198,7 @@ class JSONtoTextParser(metaclass=HandlerMeta):
"slateblue": "6D8BE8",
"plum": "AF99EF",
"salmon": "FA8072",
"white": "FFFFFF",
"orange": "FF7700",
"white": "FFFFFF"
}
def __init__(self, ctx):

View File

@@ -553,7 +553,6 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri
f"Archipelago ({__version__}) logging initialized"
f" on {platform.platform()}"
f" running Python {sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
f"{' (frozen)' if is_frozen() else ''}"
)

View File

@@ -12,9 +12,6 @@ ModuleUpdate.update()
import Utils
import settings
if typing.TYPE_CHECKING:
from flask import Flask
Utils.local_path.cached_path = os.path.dirname(__file__) or "." # py3.8 is not abs. remove "." when dropping 3.8
settings.no_gui = True
configpath = os.path.abspath("config.yaml")
@@ -22,7 +19,7 @@ if not os.path.exists(configpath): # fall back to config.yaml in home
configpath = os.path.abspath(Utils.user_path('config.yaml'))
def get_app() -> "Flask":
def get_app():
from WebHostLib import register, cache, app as raw_app
from WebHostLib.models import db
@@ -58,7 +55,6 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
worlds[game] = world
base_target_path = Utils.local_path("WebHostLib", "static", "generated", "docs")
shutil.rmtree(base_target_path, ignore_errors=True)
for game, world in worlds.items():
# copy files from world's docs folder to the generated folder
target_path = os.path.join(base_target_path, game)

View File

@@ -5,6 +5,7 @@ from uuid import UUID
from flask import Blueprint, abort, url_for
import worlds.Files
from .. import cache
from ..models import Room, Seed
api_endpoints = Blueprint('api', __name__, url_prefix="/api")
@@ -48,4 +49,21 @@ def room_info(room: UUID):
}
from . import generate, user, datapackage # trigger registration
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package
from . import generate, user # trigger registration

View File

@@ -1,32 +0,0 @@
from flask import abort
from Utils import restricted_loads
from WebHostLib import cache
from WebHostLib.models import GameDataPackage
from . import api_endpoints
@api_endpoints.route('/datapackage')
@cache.cached()
def get_datapackage():
from worlds import network_data_package
return network_data_package
@api_endpoints.route('/datapackage/<string:checksum>')
@cache.memoize(timeout=3600)
def get_datapackage_by_checksum(checksum: str):
package = GameDataPackage.get(checksum=checksum)
if package:
return restricted_loads(package.data)
return abort(404)
@api_endpoints.route('/datapackage_checksum')
@cache.cached()
def get_datapackage_checksums():
from worlds import network_data_package
version_package = {
game: game_data["checksum"] for game, game_data in network_data_package["games"].items()
}
return version_package

View File

@@ -168,28 +168,17 @@ def get_random_port():
def get_static_server_data() -> dict:
import worlds
data = {
"non_hintable_names": {
world_name: world.hint_blacklist
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"gamespackage": {
world_name: {
key: value
for key, value in game_package.items()
if key not in ("item_name_groups", "location_name_groups")
}
for world_name, game_package in worlds.network_data_package["games"].items()
},
"item_name_groups": {
world_name: world.item_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"location_name_groups": {
world_name: world.location_name_groups
for world_name, world in worlds.AutoWorldRegister.world_types.items()
},
"non_hintable_names": {},
"gamespackage": worlds.network_data_package["games"],
"item_name_groups": {world_name: world.item_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
"location_name_groups": {world_name: world.location_name_groups for world_name, world in
worlds.AutoWorldRegister.world_types.items()},
}
for world_name, world in worlds.AutoWorldRegister.world_types.items():
data["non_hintable_names"][world_name] = world.hint_blacklist
return data
@@ -277,15 +266,12 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
ctx.logger.exception("Could not determine port. Likely hosting failure.")
with db_session:
ctx.auto_shutdown = Room.get(id=room_id).timeout
if ctx.saving:
setattr(asyncio.current_task(), "save", lambda: ctx._save(True))
ctx.shutdown_task = asyncio.create_task(auto_shutdown(ctx, []))
await ctx.shutdown_task
except (KeyboardInterrupt, SystemExit):
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
except Exception as e:
with db_session:
room = Room.get(id=room_id)
@@ -295,12 +281,8 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
else:
if ctx.saving:
ctx._save()
setattr(asyncio.current_task(), "save", None)
finally:
try:
ctx.save_dirty = False # make sure the saving thread does not write to DB after final wakeup
ctx.exit_event.set() # make sure the saving thread stops at some point
# NOTE: async saving should probably be an async task and could be merged with shutdown_task
with (db_session):
# ensure the Room does not spin up again on its own, minute of safety buffer
room = Room.get(id=room_id)
@@ -312,32 +294,13 @@ def run_server_process(name: str, ponyconfig: dict, static_server_data: dict,
rooms_shutting_down.put(room_id)
class Starter(threading.Thread):
_tasks: typing.List[asyncio.Future]
def __init__(self):
super().__init__()
self._tasks = []
def _done(self, task: asyncio.Future):
self._tasks.remove(task)
task.result()
def run(self):
while 1:
next_room = rooms_to_run.get(block=True, timeout=None)
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
self._tasks.append(task)
task.add_done_callback(self._done)
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
logging.info(f"Starting room {next_room} on {name}.")
starter = Starter()
starter.daemon = True
starter.start()
try:
loop.run_forever()
finally:
# save all tasks that want to be saved during shutdown
for task in asyncio.all_tasks(loop):
save: typing.Optional[typing.Callable[[], typing.Any]] = getattr(task, "save", None)
if save:
save()
loop.run_forever()

View File

@@ -1,10 +1,9 @@
flask>=3.0.3
werkzeug>=3.0.3
flask>=3.0.0
pony>=0.7.17
waitress>=3.0.0
Flask-Caching>=2.3.0
Flask-Compress>=1.15
Flask-Limiter>=3.7.0
waitress>=2.1.2
Flask-Caching>=2.1.0
Flask-Compress>=1.14
Flask-Limiter>=3.5.0
bokeh>=3.1.1; python_version <= '3.8'
bokeh>=3.4.1; python_version >= '3.9'
markupsafe>=2.1.5
bokeh>=3.3.2; python_version >= '3.9'
markupsafe>=2.1.3

View File

@@ -15,7 +15,7 @@ html {
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-word;
word-break: break-all;
}
#player-options #player-options-header h1 {
margin-bottom: 0;

View File

@@ -16,7 +16,7 @@ html{
border-radius: 8px;
padding: 1rem;
color: #eeffeb;
word-break: break-word;
word-break: break-all;
#player-options-header{
h1{

View File

@@ -1,9 +1,9 @@
{% macro Toggle(option_name, option) %}
<table>
<tbody>
{{ RangeRow(option_name, option, "No", "false", False, "true" if option.default else "false") }}
{{ RangeRow(option_name, option, "Yes", "true", False, "true" if option.default else "false") }}
{{ RandomRow(option_name, option) }}
{{ RangeRow(option_name, option, "No", "false") }}
{{ RangeRow(option_name, option, "Yes", "true") }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
@@ -18,14 +18,10 @@
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
{% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endfor %}
{{ RandomRow(option_name, option) }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
@@ -38,7 +34,7 @@
Normal range: {{ option.range_start }} - {{ option.range_end }}
{% if option.special_range_names %}
<br /><br />
The following values have special meanings, and may fall outside the normal range.
The following values has special meaning, and may fall outside the normal range.
<ul>
{% for name, value in option.special_range_names.items() %}
<li>{{ value }}: {{ name }}</li>
@@ -76,9 +72,7 @@
</div>
<table>
<tbody>
{% if option.default %}
{{ RangeRow(option_name, option, option.default, option.default) }}
{% endif %}
<!-- This table to be filled by JS -->
</tbody>
</table>
</div>
@@ -96,14 +90,10 @@
<tbody>
{% for id, name in option.name_lookup.items() %}
{% if name != 'random' %}
{% if option.default != 'random' %}
{{ RangeRow(option_name, option, option.get_option_name(id), name, False, name if option.default == id else None) }}
{% else %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
{% endif %}
{% endfor %}
{{ RandomRow(option_name, option) }}
{{ RandomRows(option_name, option) }}
</tbody>
</table>
{% endmacro %}
@@ -122,7 +112,7 @@
type="number"
id="{{ option_name }}-{{ item_name }}-qty"
name="{{ option_name }}||{{ item_name }}"
value="{{ option.default[item_name] if item_name in option.default else "0" }}"
value="0"
/>
</div>
{% endfor %}
@@ -131,14 +121,13 @@
{% macro OptionList(option_name, option) %}
<div class="list-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
{% for key in option.valid_keys|sort %}
<div class="list-entry">
<input
type="checkbox"
id="{{ option_name }}-{{ key }}"
name="{{ option_name }}||{{ key }}"
value="1"
checked="{{ "checked" if key in option.default else "" }}"
/>
<label for="{{ option_name }}-{{ key }}">
{{ key }}
@@ -194,7 +183,7 @@
{% macro OptionSet(option_name, option) %}
<div class="set-container">
{% for key in (option.valid_keys if option.valid_keys is ordered else option.valid_keys|sort) %}
{% for key in option.valid_keys|sort %}
<div class="set-entry">
<input type="checkbox" id="{{ option_name }}-{{ key }}" name="{{ option_name }}||{{ key }}" value="1" {{ "checked" if key in option.default }} />
<label for="{{ option_name }}-{{ key }}">{{ key }}</label>
@@ -211,17 +200,13 @@
</td>
{% endmacro %}
{% macro RandomRow(option_name, option, extra_column=False) %}
{{ RangeRow(option_name, option, "Random", "random") }}
{% endmacro %}
{% macro RandomRows(option_name, option, extra_column=False) %}
{% for key, value in {"Random": "random", "Random (Low)": "random-low", "Random (Middle)": "random-middle", "Random (High)": "random-high"}.items() %}
{{ RangeRow(option_name, option, key, value) }}
{% endfor %}
{% endmacro %}
{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
{% macro RangeRow(option_name, option, display_value, value, can_delete=False) %}
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
<td class="td-left">
<label for="{{ option_name }}||{{ value }}">
@@ -235,7 +220,7 @@
name="{{ option_name }}||{{ value }}"
min="0"
max="50"
{% if option.default == value or default_override == value %}
{% if option.default == value %}
value="25"
{% else %}
value="0"
@@ -244,7 +229,7 @@
</td>
<td class="td-right">
<span id="{{ option_name }}||{{ value }}-value">
{% if option.default == value or default_override == value %}
{% if option.default == value %}
25
{% else %}
0

View File

@@ -1,6 +1,5 @@
#cython: language_level=3
#distutils: language = c
#distutils: depends = intset.h
#distutils: language = c++
"""
Provides faster implementation of some core parts.
@@ -14,6 +13,7 @@ from cpython cimport PyObject
from typing import Any, Dict, Iterable, Iterator, Generator, Sequence, Tuple, TypeVar, Union, Set, List, TYPE_CHECKING
from cymem.cymem cimport Pool
from libc.stdint cimport int64_t, uint32_t
from libcpp.set cimport set as std_set
from collections import defaultdict
cdef extern from *:
@@ -31,27 +31,6 @@ ctypedef int64_t ap_id_t
cdef ap_player_t MAX_PLAYER_ID = 1000000 # limit the size of indexing array
cdef size_t INVALID_SIZE = <size_t>(-1) # this is all 0xff... adding 1 results in 0, but it's not negative
# configure INTSET for player
cdef extern from *:
"""
#define INTSET_NAME ap_player_set
#define INTSET_TYPE uint32_t // has to match ap_player_t
"""
# create INTSET for player
cdef extern from "intset.h":
"""
#undef INTSET_NAME
#undef INTSET_TYPE
"""
ctypedef struct ap_player_set:
pass
ap_player_set* ap_player_set_new(size_t bucket_count) nogil
void ap_player_set_free(ap_player_set* set) nogil
bint ap_player_set_add(ap_player_set* set, ap_player_t val) nogil
bint ap_player_set_contains(ap_player_set* set, ap_player_t val) nogil
cdef struct LocationEntry:
# layout is so that
@@ -206,7 +185,7 @@ cdef class LocationStore:
def find_item(self, slots: Set[int], seeked_item_id: int) -> Generator[Tuple[int, int, int, int, int], None, None]:
cdef ap_id_t item = seeked_item_id
cdef ap_player_t receiver
cdef ap_player_set* receivers
cdef std_set[ap_player_t] receivers
cdef size_t slot_count = len(slots)
if slot_count == 1:
# specialized implementation for single slot
@@ -218,20 +197,13 @@ cdef class LocationStore:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
elif slot_count:
# generic implementation with lookup in set
receivers = ap_player_set_new(min(1023, slot_count)) # limit top level struct to 16KB
if not receivers:
raise MemoryError()
try:
for receiver in slots:
if not ap_player_set_add(receivers, receiver):
raise MemoryError()
with nogil:
for entry in self.entries[:self.entry_count]:
if entry.item == item and ap_player_set_contains(receivers, entry.receiver):
with gil:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
finally:
ap_player_set_free(receivers)
for receiver in slots:
receivers.insert(receiver)
with nogil:
for entry in self.entries[:self.entry_count]:
if entry.item == item and receivers.count(entry.receiver):
with gil:
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
cdef ap_player_t receiver = slot

View File

@@ -1,10 +1,8 @@
# This file is used when doing pyximport
import os
# This file is required to get pyximport to work with C++.
# Switching from std::set to a pure C implementation is still on the table to simplify everything.
def make_ext(modname, pyxfilename):
from distutils.extension import Extension
return Extension(name=modname,
sources=[pyxfilename],
depends=["intset.h"],
include_dirs=[os.getcwd()],
language="c")
language='c++')

View File

@@ -13,7 +13,6 @@
plum: "AF99EF" # typically progression item
salmon: "FA8072" # typically trap item
white: "FFFFFF" # not used, if you want to change the generic text color change color in Label
orange: "FF7700" # Used for command echo
<Label>:
color: "FFFFFF"
<TabbedPanel>:

View File

@@ -70,7 +70,7 @@
/worlds/heretic/ @Daivuk
# Hollow Knight
/worlds/hk/ @BadMagic100 @qwint
/worlds/hk/ @BadMagic100 @ThePhar
# Hylics 2
/worlds/hylics2/ @TRPG0

View File

@@ -86,29 +86,17 @@ class ExampleWorld(World):
```
### Option Groups
Options may be categorized into groups for display on the WebHost. Option groups are displayed in the order specified
by your world on the player-options and weighted-options pages. In the generated template files, there will be a comment
with the group name at the beginning of each group of options. The `start_collapsed` Boolean only affects how the groups
appear on the WebHost, with the grouping being collapsed when this is `True`.
Options without a group name are categorized into a generic "Game Options" group, which is always the first group. If
every option for your world is in a group, this group will be removed. There is also an "Items & Location Options"
group, which is automatically created using certain specified `item_and_loc_options`. These specified options cannot be
removed from this group.
Both the "Game Options" and "Item & Location Options" groups can be overridden by creating your own groups with
those names, letting you add options to them and change whether they start collapsed. The "Item &
Location Options" group can also be moved to a different position in the group ordering, but "Game Options" will always
be first, regardless of where it is in your list.
Options may be categorized into groups for display on the WebHost. Option groups are displayed alphabetically on the
player-options and weighted-options pages. Options without a group name are categorized into a generic "Game Options"
group.
```python
from worlds.AutoWorld import WebWorld
from Options import OptionGroup
from . import Options
class MyWorldWeb(WebWorld):
option_groups = [
OptionGroup("Color Options", [
OptionGroup('Color Options', [
Options.ColorblindMode,
Options.FlashReduction,
Options.UIColors,
@@ -132,8 +120,7 @@ or if I need a boolean object, such as in my slot_data I can access it as:
start_with_sword = bool(self.options.starting_sword.value)
```
All numeric options (i.e. Toggle, Choice, Range) can be compared to integers, strings that match their attributes,
strings that match the option attributes after "option_" is stripped, and the attributes themselves. The option can
also be checked to see if it exists within a collection, but this will fail for a set of strings due to hashing.
strings that match the option attributes after "option_" is stripped, and the attributes themselves.
```python
# options.py
class Logic(Choice):
@@ -145,12 +132,6 @@ class Logic(Choice):
alias_extra_hard = 2
crazy = 4 # won't be listed as an option and only exists as an attribute on the class
class Weapon(Choice):
option_none = 0
option_sword = 1
option_bow = 2
option_hammer = 3
# __init__.py
from .options import Logic
@@ -164,16 +145,6 @@ elif self.options.logic == Logic.option_extreme:
do_extreme_things()
elif self.options.logic == "crazy":
do_insane_things()
# check if the current option is in a collection of integers using the class attributes
if self.options.weapon in {Weapon.option_bow, Weapon.option_sword}:
do_stuff()
# in order to make a set of strings work, we have to compare against current_key
elif self.options.weapon.current_key in {"none", "hammer"}:
do_something_else()
# though it's usually better to just use a tuple instead
elif self.options.weapon in ("none", "hammer"):
do_something_else()
```
## Generic Option Classes
These options are generically available to every game automatically, but can be overridden for slightly different

View File

@@ -87,14 +87,7 @@ Type: files; Name: "{app}\lib\worlds\_bizhawk.apworld"
Type: files; Name: "{app}\ArchipelagoLttPClient.exe"
Type: files; Name: "{app}\ArchipelagoPokemonClient.exe"
Type: files; Name: "{app}\data\lua\connector_pkmn_rb.lua"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy"
Type: dirifempty; Name: "{app}\lib\worlds\rogue-legacy"
Type: files; Name: "{app}\lib\worlds\sc2wol.apworld"
Type: filesandordirs; Name: "{app}\lib\worlds\sc2wol"
Type: dirifempty; Name: "{app}\lib\worlds\sc2wol"
Type: filesandordirs; Name: "{app}\lib\worlds\bk_sudoku"
Type: dirifempty; Name: "{app}\lib\worlds\bk_sudoku"
Type: files; Name: "{app}\ArchipelagoLauncher(DEBUG).exe"
Type: filesandordirs; Name: "{app}\lib\worlds\rogue-legacy*"
Type: filesandordirs; Name: "{app}\SNI\lua*"
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
#include "installdelete.iss"
@@ -216,11 +209,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata"; ValueData: "Arc
Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{app}\ArchipelagoServer.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: "";
Root: HKCR; Subkey: ".apworld"; ValueData: "{#MyAppName}worlddata"; Flags: uninsdeletevalue; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata"; ValueData: "Archipelago World Data"; Flags: uninsdeletekey; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\DefaultIcon"; ValueData: "{app}\ArchipelagoLauncher.exe,0"; ValueType: string; ValueName: "";
Root: HKCR; Subkey: "{#MyAppName}worlddata\shell\open\command"; ValueData: """{app}\ArchipelagoLauncher.exe"" ""%1""";
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueData: "Archipegalo Protocol"; Flags: uninsdeletekey;
Root: HKCR; Subkey: "archipelago"; ValueType: "string"; ValueName: "URL Protocol"; ValueData: "";
Root: HKCR; Subkey: "archipelago\DefaultIcon"; ValueType: "string"; ValueData: "{app}\ArchipelagoTextClient.exe,0";

135
intset.h
View File

@@ -1,135 +0,0 @@
/* A specialized unordered_set implementation for literals, where bucket_count
* is defined at initialization rather than increased automatically.
*/
#include <stddef.h>
#include <stdbool.h>
#include <stdlib.h>
#include <string.h>
#ifndef INTSET_NAME
#error "Please #define INTSET_NAME ... before including intset.h"
#endif
#ifndef INTSET_TYPE
#error "Please #define INTSET_TYPE ... before including intset.h"
#endif
/* macros to generate unique names from INTSET_NAME */
#ifndef INTSET_CONCAT
#define INTSET_CONCAT_(a, b) a ## b
#define INTSET_CONCAT(a, b) INTSET_CONCAT_(a, b)
#define INTSET_FUNC_(a, b) INTSET_CONCAT(a, _ ## b)
#endif
#define INTSET_FUNC(name) INTSET_FUNC_(INTSET_NAME, name)
#define INTSET_BUCKET INTSET_CONCAT(INTSET_NAME, Bucket)
#define INTSET_UNION INTSET_CONCAT(INTSET_NAME, Union)
#if defined(_MSC_VER)
#pragma warning(push)
#pragma warning(disable : 4200)
#endif
typedef struct {
size_t count;
union INTSET_UNION {
INTSET_TYPE val;
INTSET_TYPE *data;
} v;
} INTSET_BUCKET;
typedef struct {
size_t bucket_count;
INTSET_BUCKET buckets[];
} INTSET_NAME;
static INTSET_NAME *INTSET_FUNC(new)(size_t buckets)
{
size_t i, size;
INTSET_NAME *set;
if (buckets < 1)
buckets = 1;
if ((SIZE_MAX - sizeof(INTSET_NAME)) / sizeof(INTSET_BUCKET) < buckets)
return NULL;
size = sizeof(INTSET_NAME) + buckets * sizeof(INTSET_BUCKET);
set = (INTSET_NAME*)malloc(size);
if (!set)
return NULL;
memset(set, 0, size); /* gcc -fanalyzer does not understand this sets all buckets' count to 0 */
for (i = 0; i < buckets; i++) {
set->buckets[i].count = 0;
}
set->bucket_count = buckets;
return set;
}
static void INTSET_FUNC(free)(INTSET_NAME *set)
{
size_t i;
if (!set)
return;
for (i = 0; i < set->bucket_count; i++) {
if (set->buckets[i].count > 1)
free(set->buckets[i].v.data);
}
free(set);
}
static bool INTSET_FUNC(contains)(INTSET_NAME *set, INTSET_TYPE val)
{
size_t i;
INTSET_BUCKET* bucket = &set->buckets[(size_t)val % set->bucket_count];
if (bucket->count == 1)
return bucket->v.val == val;
for (i = 0; i < bucket->count; ++i) {
if (bucket->v.data[i] == val)
return true;
}
return false;
}
static bool INTSET_FUNC(add)(INTSET_NAME *set, INTSET_TYPE val)
{
INTSET_BUCKET* bucket;
if (INTSET_FUNC(contains)(set, val))
return true; /* ok */
bucket = &set->buckets[(size_t)val % set->bucket_count];
if (bucket->count == 0) {
bucket->v.val = val;
bucket->count = 1;
} else if (bucket->count == 1) {
INTSET_TYPE old = bucket->v.val;
bucket->v.data = (INTSET_TYPE*)malloc(2 * sizeof(INTSET_TYPE));
if (!bucket->v.data) {
bucket->v.val = old;
return false; /* error */
}
bucket->v.data[0] = old;
bucket->v.data[1] = val;
bucket->count = 2;
} else {
size_t new_bucket_size;
INTSET_TYPE* new_bucket_data;
new_bucket_size = (bucket->count + 1) * sizeof(INTSET_TYPE);
new_bucket_data = (INTSET_TYPE*)realloc(bucket->v.data, new_bucket_size);
if (!new_bucket_data)
return false; /* error */
bucket->v.data = new_bucket_data;
bucket->v.data[bucket->count++] = val;
}
return true; /* success */
}
#if defined(_MSC_VER)
#pragma warning(pop)
#endif
#undef INTSET_FUNC
#undef INTSET_BUCKET
#undef INTSET_UNION

62
kvui.py
View File

@@ -3,7 +3,6 @@ import logging
import sys
import typing
import re
from collections import deque
if sys.platform == "win32":
import ctypes
@@ -381,57 +380,6 @@ class ConnectBarTextInput(TextInput):
return super(ConnectBarTextInput, self).insert_text(s, from_undo=from_undo)
def is_command_input(string: str) -> bool:
return len(string) > 0 and string[0] in "/!"
class CommandPromptTextInput(TextInput):
MAXIMUM_HISTORY_MESSAGES = 50
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._command_history_index = -1
self._command_history: typing.Deque[str] = deque(maxlen=CommandPromptTextInput.MAXIMUM_HISTORY_MESSAGES)
def update_history(self, new_entry: str) -> None:
self._command_history_index = -1
if is_command_input(new_entry):
self._command_history.appendleft(new_entry)
def keyboard_on_key_down(
self,
window,
keycode: typing.Tuple[int, str],
text: typing.Optional[str],
modifiers: typing.List[str]
) -> bool:
"""
:param window: The kivy window object
:param keycode: A tuple of (keycode, keyname). Keynames are always lowercase
:param text: The text printed by this key, not accounting for modifiers, or `None` if no text.
Seems to pretty naively interpret the keycode as unicode, so numlock can return odd characters.
:param modifiers: A list of string modifiers, like `ctrl` or `numlock`
"""
if keycode[1] == 'up':
self._change_to_history_text_if_available(self._command_history_index + 1)
return True
if keycode[1] == 'down':
self._change_to_history_text_if_available(self._command_history_index - 1)
return True
return super().keyboard_on_key_down(window, keycode, text, modifiers)
def _change_to_history_text_if_available(self, new_index: int) -> None:
if new_index < -1:
return
if new_index >= len(self._command_history):
return
self._command_history_index = new_index
if new_index == -1:
self.text = ""
return
self.text = self._command_history[self._command_history_index]
class MessageBox(Popup):
class MessageBoxLabel(Label):
def __init__(self, **kwargs):
@@ -467,7 +415,7 @@ class GameManager(App):
self.commandprocessor = ctx.command_processor(ctx)
self.icon = r"data/icon.png"
self.json_to_kivy_parser = KivyJSONtoTextParser(ctx)
self.log_panels: typing.Dict[str, Widget] = {}
self.log_panels = {}
# keep track of last used command to autofill on click
self.last_autofillable_command = "hint"
@@ -551,7 +499,7 @@ class GameManager(App):
info_button = Button(size=(dp(100), dp(30)), text="Command:", size_hint_x=None)
info_button.bind(on_release=self.command_button_action)
bottom_layout.add_widget(info_button)
self.textinput = CommandPromptTextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
self.textinput.bind(on_text_validate=self.on_message)
self.textinput.text_validate_unfocus = False
bottom_layout.add_widget(self.textinput)
@@ -609,18 +557,14 @@ class GameManager(App):
self.ctx.exit_event.set()
def on_message(self, textinput: CommandPromptTextInput):
def on_message(self, textinput: TextInput):
try:
input_text = textinput.text.strip()
textinput.text = ""
textinput.update_history(input_text)
if self.ctx.input_requests > 0:
self.ctx.input_requests -= 1
self.ctx.input_queue.put_nowait(input_text)
elif is_command_input(input_text):
self.ctx.on_ui_command(input_text)
self.commandprocessor(input_text)
elif input_text:
self.commandprocessor(input_text)

View File

@@ -2,13 +2,13 @@ colorama>=0.4.6
websockets>=12.0
PyYAML>=6.0.1
jellyfish>=1.0.3
jinja2>=3.1.4
schema>=0.7.7
jinja2>=3.1.3
schema>=0.7.5
kivy>=2.3.0
bsdiff4>=1.2.4
platformdirs>=4.2.2
certifi>=2024.6.2
cython>=3.0.10
platformdirs>=4.1.0
certifi>=2023.11.17
cython>=3.0.8
cymem>=2.0.8
orjson>=3.10.3
typing_extensions>=4.12.1
orjson>=3.9.10
typing_extensions>=4.7.0

View File

@@ -190,7 +190,7 @@ if is_windows:
c = next(component for component in components if component.script_name == "Launcher")
exes.append(cx_Freeze.Executable(
script=f"{c.script_name}.py",
target_name=f"{c.frozen_name}Debug.exe",
target_name=f"{c.frozen_name}(DEBUG).exe",
icon=resolve_icon(c.icon),
))

View File

@@ -1,49 +0,0 @@
cmake_minimum_required(VERSION 3.5)
project(ap-cpp-tests)
enable_testing()
find_package(GTest REQUIRED)
if (CMAKE_CXX_COMPILER_ID STREQUAL "MSVC")
add_definitions("/source-charset:utf-8")
set(CMAKE_CXX_FLAGS_DEBUG "/MTd")
set(CMAKE_CXX_FLAGS_RELEASE "/MT")
elseif (CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
# enable static analysis for gcc
add_compile_options(-fanalyzer -Werror)
# disable stuff that gets triggered by googletest
add_compile_options(-Wno-analyzer-malloc-leak)
# enable asan for gcc
add_compile_options(-fsanitize=address)
add_link_options(-fsanitize=address)
endif ()
add_executable(test_default)
target_include_directories(test_default
PRIVATE
${GTEST_INCLUDE_DIRS}
)
target_link_libraries(test_default
${GTEST_BOTH_LIBRARIES}
)
add_test(
NAME test_default
COMMAND test_default
)
set_property(
TEST test_default
PROPERTY ENVIRONMENT "ASAN_OPTIONS=allocator_may_return_null=1"
)
file(GLOB ITEMS *)
foreach(item ${ITEMS})
if(IS_DIRECTORY ${item} AND EXISTS ${item}/CMakeLists.txt)
message(${item})
add_subdirectory(${item})
endif()
endforeach()

View File

@@ -1,32 +0,0 @@
# C++ tests
Test framework for C and C++ code in AP.
## Adding a Test
### GoogleTest
Adding GoogleTests is as simple as creating a directory with
* one or more `test_*.cpp` files that define tests using
[GoogleTest API](https://google.github.io/googletest/)
* a `CMakeLists.txt` that adds the .cpp files to `test_default` target using
[target_sources](https://cmake.org/cmake/help/latest/command/target_sources.html)
### CTest
If either GoogleTest is not suitable for the test or the build flags / sources / libraries are incompatible,
you can add another CTest to the project using add_target and add_test, similar to how it's done for `test_default`.
## Running Tests
* Install [CMake](https://cmake.org/).
* Build and/or install GoogleTest and make sure
[CMake can find it](https://cmake.org/cmake/help/latest/module/FindGTest.html), or
[create a parent `CMakeLists.txt` that fetches GoogleTest](https://google.github.io/googletest/quickstart-cmake.html).
* Enter the directory with the top-most `CMakeLists.txt` and run
```sh
mkdir build
cmake -S . -B build/ -DCMAKE_BUILD_TYPE=Release
cmake --build build/ --config Release && \
ctest --test-dir build/ -C Release --output-on-failure
```

View File

@@ -1,4 +0,0 @@
target_sources(test_default
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
)

View File

@@ -1,105 +0,0 @@
#include <limits>
#include <cstdint>
#include <gtest/gtest.h>
// uint32Set
#define INTSET_NAME uint32Set
#define INTSET_TYPE uint32_t
#include "../../../intset.h"
#undef INTSET_NAME
#undef INTSET_TYPE
// int64Set
#define INTSET_NAME int64Set
#define INTSET_TYPE int64_t
#include "../../../intset.h"
TEST(IntsetTest, ZeroBuckets)
{
// trying to allocate with zero buckets has to either fail or be functioning
uint32Set *set = uint32Set_new(0);
if (!set)
return; // failed -> OK
EXPECT_FALSE(uint32Set_contains(set, 1));
EXPECT_TRUE(uint32Set_add(set, 1));
EXPECT_TRUE(uint32Set_contains(set, 1));
uint32Set_free(set);
}
TEST(IntsetTest, Duplicate)
{
// adding the same number again can't fail
uint32Set *set = uint32Set_new(2);
ASSERT_TRUE(set);
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_contains(set, 0));
uint32Set_free(set);
}
TEST(IntsetTest, SetAllocFailure)
{
// try to allocate 100TB of RAM, should fail and return NULL
if (sizeof(size_t) < 8)
GTEST_SKIP() << "Alloc error not testable on 32bit";
int64Set *set = int64Set_new(6250000000000ULL);
EXPECT_FALSE(set);
int64Set_free(set);
}
TEST(IntsetTest, SetAllocOverflow)
{
// try to overflow argument passed to malloc
int64Set *set = int64Set_new(std::numeric_limits<size_t>::max());
EXPECT_FALSE(set);
int64Set_free(set);
}
TEST(IntsetTest, NullFree)
{
// free(NULL) should not try to free buckets
uint32Set_free(NULL);
int64Set_free(NULL);
}
TEST(IntsetTest, BucketRealloc)
{
// add a couple of values to the same bucket to test growing the bucket
uint32Set* set = uint32Set_new(1);
ASSERT_TRUE(set);
EXPECT_FALSE(uint32Set_contains(set, 0));
EXPECT_TRUE(uint32Set_add(set, 0));
EXPECT_TRUE(uint32Set_contains(set, 0));
for (uint32_t i = 1; i < 32; ++i) {
EXPECT_TRUE(uint32Set_add(set, i));
EXPECT_TRUE(uint32Set_contains(set, i - 1));
EXPECT_TRUE(uint32Set_contains(set, i));
EXPECT_FALSE(uint32Set_contains(set, i + 1));
}
uint32Set_free(set);
}
TEST(IntSet, Max)
{
constexpr auto n = std::numeric_limits<uint32_t>::max();
uint32Set *set = uint32Set_new(1);
ASSERT_TRUE(set);
EXPECT_FALSE(uint32Set_contains(set, n));
EXPECT_TRUE(uint32Set_add(set, n));
EXPECT_TRUE(uint32Set_contains(set, n));
uint32Set_free(set);
}
TEST(InsetTest, Negative)
{
constexpr auto n = std::numeric_limits<int64_t>::min();
static_assert(n < 0, "n not negative");
int64Set *set = int64Set_new(3);
ASSERT_TRUE(set);
EXPECT_FALSE(int64Set_contains(set, n));
EXPECT_TRUE(int64Set_add(set, n));
EXPECT_TRUE(int64Set_contains(set, n));
int64Set_free(set);
}

View File

@@ -2,7 +2,6 @@ from argparse import Namespace
from typing import List, Optional, Tuple, Type, Union
from BaseClasses import CollectionState, Item, ItemClassification, Location, MultiWorld, Region
from worlds import network_data_package
from worlds.AutoWorld import World, call_all
gen_steps = ("generate_early", "create_regions", "create_items", "set_rules", "generate_basic", "pre_fill")
@@ -61,10 +60,6 @@ class TestWorld(World):
hidden = True
# add our test world to the data package, so we can test it later
network_data_package["games"][TestWorld.game] = TestWorld.get_data_package_data()
def generate_test_multiworld(players: int = 1) -> MultiWorld:
"""
Generates a multiworld using a special Test Case World class, and seed of 0.

View File

@@ -1,7 +1,6 @@
import unittest
from Fill import distribute_items_restrictive
from worlds import network_data_package
from worlds.AutoWorld import AutoWorldRegister, call_all
from . import setup_solo_multiworld
@@ -85,4 +84,3 @@ class TestIDs(unittest.TestCase):
f"{loc_name} is not a valid item name for location_name_to_id")
self.assertIsInstance(loc_id, int,
f"{loc_id} for {loc_name} should be an int")
self.assertEqual(datapackage["checksum"], network_data_package["games"][gamename]["checksum"])

View File

@@ -1,191 +0,0 @@
# A bunch of tests to verify MultiServer and custom webhost server work as expected.
# This spawns processes and may modify your local AP, so this is not run as part of unit testing.
# Run with `python test/hosting` instead,
import logging
import traceback
from tempfile import TemporaryDirectory
from time import sleep
from typing import Any
from test.hosting.client import Client
from test.hosting.generate import generate_local
from test.hosting.serve import ServeGame, LocalServeGame, WebHostServeGame
from test.hosting.webhost import (create_room, get_app, get_multidata_for_room, set_multidata_for_room, start_room,
stop_autohost, upload_multidata)
from test.hosting.world import copy as copy_world, delete as delete_world
failure = False
fail_fast = True
def assert_true(condition: Any, msg: str = "") -> None:
global failure
if not condition:
failure = True
msg = f": {msg}" if msg else ""
raise AssertionError(f"Assertion failed{msg}")
def assert_equal(first: Any, second: Any, msg: str = "") -> None:
global failure
if first != second:
failure = True
msg = f": {msg}" if msg else ""
raise AssertionError(f"Assertion failed: {first} == {second}{msg}")
if fail_fast:
expect_true = assert_true
expect_equal = assert_equal
else:
def expect_true(condition: Any, msg: str = "") -> None:
global failure
if not condition:
failure = True
tb = "".join(traceback.format_stack()[:-1])
msg = f": {msg}" if msg else ""
logging.error(f"Expectation failed{msg}\n{tb}")
def expect_equal(first: Any, second: Any, msg: str = "") -> None:
global failure
if first != second:
failure = True
tb = "".join(traceback.format_stack()[:-1])
msg = f": {msg}" if msg else ""
logging.error(f"Expectation failed {first} == {second}{msg}\n{tb}")
if __name__ == "__main__":
import warnings
warnings.simplefilter("ignore", ResourceWarning)
warnings.simplefilter("ignore", UserWarning)
spacer = '=' * 80
with TemporaryDirectory() as tempdir:
multis = [["Clique"], ["Temp World"], ["Clique", "Temp World"]]
p1_games = []
data_paths = []
rooms = []
copy_world("Clique", "Temp World")
try:
for n, games in enumerate(multis, 1):
print(f"Generating [{n}] {', '.join(games)}")
multidata = generate_local(games, tempdir)
print(f"Generated [{n}] {', '.join(games)} as {multidata}\n")
p1_games.append(games[0])
data_paths.append(multidata)
finally:
delete_world("Temp World")
webapp = get_app(tempdir)
webhost_client = webapp.test_client()
for n, multidata in enumerate(data_paths, 1):
seed = upload_multidata(webhost_client, multidata)
room = create_room(webhost_client, seed)
print(f"Uploaded [{n}] {multidata} as {room}\n")
rooms.append(room)
print("Starting autohost")
from WebHostLib.autolauncher import autohost
try:
autohost(webapp.config)
host: ServeGame
for n, (multidata, room, game, multi_games) in enumerate(zip(data_paths, rooms, p1_games, multis), 1):
involved_games = {"Archipelago"} | set(multi_games)
for collected_items in range(3):
print(f"\nTesting [{n}] {game} in {multidata} on MultiServer with {collected_items} items collected")
with LocalServeGame(multidata) as host:
with Client(host.address, game, "Player1") as client:
local_data_packages = client.games_packages
local_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
client.collect_any()
# TODO: Ctrl+C test here as well
for game_name in sorted(involved_games):
expect_true(game_name in local_data_packages,
f"{game_name} missing from MultiServer datap ackage")
expect_true("item_name_groups" not in local_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in MultiServer data for {game_name}")
expect_true("location_name_groups" not in local_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in MultiServer data for {game_name}")
for game_name in local_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from MultiServer")
assert_equal(local_collected_items, collected_items,
"MultiServer did not load or save correctly")
print(f"\nTesting [{n}] {game} in {multidata} on customserver with {collected_items} items collected")
prev_host_adr: str
with WebHostServeGame(webhost_client, room) as host:
prev_host_adr = host.address
with Client(host.address, game, "Player1") as client:
web_data_packages = client.games_packages
web_collected_items = len(client.checked_locations)
if collected_items < 2: # Clique only has 2 Locations
client.collect_any()
if collected_items == 1:
sleep(1) # wait for the server to collect the item
stop_autohost(True) # simulate Ctrl+C
sleep(3)
autohost(webapp.config) # this will spin the room right up again
sleep(1) # make log less annoying
# if saving failed, the next iteration will fail below
# verify server shut down
try:
with Client(prev_host_adr, game, "Player1") as client:
assert_true(False, "Server did not shut down")
except ConnectionError:
pass
for game_name in sorted(involved_games):
expect_true(game_name in web_data_packages,
f"{game_name} missing from customserver data package")
expect_true("item_name_groups" not in web_data_packages.get(game_name, {}),
f"item_name_groups are not supposed to be in customserver data for {game_name}")
expect_true("location_name_groups" not in web_data_packages.get(game_name, {}),
f"location_name_groups are not supposed to be in customserver data for {game_name}")
for game_name in web_data_packages:
expect_true(game_name in involved_games,
f"Received unexpected extra data package for {game_name} from customserver")
assert_equal(web_collected_items, collected_items,
"customserver did not load or save correctly during/after "
+ ("Ctrl+C" if collected_items == 2 else "/exit"))
# compare customserver to MultiServer
expect_equal(local_data_packages, web_data_packages,
"customserver datapackage differs from MultiServer")
sleep(5.5) # make sure all tasks actually stopped
# raise an exception in customserver and verify the save doesn't get destroyed
# local variables room is the last room's id here
old_data = get_multidata_for_room(webhost_client, room)
print(f"Destroying multidata for {room}")
set_multidata_for_room(webhost_client, room, bytes([0]))
try:
start_room(webhost_client, room, timeout=7)
except TimeoutError:
pass
else:
assert_true(False, "Room started with destroyed multidata")
print(f"Restoring multidata for {room}")
set_multidata_for_room(webhost_client, room, old_data)
with WebHostServeGame(webhost_client, room) as host:
with Client(host.address, game, "Player1") as client:
assert_equal(len(client.checked_locations), 2,
"Save was destroyed during exception in customserver")
print("Save file is not busted 🥳")
finally:
print("Stopping autohost")
stop_autohost(False)
if failure:
print("Some tests failed")
exit(1)
exit(0)

View File

@@ -1,110 +0,0 @@
import json
import sys
from typing import Any, Collection, Dict, Iterable, Optional
from websockets import ConnectionClosed
from websockets.sync.client import connect, ClientConnection
from threading import Thread
__all__ = [
"Client"
]
class Client:
"""Incomplete, minimalistic sync test client for AP network protocol"""
recv_timeout = 1.0
host: str
game: str
slot: str
password: Optional[str]
_ws: Optional[ClientConnection]
games: Iterable[str]
data_package_checksums: Dict[str, Any]
games_packages: Dict[str, Any]
missing_locations: Collection[int]
checked_locations: Collection[int]
def __init__(self, host: str, game: str, slot: str, password: Optional[str] = None) -> None:
self.host = host
self.game = game
self.slot = slot
self.password = password
self._ws = None
self.games = []
self.data_package_checksums = {}
self.games_packages = {}
self.missing_locations = []
self.checked_locations = []
def __enter__(self) -> "Client":
try:
self.connect()
except BaseException:
self.__exit__(*sys.exc_info())
raise
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
self.close()
def _poll(self) -> None:
assert self._ws
try:
while True:
self._ws.recv()
except (TimeoutError, ConnectionClosed, KeyboardInterrupt, SystemExit):
pass
def connect(self) -> None:
self._ws = connect(f"ws://{self.host}")
room_info = json.loads(self._ws.recv(self.recv_timeout))[0]
self.games = sorted(room_info["games"])
self.data_package_checksums = room_info["datapackage_checksums"]
self._ws.send(json.dumps([{
"cmd": "GetDataPackage",
"games": list(self.games),
}]))
data_package_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
self.games_packages = data_package_msg["data"]["games"]
self._ws.send(json.dumps([{
"cmd": "Connect",
"game": self.game,
"name": self.slot,
"password": self.password,
"uuid": "",
"version": {
"class": "Version",
"major": 0,
"minor": 4,
"build": 6,
},
"items_handling": 0,
"tags": [],
"slot_data": False,
}]))
connect_result_msg = json.loads(self._ws.recv(self.recv_timeout))[0]
if connect_result_msg["cmd"] != "Connected":
raise ConnectionError(", ".join(connect_result_msg.get("errors", [connect_result_msg["cmd"]])))
self.missing_locations = connect_result_msg["missing_locations"]
self.checked_locations = connect_result_msg["checked_locations"]
def close(self) -> None:
if self._ws:
Thread(target=self._poll).start()
self._ws.close()
def collect(self, locations: Iterable[int]) -> None:
if not self._ws:
raise ValueError("Not connected")
self._ws.send(json.dumps([{
"cmd": "LocationChecks",
"locations": locations,
}]))
def collect_any(self) -> None:
self.collect([next(iter(self.missing_locations))])

View File

@@ -1,75 +0,0 @@
import json
import sys
import warnings
from pathlib import Path
from typing import Iterable, Union, TYPE_CHECKING
if TYPE_CHECKING:
from multiprocessing.managers import ListProxy # noqa
__all__ = [
"generate_local",
]
def _generate_local_inner(games: Iterable[str],
dest: Union[Path, str],
results: "ListProxy[Union[Path, BaseException]]") -> None:
original_argv = sys.argv
warnings.simplefilter("ignore")
try:
from tempfile import TemporaryDirectory
if not isinstance(dest, Path):
dest = Path(dest)
with TemporaryDirectory() as players_dir:
with TemporaryDirectory() as output_dir:
import Generate
for n, game in enumerate(games, 1):
player_path = Path(players_dir) / f"{n}.yaml"
with open(player_path, "w", encoding="utf-8") as f:
f.write(json.dumps({
"name": f"Player{n}",
"game": game,
game: {"hard_mode": "true"},
"description": f"generate_local slot {n} ('Player{n}'): {game}",
}))
# this is basically copied from test/programs/test_generate.py
# uses a reproducible seed that is different for each set of games
sys.argv = [sys.argv[0], "--seed", str(hash(tuple(games))),
"--player_files_path", players_dir,
"--outputpath", output_dir]
Generate.main()
output_files = list(Path(output_dir).glob('*.zip'))
assert len(output_files) == 1
final_file = dest / output_files[0].name
output_files[0].rename(final_file)
results.append(final_file)
except BaseException as e:
results.append(e)
raise e
finally:
sys.argv = original_argv
def generate_local(games: Iterable[str], dest: Union[Path, str]) -> Path:
from multiprocessing import Manager, Process, set_start_method
try:
set_start_method("spawn")
except RuntimeError:
pass
manager = Manager()
results: "ListProxy[Union[Path, Exception]]" = manager.list()
p = Process(target=_generate_local_inner, args=(games, dest, results))
p.start()
p.join()
result = results[0]
if isinstance(result, BaseException):
raise Exception("Could not generate multiworld") from result
return result

View File

@@ -1,115 +0,0 @@
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from threading import Event
from werkzeug.test import Client as FlaskClient
__all__ = [
"ServeGame",
"LocalServeGame",
"WebHostServeGame",
]
class ServeGame:
address: str
def _launch_multiserver(multidata: Path, ready: "Event", stop: "Event") -> None:
import os
import warnings
original_argv = sys.argv
original_stdin = sys.stdin
warnings.simplefilter("ignore")
try:
import asyncio
from MultiServer import main, parse_args
sys.argv = [sys.argv[0], str(multidata), "--host", "127.0.0.1"]
r, w = os.pipe()
sys.stdin = os.fdopen(r, "r")
async def set_ready() -> None:
await asyncio.sleep(.01) # switch back to other task once more
ready.set() # server should be up, set ready state
async def wait_stop() -> None:
await asyncio.get_event_loop().run_in_executor(None, stop.wait)
os.fdopen(w, "w").write("/exit")
async def run() -> None:
# this will run main() until first await, then switch to set_ready()
await asyncio.gather(
main(parse_args()),
set_ready(),
wait_stop(),
)
asyncio.run(run())
finally:
sys.argv = original_argv
sys.stdin = original_stdin
class LocalServeGame(ServeGame):
from multiprocessing import Process
_multidata: Path
_proc: Process
_stop: "Event"
def __init__(self, multidata: Path) -> None:
self.address = ""
self._multidata = multidata
def __enter__(self) -> "LocalServeGame":
from multiprocessing import Manager, Process, set_start_method
try:
set_start_method("spawn")
except RuntimeError:
pass
manager = Manager()
ready: "Event" = manager.Event()
self._stop = manager.Event()
self._proc = Process(target=_launch_multiserver, args=(self._multidata, ready, self._stop))
try:
self._proc.start()
ready.wait(30)
self.address = "localhost:38281"
return self
except BaseException:
self.__exit__(*sys.exc_info())
raise
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
try:
self._stop.set()
self._proc.join(30)
except TimeoutError:
self._proc.terminate()
self._proc.join()
class WebHostServeGame(ServeGame):
_client: "FlaskClient"
_room: str
def __init__(self, app_client: "FlaskClient", room: str) -> None:
self.address = ""
self._client = app_client
self._room = room
def __enter__(self) -> "WebHostServeGame":
from .webhost import start_room
self.address = start_room(self._client, self._room)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None: # type: ignore
from .webhost import stop_room
stop_room(self._client, self._room, timeout=30)

View File

@@ -1,208 +0,0 @@
import re
from pathlib import Path
from typing import TYPE_CHECKING, Optional, cast
if TYPE_CHECKING:
from flask import Flask
from werkzeug.test import Client as FlaskClient
__all__ = [
"get_app",
"upload_multidata",
"create_room",
"start_room",
"stop_room",
"set_room_timeout",
"get_multidata_for_room",
"set_multidata_for_room",
"stop_autohost",
]
def get_app(tempdir: str) -> "Flask":
from WebHostLib import app as raw_app
from WebHost import get_app
raw_app.config["PONY"] = {
"provider": "sqlite",
"filename": str(Path(tempdir) / "host.db"),
"create_db": True,
}
raw_app.config.update({
"TESTING": True,
"HOST_ADDRESS": "localhost",
"HOSTERS": 1,
})
return get_app()
def upload_multidata(app_client: "FlaskClient", multidata: Path) -> str:
response = app_client.post("/uploads", data={
"file": multidata.open("rb"),
})
assert response.status_code < 400, f"Upload of {multidata} failed: status {response.status_code}"
assert "Location" in response.headers, f"Upload of {multidata} failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/seed/"), f"Upload of {multidata} failed: unexpected redirect"
return location[6:]
def create_room(app_client: "FlaskClient", seed: str, auto_start: bool = False) -> str:
response = app_client.get(f"/new_room/{seed}")
assert response.status_code < 400, f"Creating room for {seed} failed: status {response.status_code}"
assert "Location" in response.headers, f"Creating room for {seed} failed: no redirect"
location = response.headers["Location"]
assert isinstance(location, str)
assert location.startswith("/room/"), f"Creating room for {seed} failed: unexpected redirect"
room_id = location[6:]
if not auto_start:
# by default, creating a room will auto-start it, so we update last activity here
stop_room(app_client, room_id, simulate_idle=False)
return room_id
def start_room(app_client: "FlaskClient", room_id: str, timeout: float = 30) -> str:
from time import sleep
import pony.orm
poll_interval = .2
print(f"Starting room {room_id}")
no_timeout = timeout <= 0
while no_timeout or timeout > 0:
try:
response = app_client.get(f"/room/{room_id}")
except pony.orm.core.OptimisticCheckError:
# hoster wrote to room during our transaction
continue
assert response.status_code == 200, f"Starting room for {room_id} failed: status {response.status_code}"
match = re.search(r"/connect ([\w:.\-]+)", response.text)
if match:
return match[1]
timeout -= poll_interval
sleep(poll_interval)
raise TimeoutError("Room did not start")
def stop_room(app_client: "FlaskClient",
room_id: str,
timeout: Optional[float] = None,
simulate_idle: bool = True) -> None:
from datetime import datetime, timedelta
from time import sleep
from pony.orm import db_session
from WebHostLib.models import Command, Room
from WebHostLib import app
poll_interval = 2
print(f"Stopping room {room_id}")
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
if timeout is not None:
sleep(.1) # should not be required, but other things might use threading
with db_session:
room: Room = Room.get(id=room_uuid)
if simulate_idle:
new_last_activity = datetime.utcnow() - timedelta(seconds=room.timeout + 5)
else:
new_last_activity = datetime.utcnow() - timedelta(days=3)
room.last_activity = new_last_activity
address = f"localhost:{room.last_port}" if room.last_port > 0 else None
if address:
original_timeout = room.timeout
room.timeout = 1 # avoid spinning it up again
Command(room=room, commandtext="/exit")
try:
if address and timeout is not None:
print("waiting for shutdown")
import socket
host_str, port_str = tuple(address.split(":"))
address_tuple = host_str, int(port_str)
no_timeout = timeout <= 0
while no_timeout or timeout > 0:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect(address_tuple)
s.close()
except ConnectionRefusedError:
return
sleep(poll_interval)
timeout -= poll_interval
raise TimeoutError("Room did not stop")
finally:
with db_session:
room = Room.get(id=room_uuid)
room.last_port = 0 # easier to detect when the host is up this way
if address:
room.timeout = original_timeout
room.last_activity = new_last_activity
print("timeout restored")
def set_room_timeout(room_id: str, timeout: float) -> None:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
room.timeout = timeout
def get_multidata_for_room(webhost_client: "FlaskClient", room_id: str) -> bytes:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
return cast(bytes, room.seed.multidata)
def set_multidata_for_room(webhost_client: "FlaskClient", room_id: str, data: bytes) -> None:
from pony.orm import db_session
from WebHostLib.models import Room
from WebHostLib import app
room_uuid = app.url_map.converters["suuid"].to_python(None, room_id) # type: ignore[arg-type]
with db_session:
room: Room = Room.get(id=room_uuid)
room.seed.multidata = data
def stop_autohost(graceful: bool = True) -> None:
import os
import signal
import multiprocessing
from WebHostLib.autolauncher import stop
stop()
proc: multiprocessing.process.BaseProcess
for proc in filter(lambda child: child.name.startswith("MultiHoster"), multiprocessing.active_children()):
if graceful and proc.pid:
os.kill(proc.pid, getattr(signal, "CTRL_C_EVENT", signal.SIGINT))
else:
proc.kill()
try:
proc.join(30)
except TimeoutError:
proc.kill()
proc.join()

View File

@@ -1,42 +0,0 @@
import re
import shutil
from pathlib import Path
from typing import Dict
__all__ = ["copy", "delete"]
_new_worlds: Dict[str, str] = {}
def copy(src: str, dst: str) -> None:
from Utils import get_file_safe_name
from worlds import AutoWorldRegister
assert dst not in _new_worlds, "World already created"
if '"' in dst or "\\" in dst: # easier to reject than to escape
raise ValueError(f"Unsupported symbols in {dst}")
dst_folder_name = get_file_safe_name(dst.lower())
src_cls = AutoWorldRegister.world_types[src]
src_folder = Path(src_cls.__file__).parent
worlds_folder = src_folder.parent
if (not src_cls.__file__.endswith("__init__.py") or not src_folder.is_dir()
or not (worlds_folder / "generic").is_dir()):
raise ValueError(f"Unsupported layout for copy_world from {src}")
dst_folder = worlds_folder / dst_folder_name
if dst_folder.is_dir():
raise ValueError(f"Destination {dst_folder} already exists")
shutil.copytree(src_folder, dst_folder)
_new_worlds[dst] = str(dst_folder)
with open(dst_folder / "__init__.py", "r", encoding="utf-8-sig") as f:
contents = f.read()
contents = re.sub(r'game\s*=\s*[\'"]' + re.escape(src) + r'[\'"]', f'game = "{dst}"', contents)
with open(dst_folder / "__init__.py", "w", encoding="utf-8") as f:
f.write(contents)
def delete(name: str) -> None:
assert name in _new_worlds, "World not created by this script"
shutil.rmtree(_new_worlds[name])
del _new_worlds[name]

View File

@@ -1,5 +1,4 @@
# Tests for _speedups.LocationStore and NetUtils._LocationStore
import os
import typing
import unittest
import warnings
@@ -8,8 +7,6 @@ from NetUtils import LocationStore, _LocationStore
State = typing.Dict[typing.Tuple[int, int], typing.Set[int]]
RawLocations = typing.Dict[int, typing.Dict[int, typing.Tuple[int, int, int]]]
ci = bool(os.environ.get("CI")) # always set in GitHub actions
sample_data: RawLocations = {
1: {
11: (21, 2, 7),
@@ -27,9 +24,6 @@ sample_data: RawLocations = {
3: {
9: (99, 4, 0),
},
5: {
9: (99, 5, 0),
}
}
empty_state: State = {
@@ -51,14 +45,14 @@ class Base:
store: typing.Union[LocationStore, _LocationStore]
def test_len(self) -> None:
self.assertEqual(len(self.store), 5)
self.assertEqual(len(self.store), 4)
self.assertEqual(len(self.store[1]), 3)
def test_key_error(self) -> None:
with self.assertRaises(KeyError):
_ = self.store[0]
with self.assertRaises(KeyError):
_ = self.store[6]
_ = self.store[5]
locations = self.store[1] # no Exception
with self.assertRaises(KeyError):
_ = locations[7]
@@ -77,7 +71,7 @@ class Base:
self.assertEqual(self.store[1].get(10, (None, None, None)), (None, None, None))
def test_iter(self) -> None:
self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
self.assertEqual(sorted(self.store), [1, 2, 3, 4])
self.assertEqual(len(self.store), len(sample_data))
self.assertEqual(list(self.store[1]), [11, 12, 13])
self.assertEqual(len(self.store[1]), len(sample_data[1]))
@@ -91,26 +85,13 @@ class Base:
self.assertEqual(sorted(self.store[1].items())[0][1], self.store[1][11])
def test_find_item(self) -> None:
# empty player set
self.assertEqual(sorted(self.store.find_item(set(), 99)), [])
# no such player, single
self.assertEqual(sorted(self.store.find_item({6}, 99)), [])
# no such player, set
self.assertEqual(sorted(self.store.find_item({7, 8, 9}, 99)), [])
# no such item
self.assertEqual(sorted(self.store.find_item({3}, 1)), [])
# valid matches
self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
self.assertEqual(sorted(self.store.find_item({3}, 99)),
[(4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
self.assertEqual(sorted(self.store.find_item({2, 3, 4}, 99)),
[(3, 9, 99, 4, 0), (4, 9, 99, 3, 0)])
# test hash collision in set
self.assertEqual(sorted(self.store.find_item({3, 5}, 99)),
[(4, 9, 99, 3, 0), (5, 9, 99, 5, 0)])
self.assertEqual(sorted(self.store.find_item(set(range(2048)), 13)),
[(1, 13, 13, 1, 0)])
def test_get_for_player(self) -> None:
self.assertEqual(self.store.get_for_player(3), {4: {9}})
@@ -215,20 +196,18 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
super().setUp()
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
class TestSpeedupsLocationStore(Base.TestLocationStore):
"""Run base method tests for cython implementation."""
def setUp(self) -> None:
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.store = LocationStore(sample_data)
super().setUp()
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_speedups not available")
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
class TestSpeedupsLocationStoreConstructor(Base.TestLocationStoreConstructor):
"""Run base constructor tests and tests the additional constraints for cython implementation."""
def setUp(self) -> None:
self.assertFalse(LocationStore is _LocationStore, "Failed to load _speedups")
self.type = LocationStore
super().setUp()

View File

@@ -1,67 +0,0 @@
import unittest
from Options import Choice, DefaultOnToggle, Toggle
class TestNumericOptions(unittest.TestCase):
def test_numeric_option(self) -> None:
"""Tests the initialization and equivalency comparisons of the base Numeric Option class."""
class TestChoice(Choice):
option_zero = 0
option_one = 1
option_two = 2
alias_three = 1
non_option_attr = 2
class TestToggle(Toggle):
pass
class TestDefaultOnToggle(DefaultOnToggle):
pass
with self.subTest("choice"):
choice_option_default = TestChoice.from_any(TestChoice.default)
choice_option_string = TestChoice.from_any("one")
choice_option_int = TestChoice.from_any(2)
choice_option_alias = TestChoice.from_any("three")
choice_option_attr = TestChoice.from_any(TestChoice.option_two)
self.assertEqual(choice_option_default, TestChoice.option_zero,
"assigning default didn't match default value")
self.assertEqual(choice_option_string, "one")
self.assertEqual(choice_option_int, 2)
self.assertEqual(choice_option_alias, TestChoice.alias_three)
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
self.assertRaises(KeyError, TestChoice.from_any, "four")
self.assertIn(choice_option_int, [1, 2, 3])
self.assertIn(choice_option_int, {2})
self.assertIn(choice_option_int, (2,))
self.assertIn(choice_option_string, ["one", "two", "three"])
# this fails since the hash is derived from the value
self.assertNotIn(choice_option_string, {"one"})
self.assertIn(choice_option_string, ("one",))
with self.subTest("toggle"):
toggle_default = TestToggle.from_any(TestToggle.default)
toggle_string = TestToggle.from_any("false")
toggle_int = TestToggle.from_any(0)
toggle_alias = TestToggle.from_any("off")
self.assertFalse(toggle_default)
self.assertFalse(toggle_string)
self.assertFalse(toggle_int)
self.assertFalse(toggle_alias)
with self.subTest("on toggle"):
toggle_default = TestDefaultOnToggle.from_any(TestDefaultOnToggle.default)
toggle_string = TestDefaultOnToggle.from_any("true")
toggle_int = TestDefaultOnToggle.from_any(1)
toggle_alias = TestDefaultOnToggle.from_any("on")
self.assertTrue(toggle_default)
self.assertTrue(toggle_string)
self.assertTrue(toggle_int)
self.assertTrue(toggle_alias)

View File

@@ -1,15 +0,0 @@
from typing import Callable, ClassVar
from kivy.event import EventDispatcher
class WindowBase(EventDispatcher):
width: ClassVar[int] # readonly AliasProperty
height: ClassVar[int] # readonly AliasProperty
@staticmethod
def bind(**kwargs: Callable[..., None]) -> None: ...
class Window(WindowBase):
...

View File

@@ -1,2 +0,0 @@
class EventDispatcher:
...

View File

@@ -1,6 +0,0 @@
from typing import Literal
from .layout import Layout
class BoxLayout(Layout):
orientation: Literal['horizontal', 'vertical']

View File

@@ -1,14 +1,8 @@
from typing import Any, Sequence
from typing import Any
from .widget import Widget
class Layout(Widget):
@property
def children(self) -> Sequence[Widget]: ...
def add_widget(self, widget: Widget) -> None: ...
def remove_widget(self, widget: Widget) -> None: ...
def do_layout(self, *largs: Any, **kwargs: Any) -> None: ...

View File

@@ -1,17 +0,0 @@
from typing import Any, Callable
class And:
def __init__(self, __type: type, __func: Callable[[Any], bool]) -> None: ...
class Or:
def __init__(self, *args: object) -> None: ...
class Schema:
def __init__(self, __x: object) -> None: ...
class Optional(Schema):
...

View File

@@ -123,8 +123,8 @@ class WebWorldRegister(type):
assert group.options, "A custom defined Option Group must contain at least one Option."
# catch incorrectly titled versions of the prebuilt groups so they don't create extra groups
title_name = group.name.title()
assert title_name not in prebuilt_options or title_name == group.name, \
f"Prebuilt group name \"{group.name}\" must be \"{title_name}\""
if title_name in prebuilt_options:
group.name = title_name
if group.name == "Item & Location Options":
assert not any(option in item_and_loc_options for option in group.options), \

View File

@@ -1,11 +1,8 @@
import bisect
import logging
import pathlib
import weakref
from enum import Enum, auto
from typing import Optional, Callable, List, Iterable, Tuple
from typing import Optional, Callable, List, Iterable
from Utils import local_path, open_filename
from Utils import local_path
class Type(Enum):
@@ -52,10 +49,8 @@ class Component:
def __repr__(self):
return f"{self.__class__.__name__}({self.display_name})"
processes = weakref.WeakSet()
def launch_subprocess(func: Callable, name: str = None):
global processes
import multiprocessing
@@ -63,7 +58,6 @@ def launch_subprocess(func: Callable, name: str = None):
process.start()
processes.add(process)
class SuffixIdentifier:
suffixes: Iterable[str]
@@ -83,80 +77,6 @@ def launch_textclient():
launch_subprocess(CommonClient.run_as_textclient, name="TextClient")
def _install_apworld(apworld_src: str = "") -> Optional[Tuple[pathlib.Path, pathlib.Path]]:
if not apworld_src:
apworld_src = open_filename('Select APWorld file to install', (('APWorld', ('.apworld',)),))
if not apworld_src:
# user closed menu
return
if not apworld_src.endswith(".apworld"):
raise Exception(f"Wrong file format, looking for .apworld. File identified: {apworld_src}")
apworld_path = pathlib.Path(apworld_src)
module_name = pathlib.Path(apworld_path.name).stem
try:
import zipfile
zipfile.ZipFile(apworld_path).open(module_name + "/__init__.py")
except ValueError as e:
raise Exception("Archive appears invalid or damaged.") from e
except KeyError as e:
raise Exception("Archive appears to not be an apworld. (missing __init__.py)") from e
import worlds
if worlds.user_folder is None:
raise Exception("Custom Worlds directory appears to not be writable.")
for world_source in worlds.world_sources:
if apworld_path.samefile(world_source.resolved_path):
# Note that this doesn't check if the same world is already installed.
# It only checks if the user is trying to install the apworld file
# that comes from the installation location (worlds or custom_worlds)
raise Exception(f"APWorld is already installed at {world_source.resolved_path}.")
# TODO: run generic test suite over the apworld.
# TODO: have some kind of version system to tell from metadata if the apworld should be compatible.
target = pathlib.Path(worlds.user_folder) / apworld_path.name
import shutil
shutil.copyfile(apworld_path, target)
# If a module with this name is already loaded, then we can't load it now.
# TODO: We need to be able to unload a world module,
# so the user can update a world without restarting the application.
found_already_loaded = False
for loaded_world in worlds.world_sources:
loaded_name = pathlib.Path(loaded_world.path).stem
if module_name == loaded_name:
found_already_loaded = True
break
if found_already_loaded:
raise Exception(f"Installed APWorld successfully, but '{module_name}' is already loaded,\n"
"so a Launcher restart is required to use the new installation.")
world_source = worlds.WorldSource(str(target), is_zip=True)
bisect.insort(worlds.world_sources, world_source)
world_source.load()
return apworld_path, target
def install_apworld(apworld_path: str = "") -> None:
try:
res = _install_apworld(apworld_path)
if res is None:
logging.info("Aborting APWorld installation.")
return
source, target = res
except Exception as e:
import Utils
Utils.messagebox(e.__class__.__name__, str(e), error=True)
logging.exception(e)
else:
import Utils
logging.info(f"Installed APWorld successfully, copied {source} to {target}.")
Utils.messagebox("Install complete.", f"Installed APWorld from {source}.")
components: List[Component] = [
# Launcher
Component('Launcher', 'Launcher', component_type=Type.HIDDEN),
@@ -164,7 +84,6 @@ components: List[Component] = [
Component('Host', 'MultiServer', 'ArchipelagoServer', cli=True,
file_identifier=SuffixIdentifier('.archipelago', '.zip')),
Component('Generate', 'Generate', cli=True),
Component("Install APWorld", func=install_apworld, file_identifier=SuffixIdentifier(".apworld")),
Component('Text Client', 'CommonClient', 'ArchipelagoTextClient', func=launch_textclient),
Component('Links Awakening DX Client', 'LinksAwakeningClient',
file_identifier=SuffixIdentifier('.apladx')),

View File

@@ -1,22 +1,16 @@
import importlib
import importlib.util
import logging
import os
import sys
import warnings
import zipimport
import time
import dataclasses
from typing import Dict, List, TypedDict
from typing import Dict, List, TypedDict, Optional
from Utils import local_path, user_path
local_folder = os.path.dirname(__file__)
user_folder = user_path("worlds") if user_path() != local_path() else user_path("custom_worlds")
try:
os.makedirs(user_folder, exist_ok=True)
except OSError: # can't access/write?
user_folder = None
user_folder = user_path("worlds") if user_path() != local_path() else None
__all__ = {
"network_data_package",
@@ -50,7 +44,7 @@ class WorldSource:
path: str # typically relative path from this module
is_zip: bool = False
relative: bool = True # relative to regular world import folder
time_taken: float = -1.0
time_taken: Optional[float] = None
def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
@@ -94,6 +88,7 @@ class WorldSource:
print(f"Could not load world {self}:", file=file_like)
traceback.print_exc(file=file_like)
file_like.seek(0)
import logging
logging.exception(file_like.read())
failed_world_loads.append(os.path.basename(self.path).rsplit(".", 1)[0])
return False
@@ -108,12 +103,7 @@ for folder in (folder for folder in (user_folder, local_folder) if folder):
if not entry.name.startswith(("_", ".")):
file_name = entry.name if relative else os.path.join(folder, entry.name)
if entry.is_dir():
if os.path.isfile(os.path.join(entry.path, '__init__.py')):
world_sources.append(WorldSource(file_name, relative=relative))
elif os.path.isfile(os.path.join(entry.path, '__init__.pyc')):
world_sources.append(WorldSource(file_name, relative=relative))
else:
logging.warning(f"excluding {entry.name} from world sources because it has no __init__.py")
world_sources.append(WorldSource(file_name, relative=relative))
elif entry.is_file() and entry.name.endswith(".apworld"):
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))

View File

@@ -168,7 +168,6 @@ async def _game_watcher(ctx: BizHawkClientContext):
ctx.auth = None
ctx.username = None
ctx.client_handler = None
ctx.finished_game = False
await ctx.disconnect(False)
ctx.rom_hash = rom_hash

View File

@@ -28,11 +28,6 @@ class kill_switch:
logger.debug("kill_switch: Add switch")
cls._to_kill.append(value)
@classmethod
def kill(cls, value):
logger.info(f"kill_switch: Process cleanup for 1 process")
value._clean(verbose=False)
@classmethod
def kill_all(cls):
logger.info(f"kill_switch: Process cleanup for {len(cls._to_kill)} processes")
@@ -121,7 +116,7 @@ class SC2Process:
async def __aexit__(self, *args):
logger.exception("async exit")
await self._close_connection()
kill_switch.kill(self)
kill_switch.kill_all()
signal.signal(signal.SIGINT, signal.SIG_DFL)
@property

View File

@@ -2,8 +2,7 @@ from __future__ import annotations
from typing import Dict
from dataclasses import dataclass
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle
class FreeincarnateMax(Range):
@@ -224,22 +223,22 @@ class StartCastle(Choice):
option_white = 2
default = option_yellow
@dataclass
class AdventureOptions(PerGameCommonOptions):
dragon_slay_check: DragonSlayCheck
death_link: DeathLink
bat_logic: BatLogic
freeincarnate_max: FreeincarnateMax
dragon_rando_type: DragonRandoType
connector_multi_slot: ConnectorMultiSlot
yorgle_speed: YorgleStartingSpeed
yorgle_min_speed: YorgleMinimumSpeed
grundle_speed: GrundleStartingSpeed
grundle_min_speed: GrundleMinimumSpeed
rhindle_speed: RhindleStartingSpeed
rhindle_min_speed: RhindleMinimumSpeed
difficulty_switch_a: DifficultySwitchA
difficulty_switch_b: DifficultySwitchB
start_castle: StartCastle
adventure_option_definitions: Dict[str, type(Option)] = {
"dragon_slay_check": DragonSlayCheck,
"death_link": DeathLink,
"bat_logic": BatLogic,
"freeincarnate_max": FreeincarnateMax,
"dragon_rando_type": DragonRandoType,
"connector_multi_slot": ConnectorMultiSlot,
"yorgle_speed": YorgleStartingSpeed,
"yorgle_min_speed": YorgleMinimumSpeed,
"grundle_speed": GrundleStartingSpeed,
"grundle_min_speed": GrundleMinimumSpeed,
"rhindle_speed": RhindleStartingSpeed,
"rhindle_min_speed": RhindleMinimumSpeed,
"difficulty_switch_a": DifficultySwitchA,
"difficulty_switch_b": DifficultySwitchB,
"start_castle": StartCastle,
}

View File

@@ -1,5 +1,4 @@
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
from Options import PerGameCommonOptions
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
@@ -25,7 +24,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
connect(world, player, target, source, rule, True)
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
menu = Region("Menu", player, multiworld)
@@ -75,7 +74,7 @@ def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
multiworld.regions.append(credits_room_far_side)
dragon_slay_check = options.dragon_slay_check.value
dragon_slay_check = multiworld.dragon_slay_check[player].value
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
for name, location_data in location_table.items():

View File

@@ -6,7 +6,7 @@ from BaseClasses import LocationProgressType
def set_rules(self) -> None:
world = self.multiworld
use_bat_logic = self.options.bat_logic.value == BatLogic.option_use_logic
use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic
set_rule(world.get_entrance("YellowCastlePort", self.player),
lambda state: state.has("Yellow Key", self.player))
@@ -28,7 +28,7 @@ def set_rules(self) -> None:
lambda state: state.has("Bridge", self.player) or
state.has("Magnet", self.player))
dragon_slay_check = self.options.dragon_slay_check.value
dragon_slay_check = world.dragon_slay_check[self.player].value
if dragon_slay_check:
if self.difficulty_switch_b == DifficultySwitchB.option_hard_with_unlock_item:
set_rule(world.get_location("Slay Yorgle", self.player),

View File

@@ -15,8 +15,7 @@ from Options import AssembleOptions
from worlds.AutoWorld import WebWorld, World
from Fill import fill_restrictive
from worlds.generic.Rules import add_rule, set_rule
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
AdventureOptions
from .Options import adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
from .Rom import get_base_rom_bytes, get_base_rom_path, AdventureDeltaPatch, apply_basepatch, \
AdventureAutoCollectLocation
from .Items import item_table, ItemData, nothing_item_id, event_table, AdventureItem, standard_item_max
@@ -110,7 +109,7 @@ class AdventureWorld(World):
game: ClassVar[str] = "Adventure"
web: ClassVar[WebWorld] = AdventureWeb()
options_dataclass = AdventureOptions
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()}
@@ -150,18 +149,18 @@ class AdventureWorld(World):
bytearray(f"ADVENTURE{__version__.replace('.', '')[:3]}_{self.player}_{self.multiworld.seed}", "utf8")[:21]
self.rom_name.extend([0] * (21 - len(self.rom_name)))
self.dragon_rando_type = self.options.dragon_rando_type.value
self.dragon_slay_check = self.options.dragon_slay_check.value
self.connector_multi_slot = self.options.connector_multi_slot.value
self.yorgle_speed = self.options.yorgle_speed.value
self.yorgle_min_speed = self.options.yorgle_min_speed.value
self.grundle_speed = self.options.grundle_speed.value
self.grundle_min_speed = self.options.grundle_min_speed.value
self.rhindle_speed = self.options.rhindle_speed.value
self.rhindle_min_speed = self.options.rhindle_min_speed.value
self.difficulty_switch_a = self.options.difficulty_switch_a.value
self.difficulty_switch_b = self.options.difficulty_switch_b.value
self.start_castle = self.options.start_castle.value
self.dragon_rando_type = self.multiworld.dragon_rando_type[self.player].value
self.dragon_slay_check = self.multiworld.dragon_slay_check[self.player].value
self.connector_multi_slot = self.multiworld.connector_multi_slot[self.player].value
self.yorgle_speed = self.multiworld.yorgle_speed[self.player].value
self.yorgle_min_speed = self.multiworld.yorgle_min_speed[self.player].value
self.grundle_speed = self.multiworld.grundle_speed[self.player].value
self.grundle_min_speed = self.multiworld.grundle_min_speed[self.player].value
self.rhindle_speed = self.multiworld.rhindle_speed[self.player].value
self.rhindle_min_speed = self.multiworld.rhindle_min_speed[self.player].value
self.difficulty_switch_a = self.multiworld.difficulty_switch_a[self.player].value
self.difficulty_switch_b = self.multiworld.difficulty_switch_b[self.player].value
self.start_castle = self.multiworld.start_castle[self.player].value
self.created_items = 0
if self.dragon_slay_check == 0:
@@ -228,7 +227,7 @@ class AdventureWorld(World):
extra_filler_count = num_locations - self.created_items
# traps would probably go here, if enabled
freeincarnate_max = self.options.freeincarnate_max.value
freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
actual_freeincarnates = min(extra_filler_count, freeincarnate_max)
self.multiworld.itempool += [self.create_item("Freeincarnate") for _ in range(actual_freeincarnates)]
self.created_items += actual_freeincarnates
@@ -248,7 +247,7 @@ class AdventureWorld(World):
self.created_items += 1
def create_regions(self) -> None:
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
create_regions(self.multiworld, self.player, self.dragon_rooms)
set_rules = set_rules
@@ -355,7 +354,7 @@ class AdventureWorld(World):
auto_collect_locations: [AdventureAutoCollectLocation] = []
local_item_to_location: {int, int} = {}
bat_no_touch_locs: [LocationData] = []
bat_logic: int = self.options.bat_logic.value
bat_logic: int = self.multiworld.bat_logic[self.player].value
try:
rom_deltas: { int, int } = {}
self.place_dragons(rom_deltas)
@@ -422,7 +421,7 @@ class AdventureWorld(World):
item_position_data_start = get_item_position_data_start(unplaced_item.table_index)
rom_deltas[item_position_data_start] = 0xff
if self.options.connector_multi_slot.value:
if self.multiworld.connector_multi_slot[self.player].value:
rom_deltas[connector_port_offset] = (self.player & 0xff)
else:
rom_deltas[connector_port_offset] = 0

View File

@@ -35,7 +35,7 @@ dw_requirements = {
"The Mustache Gauntlet": LocData(hookshot=True, required_hats=[HatType.DWELLER]),
"Rift Collapse: Deep Sea": LocData(hookshot=True),
"Rift Collapse - Deep Sea": LocData(hookshot=True),
}
# Includes main objective requirements
@@ -55,7 +55,7 @@ dw_bonus_requirements = {
"The Mustache Gauntlet": LocData(required_hats=[HatType.ICE]),
"Rift Collapse: Deep Sea": LocData(required_hats=[HatType.DWELLER]),
"Rift Collapse - Deep Sea": LocData(required_hats=[HatType.DWELLER]),
}
dw_stamp_costs = {
@@ -178,9 +178,9 @@ def set_dw_rules(world: "HatInTimeWorld"):
def add_dw_rules(world: "HatInTimeWorld", loc: Location):
bonus: bool = "All Clear" in loc.name
if not bonus:
data = dw_requirements.get(loc.parent_region.name)
data = dw_requirements.get(loc.name)
else:
data = dw_bonus_requirements.get(loc.parent_region.name)
data = dw_bonus_requirements.get(loc.name)
if data is None:
return

View File

@@ -185,7 +185,7 @@ class AquariaLocations:
"Mithalas City, second bulb at the end of the top path": 698040,
"Mithalas City, bulb in the top path": 698036,
"Mithalas City, Mithalas Pot": 698174,
"Mithalas City, urn in the Castle flower tube entrance": 698128,
"Mithalas City, urn in the Cathedral flower tube entrance": 698128,
}
locations_mithalas_city_fishpass = {
@@ -246,7 +246,7 @@ class AquariaLocations:
"Kelp Forest top left area, bulb in the bottom left clearing": 698044,
"Kelp Forest top left area, bulb in the path down from the top left clearing": 698045,
"Kelp Forest top left area, bulb in the top left clearing": 698046,
"Kelp Forest top left area, Jelly Egg": 698185,
"Kelp Forest top left, Jelly Egg": 698185,
}
locations_forest_tl_fp = {
@@ -332,7 +332,7 @@ class AquariaLocations:
}
locations_veil_tr_l = {
"The Veil top right area, bulb at the top of the waterfall": 698080,
"The Veil top right area, bulb in the top of the waterfall": 698080,
"The Veil top right area, Transturtle": 698210,
}

View File

@@ -771,7 +771,6 @@ class AquariaRegions:
self.__connect_regions("Sunken City left area", "Sunken City boss area",
self.sunken_city_l, self.sunken_city_boss,
lambda state: _has_beast_form(state, self.player) and
_has_sun_form(state, self.player) and
_has_energy_form(state, self.player) and
_has_bind_song(state, self.player))
@@ -984,7 +983,7 @@ class AquariaRegions:
lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas City, third urn in the city reserve", self.player),
lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle flower tube entrance", self.player),
add_rule(self.multiworld.get_location("Mithalas City, urn in the Cathedral flower tube entrance", self.player),
lambda state: _has_damaging_item(state, self.player))
add_rule(self.multiworld.get_location("Mithalas City Castle, urn in the bedroom", self.player),
lambda state: _has_damaging_item(state, self.player))
@@ -1024,7 +1023,7 @@ class AquariaRegions:
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("Sun Worm path, second cliff bulb", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
add_rule(self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall", self.player),
add_rule(self.multiworld.get_location("The Veil top right area, bulb in the top of the waterfall", self.player),
lambda state: _has_hot_soup(state, self.player) and _has_beast_form(state, self.player))
def __adjusting_under_rock_location(self) -> None:
@@ -1176,7 +1175,7 @@ class AquariaRegions:
self.multiworld.get_location("Sun Worm path, second cliff bulb",
self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("The Veil top right area, bulb at the top of the waterfall",
self.multiworld.get_location("The Veil top right area, bulb in the top of the waterfall",
self.player).item_rule =\
lambda item: item.classification != ItemClassification.progression
self.multiworld.get_location("Bubble Cave, bulb in the left cave wall",

View File

@@ -167,10 +167,14 @@ class AquariaWorld(World):
self.__pre_fill_item("Transturtle Simon Says", "Arnassi Ruins, Transturtle", precollected)
self.__pre_fill_item("Transturtle Arnassi Ruins", "Simon Says area, Transturtle", precollected)
for name, data in item_table.items():
if name not in self.exclude:
for i in range(data.count):
item = self.create_item(name)
self.multiworld.itempool.append(item)
if name in precollected:
precollected.remove(name)
self.multiworld.itempool.append(self.create_item(self.get_filler_item_name()))
else:
if name not in self.exclude:
for i in range(data.count):
item = self.create_item(name)
self.multiworld.itempool.append(item)
def set_rules(self) -> None:
"""

View File

@@ -56,7 +56,7 @@ after_home_water_locations = [
"Mithalas City, second bulb at the end of the top path",
"Mithalas City, bulb in the top path",
"Mithalas City, Mithalas Pot",
"Mithalas City, urn in the Castle flower tube entrance",
"Mithalas City, urn in the Cathedral flower tube entrance",
"Mithalas City, Doll",
"Mithalas City, urn inside a home fish pass",
"Mithalas City Castle, bulb in the flesh hole",
@@ -93,7 +93,7 @@ after_home_water_locations = [
"Kelp Forest top left area, bulb in the bottom left clearing",
"Kelp Forest top left area, bulb in the path down from the top left clearing",
"Kelp Forest top left area, bulb in the top left clearing",
"Kelp Forest top left area, Jelly Egg",
"Kelp Forest top left, Jelly Egg",
"Kelp Forest top left area, bulb close to the Verse Egg",
"Kelp Forest top left area, Verse Egg",
"Kelp Forest top right area, bulb under the rock in the right path",
@@ -125,7 +125,7 @@ after_home_water_locations = [
"Turtle cave, Urchin Costume",
"The Veil top right area, bulb in the middle of the wall jump cliff",
"The Veil top right area, Golden Starfish",
"The Veil top right area, bulb at the top of the waterfall",
"The Veil top right area, bulb in the top of the waterfall",
"The Veil top right area, Transturtle",
"The Veil bottom area, bulb in the left path",
"The Veil bottom area, bulb in the spirit path",

View File

@@ -20,14 +20,14 @@ class BeastFormAccessTest(AquariaTestBase):
"Mithalas City, second bulb at the end of the top path",
"Mithalas City, bulb in the top path",
"Mithalas City, Mithalas Pot",
"Mithalas City, urn in the Castle flower tube entrance",
"Mithalas City, urn in the Cathedral flower tube entrance",
"Mermog cave, Piranha Egg",
"Mithalas Cathedral, Mithalan Dress",
"Turtle cave, bulb in Bubble Cliff",
"Turtle cave, Urchin Costume",
"Sun Worm path, first cliff bulb",
"Sun Worm path, second cliff bulb",
"The Veil top right area, bulb at the top of the waterfall",
"The Veil top right area, bulb in the top of the waterfall",
"Bubble Cave, bulb in the left cave wall",
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg",

View File

@@ -30,7 +30,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
"Final Boss area, bulb in the boss third form room",
"Sun Worm path, first cliff bulb",
"Sun Worm path, second cliff bulb",
"The Veil top right area, bulb at the top of the waterfall",
"The Veil top right area, bulb in the top of the waterfall",
"Bubble Cave, bulb in the left cave wall",
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg",

View File

@@ -30,7 +30,7 @@ class UNoProgressionHardHiddenTest(AquariaTestBase):
"Final Boss area, bulb in the boss third form room",
"Sun Worm path, first cliff bulb",
"Sun Worm path, second cliff bulb",
"The Veil top right area, bulb at the top of the waterfall",
"The Veil top right area, bulb in the top of the waterfall",
"Bubble Cave, bulb in the left cave wall",
"Bubble Cave, bulb in the right cave wall (behind the ice crystal)",
"Bubble Cave, Verse Egg",

View File

@@ -18,9 +18,6 @@ class SunFormAccessTest(AquariaTestBase):
"Abyss right area, bulb behind the rock in the whale room",
"Octopus Cave, Dumbo Egg",
"Beating Octopus Prime",
"Sunken City, bulb on top of the boss area",
"Beating the Golem",
"Sunken City cleared",
"Final Boss area, bulb in the boss third form room",
"Objective complete"
]

View File

@@ -109,7 +109,7 @@ class BombRushCyberfunkWorld(World):
def create_items(self):
rep_locations: int = 87
if self.options.skip_polo_photos:
rep_locations -= 17
rep_locations -= 18
self.options.total_rep.round_to_nearest_step()
rep_counts = self.options.total_rep.get_rep_item_counts(self.random, rep_locations)
@@ -157,7 +157,7 @@ class BombRushCyberfunkWorld(World):
self.get_region(n).add_exits(region_exits[n])
for index, loc in enumerate(location_table):
if self.options.skip_polo_photos and "Polo" in loc["game_id"]:
if self.options.skip_polo_photos and "Polo" in loc["name"]:
continue
stage: Region = self.get_region(loc["stage"])
stage.add_locations({loc["name"]: base_id + index})

View File

@@ -64,4 +64,3 @@ item_name_groups = ({
})
item_name_groups['Horizontal'] = item_name_groups['Cloak'] | item_name_groups['CDash']
item_name_groups['Vertical'] = item_name_groups['Claw'] | {'Monarch_Wings'}
item_name_groups['Skills'] |= item_name_groups['Vertical'] | item_name_groups['Horizontal']

View File

@@ -2,7 +2,6 @@ import typing
import re
from .ExtractedData import logic_options, starts, pool_options
from .Rules import cost_terms
from schema import And, Schema, Optional
from Options import Option, DefaultOnToggle, Toggle, Choice, Range, OptionDict, NamedRange, DeathLink
from .Charms import vanilla_costs, names as charm_names
@@ -213,7 +212,7 @@ class MinimumEggPrice(Range):
Only takes effect if the EggSlotShops option is greater than 0."""
display_name = "Minimum Egg Price"
range_start = 1
range_end = 20
range_end = 21
default = 1
@@ -297,9 +296,6 @@ class PlandoCharmCosts(OptionDict):
This is set after any random Charm Notch costs, if applicable."""
display_name = "Charm Notch Cost Plando"
valid_keys = frozenset(charm_names)
schema = Schema({
Optional(name): And(int, lambda n: 6 >= n >= 0) for name in charm_names
})
def get_costs(self, charm_costs: typing.List[int]) -> typing.List[int]:
for name, cost in self.value.items():

View File

@@ -308,14 +308,14 @@ class CorSkipToggle(Toggle):
Full Cor Skip is also affected by this Toggle.
"""
display_name = "CoR Skip Toggle"
display_name = "CoR Skip Toggle."
default = False
class CustomItemPoolQuantity(ItemDict):
"""Add more of an item into the itempool. Note: You cannot take out items from the pool."""
display_name = "Custom Item Pool"
valid_keys = default_itempool_option.keys()
verify_item_name = True
default = default_itempool_option

View File

@@ -430,13 +430,13 @@ class KH2World(World):
"""
for item, value in self.options.start_inventory.value.items():
if item in ActionAbility_Table \
or item in SupportAbility_Table or item in exclusion_item_table["StatUps"] \
or item in SupportAbility_Table or exclusion_item_table["StatUps"] \
or item in DonaldAbility_Table or item in GoofyAbility_Table:
# cannot have more than the quantity for abilties
if value > item_dictionary_table[item].quantity:
logging.info(
f"{self.multiworld.get_file_safe_player_name(self.player)} cannot have more than {item_dictionary_table[item].quantity} of {item}."
f" Changing the amount to the max amount")
f"{self.multiworld.get_file_safe_player_name(self.player)} cannot have more than {item_dictionary_table[item].quantity} of {item}"
f"Changing the amount to the max amount")
value = item_dictionary_table[item].quantity
self.item_quantity_dict[item] -= value

View File

@@ -49,8 +49,8 @@ class TestMasteryBlocksDependents(LingoTestBase):
def test_requirement(self):
self.collect_all_but("Gray")
self.assertFalse(self.can_reach_location("Orange Tower Basement - THE LIBRARY"))
self.assertFalse(self.can_reach_location("The Fearless - MASTERY"))
self.assertFalse(self.can_reach_location("Orange Tower Seventh Floor - MASTERY"))
self.collect_by_name("Gray")
self.assertTrue(self.can_reach_location("Orange Tower Basement - THE LIBRARY"))
self.assertTrue(self.can_reach_location("The Fearless - MASTERY"))
self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - MASTERY"))

View File

@@ -5,7 +5,7 @@ from schema import And, Optional, Or, Schema
from Options import Accessibility, Choice, DeathLinkMixin, DefaultOnToggle, OptionDict, PerGameCommonOptions, \
PlandoConnections, Range, StartInventoryPool, Toggle, Visibility
from .portals import CHECKPOINTS, PORTALS, SHOP_POINTS
from worlds.messenger.portals import CHECKPOINTS, PORTALS, SHOP_POINTS
class MessengerAccessibility(Accessibility):

View File

@@ -306,7 +306,8 @@ def write_tokens(world: "MLSSWorld", patch: MLSSProcedurePatch) -> None:
if world.options.scale_stats:
patch.write_token(APTokenTypes.WRITE, 0xD00002, bytes([0x1]))
patch.write_token(APTokenTypes.WRITE, 0xD00003, bytes([world.options.xp_multiplier.value]))
if world.options.xp_multiplier:
patch.write_token(APTokenTypes.WRITE, 0xD00003, bytes([world.options.xp_multiplier.value]))
if world.options.tattle_hp:
patch.write_token(APTokenTypes.WRITE, 0xD00000, bytes([0x1]))

View File

@@ -22,15 +22,12 @@ class MuseDashCollections:
]
MUSE_PLUS_DLC: str = "Muse Plus"
# Ordering matters for webhost. Order goes: Muse Plus, Time Limited Muse Plus Dlcs, Paid Dlcs
DLC: List[str] = [
MUSE_PLUS_DLC,
"CHUNITHM COURSE MUSE", # Part of Muse Plus. Goes away 22nd May 2027.
"maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026.
"MSR Anthology", # Now no longer available.
"Miku in Museland", # Paid DLC not included in Muse Plus
"Rin Len's Mirrorland", # Paid DLC not included in Muse Plus
# MUSE_PLUS_DLC, # To be included when OptionSets are rendered as part of basic settings.
# "maimai DX Limited-time Suite", # Part of Muse Plus. Goes away 31st Jan 2026.
"Miku in Museland", # Paid DLC not included in Muse Plus
"Rin Len's Mirrorland", # Paid DLC not included in Muse Plus
"MSR Anthology", # Now no longer available.
]
DIFF_OVERRIDES: List[str] = [
@@ -53,7 +50,7 @@ class MuseDashCollections:
song_items: Dict[str, SongData] = {}
song_locations: Dict[str, int] = {}
trap_items: Dict[str, int] = {
vfx_trap_items: Dict[str, int] = {
"Bad Apple Trap": STARTING_CODE + 1,
"Pixelate Trap": STARTING_CODE + 2,
"Ripple Trap": STARTING_CODE + 3,
@@ -61,15 +58,13 @@ class MuseDashCollections:
"Chromatic Aberration Trap": STARTING_CODE + 5,
"Background Freeze Trap": STARTING_CODE + 6,
"Gray Scale Trap": STARTING_CODE + 7,
"Nyaa SFX Trap": STARTING_CODE + 8,
"Error SFX Trap": STARTING_CODE + 9,
"Focus Line Trap": STARTING_CODE + 10,
"Focus Line Trap": STARTING_CODE + 10,
}
sfx_trap_items: List[str] = [
"Nyaa SFX Trap",
"Error SFX Trap",
]
sfx_trap_items: Dict[str, int] = {
"Nyaa SFX Trap": STARTING_CODE + 8,
"Error SFX Trap": STARTING_CODE + 9,
}
filler_items: Dict[str, int] = {
"Great To Perfect (10 Pack)": STARTING_CODE + 30,
@@ -83,7 +78,7 @@ class MuseDashCollections:
"Extra Life": 1,
}
item_names_to_id: ChainMap = ChainMap({}, filler_items, trap_items)
item_names_to_id: ChainMap = ChainMap({}, filler_items, sfx_trap_items, vfx_trap_items)
location_names_to_id: ChainMap = ChainMap(song_locations, album_locations)
def __init__(self) -> None:
@@ -176,9 +171,6 @@ class MuseDashCollections:
return filtered_list
def filter_songs_to_dlc(self, song_list: List[str], dlc_songs: Set[str]) -> List[str]:
return [song for song in song_list if self.song_matches_dlc_filter(self.song_items[song], dlc_songs)]
def song_matches_dlc_filter(self, song: SongData, dlc_songs: Set[str]) -> bool:
if song.album in self.FREE_ALBUMS:
return True

View File

@@ -1,26 +1,26 @@
from Options import Toggle, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions, OptionGroup, Removed
from typing import Dict
from Options import Toggle, Option, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions
from dataclasses import dataclass
from .MuseDashCollection import MuseDashCollections
class AllowJustAsPlannedDLCSongs(Toggle):
"""Whether [Muse Plus] DLC Songs, and all the albums included in it, can be chosen as randomised songs.
Note: The [Just As Planned] DLC contains all [Muse Plus] songs."""
display_name = "Allow [Muse Plus] DLC Songs"
class DLCMusicPacks(OptionSet):
"""
Choose which DLC Packs will be included in the pool of chooseable songs.
Note: The [Just As Planned] DLC contains all [Muse Plus] songs.
"""
"""Which non-[Muse Plus] DLC packs can be chosen as randomised songs."""
display_name = "DLC Packs"
default = {}
valid_keys = [dlc for dlc in MuseDashCollections.DLC]
class StreamerModeEnabled(Toggle):
"""
In Muse Dash, an option named 'Streamer Mode' removes songs which may trigger copyright issues when streaming.
If this is enabled, only songs available under Streamer Mode will be available for randomization.
"""
"""In Muse Dash, an option named 'Streamer Mode' removes songs which may trigger copyright issues when streaming.
If this is enabled, only songs available under Streamer Mode will be available for randomization."""
display_name = "Streamer Mode Only Songs"
@@ -33,8 +33,7 @@ class StartingSongs(Range):
class AdditionalSongs(Range):
"""
The total number of songs that will be placed in the randomization pool.
"""The total number of songs that will be placed in the randomization pool.
- This does not count any starting songs or the goal song.
- The final song count may be lower due to other settings.
"""
@@ -45,8 +44,7 @@ class AdditionalSongs(Range):
class DifficultyMode(Choice):
"""
Ensures that at any chosen song has at least 1 value falling within these values.
"""Ensures that at any chosen song has at least 1 value falling within these values.
- Any: All songs are available
- Easy: 1, 2 or 3
- Medium: 4, 5
@@ -68,11 +66,8 @@ class DifficultyMode(Choice):
# Todo: Investigate options to make this non randomizable
class DifficultyModeOverrideMin(Range):
"""
Ensures that 1 difficulty has at least 1 this value or higher per song.
Note: Difficulty Mode must be set to Manual.
"""
"""Ensures that 1 difficulty has at least 1 this value or higher per song.
- Difficulty Mode must be set to Manual."""
display_name = "Manual Difficulty Min"
range_start = 1
range_end = 11
@@ -81,11 +76,8 @@ class DifficultyModeOverrideMin(Range):
# Todo: Investigate options to make this non randomizable
class DifficultyModeOverrideMax(Range):
"""
Ensures that 1 difficulty has at least 1 this value or lower per song.
Note: Difficulty Mode must be set to Manual.
"""
"""Ensures that 1 difficulty has at least 1 this value or lower per song.
- Difficulty Mode must be set to Manual."""
display_name = "Manual Difficulty Max"
range_start = 1
range_end = 11
@@ -93,8 +85,7 @@ class DifficultyModeOverrideMax(Range):
class GradeNeeded(Choice):
"""
Completing a song will require a grade of this value or higher in order to unlock items.
"""Completing a song will require a grade of this value or higher in order to unlock items.
The grades are as follows:
- Silver S (SS): >= 95% accuracy
- Pink S (S): >= 90% accuracy
@@ -113,9 +104,7 @@ class GradeNeeded(Choice):
class MusicSheetCountPercentage(Range):
"""
Controls how many music sheets are added to the pool based on the number of songs, including starting songs.
"""Controls how many music sheets are added to the pool based on the number of songs, including starting songs.
Higher numbers leads to more consistent game lengths, but will cause individual music sheets to be less important.
"""
range_start = 10
@@ -132,18 +121,19 @@ class MusicSheetWinCountPercentage(Range):
display_name = "Music Sheets Needed to Win"
class ChosenTraps(OptionSet):
"""
This controls the types of traps that can be added to the pool.
- Traps last the length of a song, or until you die.
class TrapTypes(Choice):
"""This controls the types of traps that can be added to the pool.
- VFX Traps consist of visual effects that play over the song. (i.e. Grayscale.)
- SFX Traps consist of changing your sfx setting to one possibly more annoying sfx.
Traps last the length of a song, or until you die.
Note: SFX traps are only available if [Just as Planned] DLC songs are enabled.
"""
display_name = "Chosen Traps"
default = {}
valid_keys = {trap for trap in MuseDashCollections.trap_items.keys()}
display_name = "Available Trap Types"
option_None = 0
option_VFX = 1
option_SFX = 2
option_All = 3
default = 3
class TrapCountPercentage(Range):
@@ -155,49 +145,24 @@ class TrapCountPercentage(Range):
class IncludeSongs(ItemSet):
"""
These songs will be guaranteed to show up within the seed.
- You must have the DLC enabled to play these songs.
- Difficulty options will not affect these songs.
- If there are too many included songs, this will act as a whitelist ignoring song difficulty.
"""Any song listed here will be guaranteed to be included as part of the seed.
- Difficulty options will be skipped for these songs.
- If there being too many included songs, songs will be randomly chosen without regard for difficulty.
- If you want these songs immediately, use start_inventory instead.
"""
verify_item_name = True
display_name = "Include Songs"
class ExcludeSongs(ItemSet):
"""
These songs will be guaranteed to not show up within the seed.
Note: Does not affect songs within the "Include Songs" list.
"""
"""Any song listed here will be excluded from being a part of the seed."""
verify_item_name = True
display_name = "Exclude Songs"
md_option_groups = [
OptionGroup("Song Choice", [
DLCMusicPacks,
StreamerModeEnabled,
IncludeSongs,
ExcludeSongs,
]),
OptionGroup("Difficulty", [
GradeNeeded,
DifficultyMode,
DifficultyModeOverrideMin,
DifficultyModeOverrideMax,
DeathLink,
]),
OptionGroup("Traps", [
ChosenTraps,
TrapCountPercentage,
]),
]
@dataclass
class MuseDashOptions(PerGameCommonOptions):
allow_just_as_planned_dlc_songs: AllowJustAsPlannedDLCSongs
dlc_packs: DLCMusicPacks
streamer_mode_enabled: StreamerModeEnabled
starting_song_count: StartingSongs
@@ -208,12 +173,8 @@ class MuseDashOptions(PerGameCommonOptions):
grade_needed: GradeNeeded
music_sheet_count_percentage: MusicSheetCountPercentage
music_sheet_win_count_percentage: MusicSheetWinCountPercentage
chosen_traps: ChosenTraps
available_trap_types: TrapTypes
trap_count_percentage: TrapCountPercentage
death_link: DeathLink
include_songs: IncludeSongs
exclude_songs: ExcludeSongs
# Removed
allow_just_as_planned_dlc_songs: Removed
available_trap_types: Removed

View File

@@ -3,7 +3,7 @@
MuseDashPresets: Dict[str, Dict[str, Any]] = {
# An option to support Short Sync games. 40 songs.
"No DLC - Short": {
"dlc_packs": [],
"allow_just_as_planned_dlc_songs": False,
"starting_song_count": 5,
"additional_song_count": 34,
"music_sheet_count_percentage": 20,
@@ -11,7 +11,7 @@ MuseDashPresets: Dict[str, Dict[str, Any]] = {
},
# An option to support Short Sync games but adds variety. 40 songs.
"DLC - Short": {
"dlc_packs": ["Muse Plus"],
"allow_just_as_planned_dlc_songs": True,
"starting_song_count": 5,
"additional_song_count": 34,
"music_sheet_count_percentage": 20,
@@ -19,7 +19,7 @@ MuseDashPresets: Dict[str, Dict[str, Any]] = {
},
# An option to support Longer Sync/Async games. 100 songs.
"DLC - Long": {
"dlc_packs": ["Muse Plus"],
"allow_just_as_planned_dlc_songs": True,
"starting_song_count": 8,
"additional_song_count": 91,
"music_sheet_count_percentage": 20,

View File

@@ -1,10 +1,10 @@
from worlds.AutoWorld import World, WebWorld
from BaseClasses import Region, Item, ItemClassification, Tutorial
from typing import List, ClassVar, Type, Set
from BaseClasses import Region, Item, ItemClassification, Entrance, Tutorial
from typing import List, ClassVar, Type
from math import floor
from Options import PerGameCommonOptions
from .Options import MuseDashOptions, md_option_groups
from .Options import MuseDashOptions
from .Items import MuseDashSongItem, MuseDashFixedItem
from .Locations import MuseDashLocation
from .MuseDashCollection import MuseDashCollections
@@ -35,7 +35,6 @@ class MuseDashWebWorld(WebWorld):
tutorials = [setup_en, setup_es]
options_presets = MuseDashPresets
option_groups = md_option_groups
class MuseDashWorld(World):
@@ -73,6 +72,8 @@ class MuseDashWorld(World):
def generate_early(self):
dlc_songs = {key for key in self.options.dlc_packs.value}
if self.options.allow_just_as_planned_dlc_songs.value:
dlc_songs.add(self.md_collection.MUSE_PLUS_DLC)
streamer_mode = self.options.streamer_mode_enabled
(lower_diff_threshold, higher_diff_threshold) = self.get_difficulty_range()
@@ -87,7 +88,7 @@ class MuseDashWorld(World):
available_song_keys = self.md_collection.get_songs_with_settings(
dlc_songs, bool(streamer_mode.value), lower_diff_threshold, higher_diff_threshold)
available_song_keys = self.handle_plando(available_song_keys, dlc_songs)
available_song_keys = self.handle_plando(available_song_keys)
count_needed_for_start = max(0, starter_song_count - len(self.starting_songs))
if len(available_song_keys) + len(self.included_songs) >= count_needed_for_start + 11:
@@ -108,7 +109,7 @@ class MuseDashWorld(World):
for song in self.starting_songs:
self.multiworld.push_precollected(self.create_item(song))
def handle_plando(self, available_song_keys: List[str], dlc_songs: Set[str]) -> List[str]:
def handle_plando(self, available_song_keys: List[str]) -> List[str]:
song_items = self.md_collection.song_items
start_items = self.options.start_inventory.value.keys()
@@ -116,9 +117,7 @@ class MuseDashWorld(World):
exclude_songs = self.options.exclude_songs.value
self.starting_songs = [s for s in start_items if s in song_items]
self.starting_songs = self.md_collection.filter_songs_to_dlc(self.starting_songs, dlc_songs)
self.included_songs = [s for s in include_songs if s in song_items and s not in self.starting_songs]
self.included_songs = self.md_collection.filter_songs_to_dlc(self.included_songs, dlc_songs)
return [s for s in available_song_keys if s not in start_items
and s not in include_songs and s not in exclude_songs]
@@ -149,7 +148,7 @@ class MuseDashWorld(World):
self.victory_song_name = available_song_keys[chosen_song - included_song_count]
del available_song_keys[chosen_song - included_song_count]
# Next, make sure the starting songs are fulfilled
# Next, make sure the starting songs are fufilled
if len(self.starting_songs) < starting_song_count:
for _ in range(len(self.starting_songs), starting_song_count):
if len(available_song_keys) > 0:
@@ -157,7 +156,7 @@ class MuseDashWorld(World):
else:
self.starting_songs.append(self.included_songs.pop())
# Then attempt to fulfill any remaining songs for interim songs
# Then attempt to fufill any remaining songs for interim songs
if len(self.included_songs) < additional_song_count:
for _ in range(len(self.included_songs), self.options.additional_song_count):
if len(available_song_keys) <= 0:
@@ -175,7 +174,11 @@ class MuseDashWorld(World):
if filler:
return MuseDashFixedItem(name, ItemClassification.filler, filler, self.player)
trap = self.md_collection.trap_items.get(name)
trap = self.md_collection.vfx_trap_items.get(name)
if trap:
return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player)
trap = self.md_collection.sfx_trap_items.get(name)
if trap:
return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player)
@@ -249,7 +252,9 @@ class MuseDashWorld(World):
def create_regions(self) -> None:
menu_region = Region("Menu", self.player, self.multiworld)
self.multiworld.regions += [menu_region]
song_select_region = Region("Song Select", self.player, self.multiworld)
self.multiworld.regions += [menu_region, song_select_region]
menu_region.connect(song_select_region)
# Make a collection of all songs available for this rando.
# 1. All starting songs
@@ -263,27 +268,35 @@ class MuseDashWorld(World):
self.random.shuffle(included_song_copy)
all_selected_locations.extend(included_song_copy)
# Adds 2 item locations per song/album to the menu region.
# Make a region per song/album, then adds 1-2 item locations to them
for i in range(0, len(all_selected_locations)):
name = all_selected_locations[i]
loc1 = MuseDashLocation(self.player, name + "-0", self.md_collection.song_locations[name + "-0"], menu_region)
loc1.access_rule = lambda state, place=name: state.has(place, self.player)
menu_region.locations.append(loc1)
region = Region(name, self.player, self.multiworld)
self.multiworld.regions.append(region)
song_select_region.connect(region, name, lambda state, place=name: state.has(place, self.player))
loc2 = MuseDashLocation(self.player, name + "-1", self.md_collection.song_locations[name + "-1"], menu_region)
loc2.access_rule = lambda state, place=name: state.has(place, self.player)
menu_region.locations.append(loc2)
# Muse Dash requires 2 locations per song to be *interesting*. Balanced out by filler.
region.add_locations({
name + "-0": self.md_collection.song_locations[name + "-0"],
name + "-1": self.md_collection.song_locations[name + "-1"]
}, MuseDashLocation)
def set_rules(self) -> None:
self.multiworld.completion_condition[self.player] = lambda state: \
state.has(self.md_collection.MUSIC_SHEET_NAME, self.player, self.get_music_sheet_win_count())
def get_available_traps(self) -> List[str]:
full_trap_list = self.md_collection.trap_items.keys()
if self.md_collection.MUSE_PLUS_DLC not in self.options.dlc_packs.value:
full_trap_list = [trap for trap in full_trap_list if trap not in self.md_collection.sfx_trap_items]
sfx_traps_available = self.options.allow_just_as_planned_dlc_songs.value
return [trap for trap in full_trap_list if trap in self.options.chosen_traps.value]
trap_list = []
if self.options.available_trap_types.value & 1 != 0:
trap_list += self.md_collection.vfx_trap_items.keys()
# SFX options are only available under Just as Planned DLC.
if sfx_traps_available and self.options.available_trap_types.value & 2 != 0:
trap_list += self.md_collection.sfx_trap_items.keys()
return trap_list
def get_trap_count(self) -> int:
multiplier = self.options.trap_count_percentage.value / 100.0

View File

@@ -9,26 +9,25 @@ class CollectionsTest(unittest.TestCase):
for name in collection.song_items.keys():
for c in name:
# This is taken directly from OoT. Represents the generally excepted characters.
if 0x20 <= ord(c) < 0x7e:
if (0x20 <= ord(c) < 0x7e):
continue
bad_names.append(name)
break
self.assertEqual(len(bad_names), 0,
f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}")
self.assertEqual(len(bad_names), 0, f"Muse Dash has {len(bad_names)} songs with non-ASCII characters.\n{bad_names}")
def test_ids_dont_change(self) -> None:
collection = MuseDashCollections()
items_before = {name: code for name, code in collection.item_names_to_id.items()}
locations_before = {name: code for name, code in collection.location_names_to_id.items()}
itemsBefore = {name: code for name, code in collection.item_names_to_id.items()}
locationsBefore = {name: code for name, code in collection.location_names_to_id.items()}
collection.__init__()
items_after = {name: code for name, code in collection.item_names_to_id.items()}
locations_after = {name: code for name, code in collection.location_names_to_id.items()}
itemsAfter = {name: code for name, code in collection.item_names_to_id.items()}
locationsAfter = {name: code for name, code in collection.location_names_to_id.items()}
self.assertDictEqual(items_before, items_after, "Item ID changed after secondary init.")
self.assertDictEqual(locations_before, locations_after, "Location ID changed after secondary init.")
self.assertDictEqual(itemsBefore, itemsAfter, "Item ID changed after secondary init.")
self.assertDictEqual(locationsBefore, locationsAfter, "Location ID changed after secondary init.")
def test_free_dlc_included_in_base_songs(self) -> None:
collection = MuseDashCollections()

View File

@@ -3,31 +3,31 @@ from . import MuseDashTestBase
class DifficultyRanges(MuseDashTestBase):
def test_all_difficulty_ranges(self) -> None:
muse_dash_world = self.get_world()
muse_dash_world = self.multiworld.worlds[1]
dlc_set = {x for x in muse_dash_world.md_collection.DLC}
difficulty_choice = muse_dash_world.options.song_difficulty_mode
difficulty_min = muse_dash_world.options.song_difficulty_min
difficulty_max = muse_dash_world.options.song_difficulty_max
def test_range(input_range, lower, upper):
self.assertEqual(input_range[0], lower)
self.assertEqual(input_range[1], upper)
def test_range(inputRange, lower, upper):
self.assertEqual(inputRange[0], lower)
self.assertEqual(inputRange[1], upper)
songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, input_range[0], input_range[1])
songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, inputRange[0], inputRange[1])
for songKey in songs:
song = muse_dash_world.md_collection.song_items[songKey]
if song.easy is not None and input_range[0] <= song.easy <= input_range[1]:
if (song.easy is not None and inputRange[0] <= song.easy <= inputRange[1]):
continue
if song.hard is not None and input_range[0] <= song.hard <= input_range[1]:
if (song.hard is not None and inputRange[0] <= song.hard <= inputRange[1]):
continue
if song.master is not None and input_range[0] <= song.master <= input_range[1]:
if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]):
continue
self.fail(f"Invalid song '{songKey}' was given for range '{input_range[0]} to {input_range[1]}'")
self.fail(f"Invalid song '{songKey}' was given for range '{inputRange[0]} to {inputRange[1]}'")
# auto ranges
#auto ranges
difficulty_choice.value = 0
test_range(muse_dash_world.get_difficulty_range(), 0, 12)
difficulty_choice.value = 1
@@ -61,7 +61,7 @@ class DifficultyRanges(MuseDashTestBase):
test_range(muse_dash_world.get_difficulty_range(), 4, 6)
def test_songs_have_difficulty(self) -> None:
muse_dash_world = self.get_world()
muse_dash_world = self.multiworld.worlds[1]
for song_name in muse_dash_world.md_collection.DIFF_OVERRIDES:
song = muse_dash_world.md_collection.song_items[song_name]
@@ -73,4 +73,4 @@ class DifficultyRanges(MuseDashTestBase):
f"Song '{song_name}' difficulty not set when it should be.")
else:
self.assertTrue(song.easy is not None and song.hard is not None and song.master is not None,
f"Song '{song_name}' difficulty not set when it should be.")
f"Song '{song_name}' difficulty not set when it should be.")

View File

@@ -4,32 +4,7 @@ from . import MuseDashTestBase
class TestPlandoSettings(MuseDashTestBase):
options = {
"additional_song_count": 15,
"dlc_packs": {"Muse Plus"},
"include_songs": [
"Lunatic",
"Out of Sense",
"Magic Knight Girl",
]
}
def test_included_songs_didnt_grow_item_count(self) -> None:
muse_dash_world = self.get_world()
self.assertEqual(len(muse_dash_world.included_songs), 15, "Logical songs size grew when it shouldn't.")
def test_included_songs_plando(self) -> None:
muse_dash_world = self.get_world()
songs = muse_dash_world.included_songs.copy()
songs.append(muse_dash_world.victory_song_name)
self.assertIn("Lunatic", songs, "Logical songs is missing a plando song: Lunatic")
self.assertIn("Out of Sense", songs, "Logical songs is missing a plando song: Out of Sense")
self.assertIn("Magic Knight Girl", songs, "Logical songs is missing a plando song: Magic Knight Girl")
class TestFilteredPlandoSettings(MuseDashTestBase):
options = {
"additional_song_count": 15,
"dlc_packs": {"MSR Anthology"},
"allow_just_as_planned_dlc_songs": True,
"include_songs": [
"Operation Blade",
"Autumn Moods",
@@ -38,15 +13,15 @@ class TestFilteredPlandoSettings(MuseDashTestBase):
}
def test_included_songs_didnt_grow_item_count(self) -> None:
muse_dash_world = self.get_world()
self.assertEqual(len(muse_dash_world.included_songs), 15, "Logical songs size grew when it shouldn't.")
muse_dash_world = self.multiworld.worlds[1]
self.assertEqual(len(muse_dash_world.included_songs), 15,
f"Logical songs size grew when it shouldn't. Expected 15. Got {len(muse_dash_world.included_songs)}")
# Tests for excluding included songs when the right dlc isn't enabled
def test_filtered_included_songs_plando(self) -> None:
muse_dash_world = self.get_world()
def test_included_songs_plando(self) -> None:
muse_dash_world = self.multiworld.worlds[1]
songs = muse_dash_world.included_songs.copy()
songs.append(muse_dash_world.victory_song_name)
self.assertIn("Operation Blade", songs, "Logical songs is missing a plando song: Operation Blade")
self.assertIn("Autumn Moods", songs, "Logical songs is missing a plando song: Autumn Moods")
self.assertNotIn("Fireflies", songs, "Logical songs has added a filtered a plando song: Fireflies")
self.assertIn("Fireflies", songs, "Logical songs is missing a plando song: Fireflies")

View File

@@ -1,33 +0,0 @@
from . import MuseDashTestBase
class TestNoTraps(MuseDashTestBase):
def test_no_traps(self) -> None:
md_world = self.get_world()
md_world.options.chosen_traps.value.clear()
self.assertEqual(len(md_world.get_available_traps()), 0, "Got an available trap when we expected none.")
def test_all_traps(self) -> None:
md_world = self.get_world()
md_world.options.dlc_packs.value.add(md_world.md_collection.MUSE_PLUS_DLC)
for trap in md_world.md_collection.trap_items.keys():
md_world.options.chosen_traps.value.add(trap)
trap_count = len(md_world.get_available_traps())
true_count = len(md_world.md_collection.trap_items.keys())
self.assertEqual(trap_count, true_count, "Got a different amount of traps than what was expected.")
def test_exclude_sfx_traps(self) -> None:
md_world = self.get_world()
if "Muse Plus" in md_world.options.dlc_packs.value:
md_world.options.dlc_packs.value.remove("Muse Plus")
for trap in md_world.md_collection.trap_items.keys():
md_world.options.chosen_traps.value.add(trap)
trap_count = len(md_world.get_available_traps())
true_count = len(md_world.md_collection.trap_items.keys()) - len(md_world.md_collection.sfx_trap_items)
self.assertEqual(trap_count, true_count, "Got a different amount of traps than what was expected.")

View File

@@ -4,33 +4,30 @@ from . import MuseDashTestBase
# This ends up with only 25 valid songs that can be chosen.
# These tests ensure that this won't fail generation
class TestWorstCaseHighDifficulty(MuseDashTestBase):
options = {
"starting_song_count": 10,
"dlc_packs": [],
"allow_just_as_planned_dlc_songs": False,
"streamer_mode_enabled": True,
"song_difficulty_mode": 6,
"song_difficulty_min": 11,
"song_difficulty_max": 11,
}
class TestWorstCaseMidDifficulty(MuseDashTestBase):
options = {
"starting_song_count": 10,
"dlc_packs": [],
"allow_just_as_planned_dlc_songs": False,
"streamer_mode_enabled": True,
"song_difficulty_mode": 6,
"song_difficulty_min": 6,
"song_difficulty_max": 6,
}
class TestWorstCaseLowDifficulty(MuseDashTestBase):
options = {
"starting_song_count": 10,
"dlc_packs": [],
"allow_just_as_planned_dlc_songs": False,
"streamer_mode_enabled": True,
"song_difficulty_mode": 6,
"song_difficulty_min": 1,

View File

@@ -1,10 +1,5 @@
from test.bases import WorldTestBase
from .. import MuseDashWorld
from typing import cast
class MuseDashTestBase(WorldTestBase):
game = "Muse Dash"
def get_world(self) -> MuseDashWorld:
return cast(MuseDashWorld, self.multiworld.worlds[1])

View File

@@ -12,7 +12,6 @@ and won't show up in the wild. Previously they would be forced to show up exactl
- The Lilycove Wailmer now logically block you from the east. Actual game behavior is still unchanged for now.
- Water encounters in Slateport now correctly require Surf.
- Mirage Tower can no longer be your only logical access to a species in the wild, since it can permanently disappear.
- Updated the tracker link in the setup guide.
# 2.1.1

View File

@@ -25,20 +25,13 @@ IGNORABLE_MAPS = {
}
"""These maps exist but don't show up in the rando or are unused, and so should be discarded"""
OUT_OF_LOGIC_MAPS = {
POSTGAME_MAPS = {
"MAP_DESERT_UNDERPASS",
"MAP_SAFARI_ZONE_NORTHEAST",
"MAP_SAFARI_ZONE_SOUTHEAST",
"MAP_METEOR_FALLS_STEVENS_CAVE",
"MAP_MIRAGE_TOWER_1F",
"MAP_MIRAGE_TOWER_2F",
"MAP_MIRAGE_TOWER_3F",
"MAP_MIRAGE_TOWER_4F",
}
"""
These maps have encounters and are locked behind beating the champion or are missable.
Those encounter slots should be ignored for logical access to a species.
"""
"""These maps have encounters and are locked behind beating the champion. Those encounter slots should be ignored for logical access to a species."""
NUM_REAL_SPECIES = 386

View File

@@ -4,8 +4,9 @@ Functions related to pokemon species and moves
import functools
from typing import TYPE_CHECKING, Dict, List, Set, Optional, Tuple
from .data import (NUM_REAL_SPECIES, OUT_OF_LOGIC_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData,
SpeciesData, data)
from Options import Toggle
from .data import NUM_REAL_SPECIES, POSTGAME_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData, SpeciesData, data
from .options import (Goal, HmCompatibility, LevelUpMoves, RandomizeAbilities, RandomizeLegendaryEncounters,
RandomizeMiscPokemon, RandomizeStarters, RandomizeTypes, RandomizeWildPokemon,
TmTutorCompatibility)
@@ -265,8 +266,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
species_old_to_new_map: Dict[int, int] = {}
for species_id in table.slots:
if species_id not in species_old_to_new_map:
if not placed_priority_species and len(priority_species) > 0 \
and map_name not in OUT_OF_LOGIC_MAPS:
if not placed_priority_species and len(priority_species) > 0:
new_species_id = priority_species.pop()
placed_priority_species = True
else:
@@ -329,7 +329,7 @@ def randomize_wild_encounters(world: "PokemonEmeraldWorld") -> None:
new_species_id = world.random.choice(candidates).species_id
species_old_to_new_map[species_id] = new_species_id
if world.options.dexsanity and map_name not in OUT_OF_LOGIC_MAPS:
if world.options.dexsanity and map_data.name not in POSTGAME_MAPS:
already_placed.add(new_species_id)
# Actually create the new list of slots and encounter table

View File

@@ -7,7 +7,7 @@ from .ror2environments import environment_vanilla_table, environment_vanilla_ord
environment_sotv_orderedstages_table, environment_sotv_table, collapse_dict_list_vertical, shift_by_offset
from BaseClasses import Item, ItemClassification, Tutorial
from .options import ItemWeights, ROR2Options, ror2_option_groups
from .options import ItemWeights, ROR2Options
from worlds.AutoWorld import World, WebWorld
from .regions import create_explore_regions, create_classic_regions
from typing import List, Dict, Any
@@ -23,8 +23,6 @@ class RiskOfWeb(WebWorld):
["Ijwu", "Kindasneaki"]
)]
option_groups = ror2_option_groups
class RiskOfRainWorld(World):
"""
@@ -46,7 +44,7 @@ class RiskOfRainWorld(World):
}
location_name_to_id = item_pickups
required_client_version = (0, 5, 0)
required_client_version = (0, 4, 5)
web = RiskOfWeb()
total_revivals: int

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions, OptionGroup
from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions
# NOTE be aware that since the range of item ids that RoR2 uses is based off of the maximums of checks
@@ -350,7 +350,7 @@ class ItemPoolPresetToggle(Toggle):
class ItemWeights(Choice):
"""Set Use Item Weight Presets to yes if you want to use one of these presets.
"""Set item_pool_presets to true if you want to use one of these presets.
Preset choices for determining the weights of the item pool.
- New is a test for a potential adjustment to the default weights.
- Uncommon puts a large number of uncommon items in the pool.
@@ -375,44 +375,6 @@ class ItemWeights(Choice):
option_void = 9
ror2_option_groups = [
OptionGroup("Explore Mode Options", [
ChestsPerEnvironment,
ShrinesPerEnvironment,
ScavengersPerEnvironment,
ScannersPerEnvironment,
AltarsPerEnvironment,
RequireStages,
ProgressiveStages,
]),
OptionGroup("Classic Mode Options", [
TotalLocations,
], start_collapsed=True),
OptionGroup("Weighted Choices", [
ItemWeights,
ItemPoolPresetToggle,
WhiteScrap,
GreenScrap,
YellowScrap,
RedScrap,
CommonItem,
UncommonItem,
LegendaryItem,
BossItem,
LunarItem,
VoidItem,
Equipment,
Money,
LunarCoin,
Experience,
MountainTrap,
TimeWarpTrap,
CombatTrap,
TeleportTrap,
]),
]
@dataclass
class ROR2Options(PerGameCommonOptions):
goal: Goal
@@ -437,10 +399,10 @@ class ROR2Options(PerGameCommonOptions):
item_weights: ItemWeights
item_pool_presets: ItemPoolPresetToggle
# define the weights of the generated item pool.
white_scrap: WhiteScrap
green_scrap: GreenScrap
yellow_scrap: YellowScrap
red_scrap: RedScrap
yellow_scrap: YellowScrap
white_scrap: WhiteScrap
common_item: CommonItem
uncommon_item: UncommonItem
legendary_item: LegendaryItem

View File

@@ -19,13 +19,11 @@ def create_explore_regions(ror2_world: "RiskOfRainWorld") -> None:
# Default Locations
non_dlc_regions: Dict[str, RoRRegionData] = {
"Menu": RoRRegionData(None, ["Distant Roost", "Distant Roost (2)",
"Titanic Plains", "Titanic Plains (2)",
"Verdant Falls"]),
"Titanic Plains", "Titanic Plains (2)"]),
"Distant Roost": RoRRegionData([], ["OrderedStage_1"]),
"Distant Roost (2)": RoRRegionData([], ["OrderedStage_1"]),
"Titanic Plains": RoRRegionData([], ["OrderedStage_1"]),
"Titanic Plains (2)": RoRRegionData([], ["OrderedStage_1"]),
"Verdant Falls": RoRRegionData([], ["OrderedStage_1"]),
"Abandoned Aqueduct": RoRRegionData([], ["OrderedStage_2"]),
"Wetland Aspect": RoRRegionData([], ["OrderedStage_2"]),
"Rallypoint Delta": RoRRegionData([], ["OrderedStage_3"]),

View File

@@ -7,7 +7,6 @@ environment_vanilla_orderedstage_1_table: Dict[str, int] = {
"Distant Roost (2)": 8, # blackbeach2
"Titanic Plains": 15, # golemplains
"Titanic Plains (2)": 16, # golemplains2
"Verdant Falls": 28, # lakes
}
environment_vanilla_orderedstage_2_table: Dict[str, int] = {
"Abandoned Aqueduct": 17, # goolake

View File

@@ -107,10 +107,10 @@ class ColouredMessage:
def coloured(self, text: str, colour: str) -> 'ColouredMessage':
add_json_text(self.parts, text, type="color", color=colour)
return self
def location(self, location_id: int, player_id: int) -> 'ColouredMessage':
def location(self, location_id: int, player_id: int = 0) -> 'ColouredMessage':
add_json_location(self.parts, location_id, player_id)
return self
def item(self, item_id: int, player_id: int, flags: int = 0) -> 'ColouredMessage':
def item(self, item_id: int, player_id: int = 0, flags: int = 0) -> 'ColouredMessage':
add_json_item(self.parts, item_id, player_id, flags)
return self
def player(self, player_id: int) -> 'ColouredMessage':
@@ -122,6 +122,7 @@ class ColouredMessage:
class StarcraftClientProcessor(ClientCommandProcessor):
ctx: SC2Context
echo_commands = True
def formatted_print(self, text: str) -> None:
"""Prints with kivy formatting to the GUI, and also prints to command-line and to all logs"""
@@ -256,7 +257,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
for item in received_items_of_this_type:
print_faction_title()
has_printed_faction_title = True
(ColouredMessage('* ').item(item.item, self.ctx.slot, flags=item.flags)
(ColouredMessage('* ').item(item.item, flags=item.flags)
(" from ").location(item.location, self.ctx.slot)
(" by ").player(item.player)
).send(self.ctx)
@@ -277,7 +278,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
received_items_of_this_type = items_received.get(child_item, [])
for item in received_items_of_this_type:
filter_match_count += len(received_items_of_this_type)
(ColouredMessage(' * ').item(item.item, self.ctx.slot, flags=item.flags)
(ColouredMessage(' * ').item(item.item, flags=item.flags)
(" from ").location(item.location, self.ctx.slot)
(" by ").player(item.player)
).send(self.ctx)

View File

@@ -10,15 +10,15 @@ class ItemDict(TypedDict):
base_id = 82000
item_table: List[ItemDict] = [
{"name": "Stick", "id": base_id + 1, "count": 0, "classification": ItemClassification.progression_skip_balancing},
{"name": "Stick", "id": base_id + 1, "count": 8, "classification": ItemClassification.progression_skip_balancing},
{"name": "Seashell", "id": base_id + 2, "count": 23, "classification": ItemClassification.progression_skip_balancing},
{"name": "Golden Feather", "id": base_id + 3, "count": 0, "classification": ItemClassification.progression},
{"name": "Silver Feather", "id": base_id + 4, "count": 0, "classification": ItemClassification.useful},
{"name": "Bucket", "id": base_id + 5, "count": 0, "classification": ItemClassification.progression},
{"name": "Bait", "id": base_id + 6, "count": 2, "classification": ItemClassification.filler},
{"name": "Progressive Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression},
{"name": "Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression},
{"name": "Shovel", "id": base_id + 8, "count": 1, "classification": ItemClassification.progression},
{"name": "Toy Shovel", "id": base_id + 9, "count": 0, "classification": ItemClassification.progression_skip_balancing},
{"name": "Toy Shovel", "id": base_id + 9, "count": 5, "classification": ItemClassification.progression_skip_balancing},
{"name": "Compass", "id": base_id + 10, "count": 1, "classification": ItemClassification.useful},
{"name": "Medal", "id": base_id + 11, "count": 3, "classification": ItemClassification.filler},
{"name": "Shell Necklace", "id": base_id + 12, "count": 1, "classification": ItemClassification.progression},
@@ -36,7 +36,7 @@ item_table: List[ItemDict] = [
{"name": "Headband", "id": base_id + 24, "count": 1, "classification": ItemClassification.progression},
{"name": "Running Shoes", "id": base_id + 25, "count": 1, "classification": ItemClassification.useful},
{"name": "Camping Permit", "id": base_id + 26, "count": 1, "classification": ItemClassification.progression},
{"name": "Walkie Talkie", "id": base_id + 27, "count": 0, "classification": ItemClassification.useful},
{"name": "Walkie Talkie", "id": base_id + 27, "count": 1, "classification": ItemClassification.useful},
# Not in the item pool for now
#{"name": "Boating Manual", "id": base_id + ~, "count": 1, "classification": ItemClassification.filler},
@@ -48,9 +48,9 @@ item_table: List[ItemDict] = [
{"name": "21 Coins", "id": base_id + 31, "count": 2, "classification": ItemClassification.filler},
{"name": "25 Coins", "id": base_id + 32, "count": 7, "classification": ItemClassification.filler},
{"name": "27 Coins", "id": base_id + 33, "count": 1, "classification": ItemClassification.filler},
{"name": "32 Coins", "id": base_id + 34, "count": 1, "classification": ItemClassification.useful},
{"name": "33 Coins", "id": base_id + 35, "count": 6, "classification": ItemClassification.useful},
{"name": "50 Coins", "id": base_id + 36, "count": 1, "classification": ItemClassification.useful},
{"name": "32 Coins", "id": base_id + 34, "count": 1, "classification": ItemClassification.filler},
{"name": "33 Coins", "id": base_id + 35, "count": 6, "classification": ItemClassification.filler},
{"name": "50 Coins", "id": base_id + 36, "count": 1, "classification": ItemClassification.filler},
# Filler item determined by settings
{"name": "13 Coins", "id": base_id + 37, "count": 0, "classification": ItemClassification.filler},

View File

@@ -5,7 +5,7 @@ class LocationInfo(TypedDict):
id: int
inGameId: str
needsShovel: bool
purchase: int
purchase: bool
minGoldenFeathers: int
minGoldenFeathersEasy: int
minGoldenFeathersBucket: int
@@ -17,311 +17,311 @@ location_table: List[LocationInfo] = [
{"name": "Start Beach Seashell",
"id": base_id + 1,
"inGameId": "PickUps.3",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Beach Hut Seashell",
"id": base_id + 2,
"inGameId": "PickUps.2",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Beach Umbrella Seashell",
"id": base_id + 3,
"inGameId": "PickUps.8",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Sid Beach Mound Seashell",
"id": base_id + 4,
"inGameId": "PickUps.12",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Sid Beach Seashell",
"id": base_id + 5,
"inGameId": "PickUps.11",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Shirley's Point Beach Seashell",
"id": base_id + 6,
"inGameId": "PickUps.18",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Shirley's Point Rock Seashell",
"id": base_id + 7,
"inGameId": "PickUps.17",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Visitor's Center Beach Seashell",
"id": base_id + 8,
"inGameId": "PickUps.19",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "West River Seashell",
"id": base_id + 9,
"inGameId": "PickUps.10",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "West Riverbank Seashell",
"id": base_id + 10,
"inGameId": "PickUps.4",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Stone Tower Riverbank Seashell",
"id": base_id + 11,
"inGameId": "PickUps.23",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "North Beach Seashell",
"id": base_id + 12,
"inGameId": "PickUps.6",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "North Coast Seashell",
"id": base_id + 13,
"inGameId": "PickUps.7",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Boat Cliff Seashell",
"id": base_id + 14,
"inGameId": "PickUps.14",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Boat Isle Mound Seashell",
"id": base_id + 15,
"inGameId": "PickUps.22",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "East Coast Seashell",
"id": base_id + 16,
"inGameId": "PickUps.21",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "House North Beach Seashell",
"id": base_id + 17,
"inGameId": "PickUps.16",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Airstream Island North Seashell",
"id": base_id + 18,
"inGameId": "PickUps.13",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Airstream Island South Seashell",
"id": base_id + 19,
"inGameId": "PickUps.15",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Secret Island Beach Seashell",
"id": base_id + 20,
"inGameId": "PickUps.1",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Meteor Lake Seashell",
"id": base_id + 126,
"inGameId": "PickUps.20",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Good Creek Path Seashell",
"id": base_id + 127,
"inGameId": "PickUps.9",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
# Visitor's Center Shop
{"name": "Visitor's Center Shop Golden Feather 1",
"id": base_id + 21,
"inGameId": "CampRangerNPC[0]",
"needsShovel": False, "purchase": 40,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Visitor's Center Shop Golden Feather 2",
"id": base_id + 22,
"inGameId": "CampRangerNPC[1]",
"needsShovel": False, "purchase": 40,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Visitor's Center Shop Hat",
"id": base_id + 23,
"inGameId": "CampRangerNPC[9]",
"needsShovel": False, "purchase": 100,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Tough Bird Salesman
{"name": "Tough Bird Salesman Golden Feather 1",
"id": base_id + 24,
"inGameId": "ToughBirdNPC (1)[0]",
"needsShovel": False, "purchase": 100,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Tough Bird Salesman Golden Feather 2",
"id": base_id + 25,
"inGameId": "ToughBirdNPC (1)[1]",
"needsShovel": False, "purchase": 100,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Tough Bird Salesman Golden Feather 3",
"id": base_id + 26,
"inGameId": "ToughBirdNPC (1)[2]",
"needsShovel": False, "purchase": 100,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Tough Bird Salesman Golden Feather 4",
"id": base_id + 27,
"inGameId": "ToughBirdNPC (1)[3]",
"needsShovel": False, "purchase": 100,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Tough Bird Salesman (400 Coins)",
"id": base_id + 28,
"inGameId": "ToughBirdNPC (1)[9]",
"needsShovel": False, "purchase": 400,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
# Beachstickball
{"name": "Beachstickball (10 Hits)",
"id": base_id + 29,
"inGameId": "VolleyballOpponent[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Beachstickball (20 Hits)",
"id": base_id + 30,
"inGameId": "VolleyballOpponent[1]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Beachstickball (30 Hits)",
"id": base_id + 31,
"inGameId": "VolleyballOpponent[2]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Misc Item Locations
{"name": "Shovel Kid Trade",
"id": base_id + 32,
"inGameId": "Frog_StandingNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Compass Guy",
"id": base_id + 33,
"inGameId": "Fox_WalkingNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Hawk Peak Bucket Rock",
"id": base_id + 34,
"inGameId": "Tools.23",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands Bucket Rock",
"id": base_id + 35,
"inGameId": "Tools.42",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Bill the Walrus Fisherman",
"id": base_id + 36,
"inGameId": "SittingNPC (1)[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Catch 3 Fish Reward",
"id": base_id + 37,
"inGameId": "FishBuyer[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Catch All Fish Reward",
"id": base_id + 38,
"inGameId": "FishBuyer[1]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7},
{"name": "Permit Guy Bribe",
"id": base_id + 39,
"inGameId": "CamperNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Catch Fish with Permit",
"id": base_id + 129,
"inGameId": "Player[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Return Camping Permit",
"id": base_id + 130,
"inGameId": "CamperNPC[1]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
# Original Pickaxe Locations
{"name": "Blocked Mine Pickaxe 1",
"id": base_id + 40,
"inGameId": "Tools.31",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Blocked Mine Pickaxe 2",
"id": base_id + 41,
"inGameId": "Tools.32",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Blocked Mine Pickaxe 3",
"id": base_id + 42,
"inGameId": "Tools.33",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Original Toy Shovel Locations
{"name": "Blackwood Trail Lookout Toy Shovel",
"id": base_id + 43,
"inGameId": "PickUps.27",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Shirley's Point Beach Toy Shovel",
"id": base_id + 44,
"inGameId": "PickUps.30",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Visitor's Center Beach Toy Shovel",
"id": base_id + 45,
"inGameId": "PickUps.29",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Blackwood Trail Rock Toy Shovel",
"id": base_id + 46,
"inGameId": "PickUps.26",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Beach Hut Cliff Toy Shovel",
"id": base_id + 128,
"inGameId": "PickUps.28",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Original Stick Locations
{"name": "Secret Island Beach Trail Stick",
"id": base_id + 47,
"inGameId": "PickUps.25",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Below Lighthouse Walkway Stick",
"id": base_id + 48,
"inGameId": "Tools.3",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Beach Hut Rocky Pool Sand Stick",
"id": base_id + 49,
"inGameId": "Tools.0",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Cliff Overlooking West River Waterfall Stick",
"id": base_id + 50,
"inGameId": "Tools.2",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0},
{"name": "Trail to Tough Bird Salesman Stick",
"id": base_id + 51,
"inGameId": "Tools.8",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "North Beach Stick",
"id": base_id + 52,
"inGameId": "Tools.4",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Beachstickball Court Stick",
"id": base_id + 53,
"inGameId": "VolleyballMinigame.4",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Stick Under Sid Beach Umbrella",
"id": base_id + 54,
"inGameId": "Tools.1",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Boating
@@ -333,377 +333,377 @@ location_table: List[LocationInfo] = [
{"name": "Boat Challenge Reward",
"id": base_id + 56,
"inGameId": "DeerKidBoat[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Not a location for now, corresponding with the Boating Manual
# {"name": "Receive Boating Manual",
# "id": base_id + 133,
# "inGameId": "DadDeer[1]",
# "needsShovel": False, "purchase": 0,
# "needsShovel": False, "purchase": False,
# "minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Original Map Locations
{"name": "Outlook Point Dog Gift",
"id": base_id + 57,
"inGameId": "Dog_WalkingNPC_BlueEyed[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
# Original Clothes Locations
{"name": "Collect 15 Seashells",
"id": base_id + 58,
"inGameId": "LittleKidNPCVariant (1)[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Return to Shell Kid",
"id": base_id + 132,
"inGameId": "LittleKidNPCVariant (1)[1]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Taylor the Turtle Headband Gift",
"id": base_id + 59,
"inGameId": "Turtle_WalkingNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Sue the Rabbit Shoes Reward",
"id": base_id + 60,
"inGameId": "Bunny_WalkingNPC (1)[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Purchase Sunhat",
"id": base_id + 61,
"inGameId": "SittingNPC[0]",
"needsShovel": False, "purchase": 100,
"needsShovel": False, "purchase": True,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Original Golden Feather Locations
{"name": "Blackwood Forest Golden Feather",
"id": base_id + 62,
"inGameId": "Feathers.3",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Ranger May Shell Necklace Golden Feather",
"id": base_id + 63,
"inGameId": "AuntMayNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Sand Castle Golden Feather",
"id": base_id + 64,
"inGameId": "SandProvince.3",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Artist Golden Feather",
"id": base_id + 65,
"inGameId": "StandingNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Visitor Camp Rock Golden Feather",
"id": base_id + 66,
"inGameId": "Feathers.8",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Outlook Cliff Golden Feather",
"id": base_id + 67,
"inGameId": "Feathers.2",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Meteor Lake Cliff Golden Feather",
"id": base_id + 68,
"inGameId": "Feathers.7",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0},
# Original Silver Feather Locations
{"name": "Secret Island Peak",
"id": base_id + 69,
"inGameId": "PickUps.24",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 5, "minGoldenFeathersEasy": 7, "minGoldenFeathersBucket": 7},
{"name": "Wristwatch Trade",
"id": base_id + 70,
"inGameId": "Goat_StandingNPC[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
# Golden Chests
{"name": "Lighthouse Golden Chest",
"id": base_id + 71,
"inGameId": "Feathers.0",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 0},
{"name": "Outlook Golden Chest",
"id": base_id + 72,
"inGameId": "Feathers.6",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Stone Tower Golden Chest",
"id": base_id + 73,
"inGameId": "Feathers.5",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "North Cliff Golden Chest",
"id": base_id + 74,
"inGameId": "Feathers.4",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 10, "minGoldenFeathersBucket": 10},
# Chests
{"name": "Blackwood Cliff Chest",
"id": base_id + 75,
"inGameId": "Coins.22",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "White Coast Trail Chest",
"id": base_id + 76,
"inGameId": "Coins.6",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Sid Beach Chest",
"id": base_id + 77,
"inGameId": "Coins.7",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Sid Beach Buried Treasure Chest",
"id": base_id + 78,
"inGameId": "Coins.46",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Sid Beach Cliff Chest",
"id": base_id + 79,
"inGameId": "Coins.9",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Visitor's Center Buried Chest",
"id": base_id + 80,
"inGameId": "Coins.94",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Visitor's Center Hidden Chest",
"id": base_id + 81,
"inGameId": "Coins.42",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Shirley's Point Chest",
"id": base_id + 82,
"inGameId": "Coins.10",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 2},
{"name": "Caravan Cliff Chest",
"id": base_id + 83,
"inGameId": "Coins.12",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Caravan Arch Chest",
"id": base_id + 84,
"inGameId": "Coins.11",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "King Buried Treasure Chest",
"id": base_id + 85,
"inGameId": "Coins.41",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Good Creek Path Buried Chest",
"id": base_id + 86,
"inGameId": "Coins.48",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Good Creek Path West Chest",
"id": base_id + 87,
"inGameId": "Coins.33",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Good Creek Path East Chest",
"id": base_id + 88,
"inGameId": "Coins.62",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "West Waterfall Chest",
"id": base_id + 89,
"inGameId": "Coins.20",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Stone Tower West Cliff Chest",
"id": base_id + 90,
"inGameId": "PickUps.0",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Bucket Path Chest",
"id": base_id + 91,
"inGameId": "Coins.50",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Bucket Cliff Chest",
"id": base_id + 92,
"inGameId": "Coins.49",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
{"name": "In Her Shadow Buried Treasure Chest",
"id": base_id + 93,
"inGameId": "Feathers.9",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Meteor Lake Buried Chest",
"id": base_id + 94,
"inGameId": "Coins.86",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Meteor Lake Chest",
"id": base_id + 95,
"inGameId": "Coins.64",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "House North Beach Chest",
"id": base_id + 96,
"inGameId": "Coins.65",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "East Coast Chest",
"id": base_id + 97,
"inGameId": "Coins.98",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Fisherman's Boat Chest 1",
"id": base_id + 99,
"inGameId": "Boat.0",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Fisherman's Boat Chest 2",
"id": base_id + 100,
"inGameId": "Boat.7",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Airstream Island Chest",
"id": base_id + 101,
"inGameId": "Coins.31",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "West River Waterfall Head Chest",
"id": base_id + 102,
"inGameId": "Coins.34",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Old Building Chest",
"id": base_id + 103,
"inGameId": "Coins.104",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Old Building West Chest",
"id": base_id + 104,
"inGameId": "Coins.109",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Old Building East Chest",
"id": base_id + 105,
"inGameId": "Coins.8",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Hawk Peak West Chest",
"id": base_id + 106,
"inGameId": "Coins.21",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
{"name": "Hawk Peak East Buried Chest",
"id": base_id + 107,
"inGameId": "Coins.76",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
{"name": "Hawk Peak Northeast Chest",
"id": base_id + 108,
"inGameId": "Coins.79",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
{"name": "Northern East Coast Chest",
"id": base_id + 109,
"inGameId": "Coins.45",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0},
{"name": "North Coast Chest",
"id": base_id + 110,
"inGameId": "Coins.28",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "North Coast Buried Chest",
"id": base_id + 111,
"inGameId": "Coins.47",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Small South Island Buried Chest",
"id": base_id + 112,
"inGameId": "Coins.87",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Secret Island Bottom Chest",
"id": base_id + 113,
"inGameId": "Coins.88",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Secret Island Treehouse Chest",
"id": base_id + 114,
"inGameId": "Coins.89",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 1},
{"name": "Sunhat Island Buried Chest",
"id": base_id + 115,
"inGameId": "Coins.112",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands South Buried Chest",
"id": base_id + 116,
"inGameId": "Coins.119",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands West Chest",
"id": base_id + 117,
"inGameId": "Coins.121",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands North Buried Chest",
"id": base_id + 118,
"inGameId": "Coins.117",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands East Chest",
"id": base_id + 119,
"inGameId": "Coins.120",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands South Hidden Chest",
"id": base_id + 120,
"inGameId": "Coins.124",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "A Stormy View Buried Treasure Chest",
"id": base_id + 121,
"inGameId": "Coins.113",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
{"name": "Orange Islands Ruins Buried Chest",
"id": base_id + 122,
"inGameId": "Coins.118",
"needsShovel": True, "purchase": 0,
"needsShovel": True, "purchase": False,
"minGoldenFeathers": 2, "minGoldenFeathersEasy": 4, "minGoldenFeathersBucket": 0},
# Race Rewards
{"name": "Lighthouse Race Reward",
"id": base_id + 123,
"inGameId": "RaceOpponent[0]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 1},
{"name": "Old Building Race Reward",
"id": base_id + 124,
"inGameId": "RaceOpponent[1]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0},
{"name": "Hawk Peak Race Reward",
"id": base_id + 125,
"inGameId": "RaceOpponent[2]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7},
{"name": "Lose Race Gift",
"id": base_id + 131,
"inGameId": "RaceOpponent[9]",
"needsShovel": False, "purchase": 0,
"needsShovel": False, "purchase": False,
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
]

View File

@@ -1,5 +1,5 @@
from dataclasses import dataclass
from Options import Choice, OptionGroup, PerGameCommonOptions, Range, StartInventoryPool, Toggle, DefaultOnToggle
from Options import Choice, PerGameCommonOptions, Range, StartInventoryPool, Toggle
class Goal(Choice):
"""Choose the end goal.
@@ -22,10 +22,8 @@ class CoinsInShops(Toggle):
default = False
class GoldenFeathers(Range):
"""
Number of Golden Feathers in the item pool.
(Note that for the Photo and Help Everyone goals, a minimum of 12 Golden Feathers is enforced)
"""
"""Number of Golden Feathers in the item pool.
(Note that for the Photo and Help Everyone goals, a minimum of 12 Golden Feathers is enforced)"""
display_name = "Golden Feathers"
range_start = 0
range_end = 20
@@ -45,20 +43,6 @@ class Buckets(Range):
range_end = 2
default = 2
class Sticks(Range):
"""Number of Sticks in the item pool."""
display_name = "Sticks"
range_start = 1
range_end = 8
default = 8
class ToyShovels(Range):
"""Number of Toy Shovels in the item pool."""
display_name = "Toy Shovels"
range_start = 1
range_end = 5
default = 5
class GoldenFeatherProgression(Choice):
"""Determines which locations are considered in logic based on the required amount of golden feathers to reach them.
Easy: Locations will be considered inaccessible until the player has enough golden feathers to easily reach them. A minimum of 10 golden feathers is recommended for this setting.
@@ -92,40 +76,6 @@ class FillerCoinAmount(Choice):
option_50_coins = 9
default = 1
class RandomWalkieTalkie(DefaultOnToggle):
"""
When enabled, the Walkie Talkie item will be placed into the item pool. Otherwise, it will be placed in its vanilla location.
This item usually allows the player to locate Avery around the map or restart a race.
"""
display_name = "Randomize Walkie Talkie"
class EasierRaces(Toggle):
"""When enabled, the Running Shoes will be added as a logical requirement for beating any of the races."""
display_name = "Easier Races"
class ShopCheckLogic(Choice):
"""Determines which items will be added as logical requirements to making certain purchases in shops."""
display_name = "Shop Check Logic"
option_nothing = 0
option_fishing_rod = 1
option_shovel = 2
option_fishing_rod_and_shovel = 3
option_golden_fishing_rod = 4
option_golden_fishing_rod_and_shovel = 5
default = 1
class MinShopCheckLogic(Choice):
"""
Determines the minimum cost of a shop item that will have the shop check logic applied to it.
If the cost of a shop item is less than this value, no items will be required to access it.
This is based on the vanilla prices of the shop item. The set cost multiplier will not affect this value.
"""
display_name = "Minimum Shop Check Logic Application"
option_40_coins = 0
option_100_coins = 1
option_400_coins = 2
default = 1
@dataclass
class ShortHikeOptions(PerGameCommonOptions):
start_inventory_from_pool: StartInventoryPool
@@ -134,37 +84,6 @@ class ShortHikeOptions(PerGameCommonOptions):
golden_feathers: GoldenFeathers
silver_feathers: SilverFeathers
buckets: Buckets
sticks: Sticks
toy_shovels: ToyShovels
golden_feather_progression: GoldenFeatherProgression
cost_multiplier: CostMultiplier
filler_coin_amount: FillerCoinAmount
random_walkie_talkie: RandomWalkieTalkie
easier_races: EasierRaces
shop_check_logic: ShopCheckLogic
min_shop_check_logic: MinShopCheckLogic
shorthike_option_groups = [
OptionGroup("General Options", [
Goal,
FillerCoinAmount,
RandomWalkieTalkie
]),
OptionGroup("Logic Options", [
GoldenFeatherProgression,
EasierRaces
]),
OptionGroup("Item Pool Options", [
GoldenFeathers,
SilverFeathers,
Buckets,
Sticks,
ToyShovels
]),
OptionGroup("Shop Options", [
CoinsInShops,
CostMultiplier,
ShopCheckLogic,
MinShopCheckLogic
])
]

View File

@@ -1,6 +1,4 @@
from worlds.generic.Rules import forbid_items_for_player, add_rule
from .Options import Goal, GoldenFeatherProgression, MinShopCheckLogic, ShopCheckLogic
def create_rules(self, location_table):
multiworld = self.multiworld
@@ -13,23 +11,11 @@ def create_rules(self, location_table):
forbid_items_for_player(multiworld.get_location(loc["name"], player), self.item_name_groups['Maps'], player)
add_rule(multiworld.get_location(loc["name"], player),
lambda state: state.has("Shovel", player))
# Shop Rules
if loc["purchase"] and not options.coins_in_shops:
forbid_items_for_player(multiworld.get_location(loc["name"], player), self.item_name_groups['Coins'], player)
if loc["purchase"] >= get_min_shop_logic_cost(self) and options.shop_check_logic != ShopCheckLogic.option_nothing:
if options.shop_check_logic in {ShopCheckLogic.option_fishing_rod, ShopCheckLogic.option_fishing_rod_and_shovel}:
add_rule(multiworld.get_location(loc["name"], player),
lambda state: state.has("Progressive Fishing Rod", player))
if options.shop_check_logic in {ShopCheckLogic.option_golden_fishing_rod, ShopCheckLogic.option_golden_fishing_rod_and_shovel}:
add_rule(multiworld.get_location(loc["name"], player),
lambda state: state.has("Progressive Fishing Rod", player, 2))
if options.shop_check_logic in {ShopCheckLogic.option_shovel, ShopCheckLogic.option_fishing_rod_and_shovel, ShopCheckLogic.option_golden_fishing_rod_and_shovel}:
add_rule(multiworld.get_location(loc["name"], player),
lambda state: state.has("Shovel", player))
# Minimum Feather Rules
if options.golden_feather_progression != GoldenFeatherProgression.option_hard:
if options.golden_feather_progression != 2:
min_feathers = get_min_feathers(self, loc["minGoldenFeathers"], loc["minGoldenFeathersEasy"])
if options.buckets > 0 and loc["minGoldenFeathersBucket"] < min_feathers:
@@ -46,11 +32,11 @@ def create_rules(self, location_table):
# Fishing Rules
add_rule(multiworld.get_location("Catch 3 Fish Reward", player),
lambda state: state.has("Progressive Fishing Rod", player))
lambda state: state.has("Fishing Rod", player))
add_rule(multiworld.get_location("Catch Fish with Permit", player),
lambda state: state.has("Progressive Fishing Rod", player))
lambda state: state.has("Fishing Rod", player))
add_rule(multiworld.get_location("Catch All Fish Reward", player),
lambda state: state.has("Progressive Fishing Rod", player, 2))
lambda state: state.has("Fishing Rod", player))
# Misc Rules
add_rule(multiworld.get_location("Return Camping Permit", player),
@@ -73,34 +59,15 @@ def create_rules(self, location_table):
lambda state: state.has("Stick", player))
add_rule(multiworld.get_location("Beachstickball (30 Hits)", player),
lambda state: state.has("Stick", player))
# Race Rules
if options.easier_races:
add_rule(multiworld.get_location("Lighthouse Race Reward", player),
lambda state: state.has("Running Shoes", player))
add_rule(multiworld.get_location("Old Building Race Reward", player),
lambda state: state.has("Running Shoes", player))
add_rule(multiworld.get_location("Hawk Peak Race Reward", player),
lambda state: state.has("Running Shoes", player))
def get_min_feathers(self, min_golden_feathers, min_golden_feathers_easy):
options = self.options
min_feathers = min_golden_feathers
if options.golden_feather_progression == GoldenFeatherProgression.option_easy:
if options.golden_feather_progression == 0:
min_feathers = min_golden_feathers_easy
if min_feathers > options.golden_feathers:
if options.goal not in {Goal.option_help_everyone, Goal.option_photo}:
if options.goal != 1 and options.goal != 3:
min_feathers = options.golden_feathers
return min_feathers
def get_min_shop_logic_cost(self):
options = self.options
if options.min_shop_check_logic == MinShopCheckLogic.option_40_coins:
return 40
elif options.min_shop_check_logic == MinShopCheckLogic.option_100_coins:
return 100
elif options.min_shop_check_logic == MinShopCheckLogic.option_400_coins:
return 400

Some files were not shown because too many files have changed in this diff Show More