mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-10 01:23:48 -07:00
Compare commits
56 Commits
multiserve
...
NewSoupVi-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b874b6c6d7 | ||
|
|
94ed49ae2a | ||
|
|
c108845d1f | ||
|
|
acf85eb9ab | ||
|
|
2daccded36 | ||
|
|
3b9b9353b7 | ||
|
|
b9e454ab4e | ||
|
|
7299891bdf | ||
|
|
e755f1a0b5 | ||
|
|
87d24eb38a | ||
|
|
54531c6eba | ||
|
|
ccfffa1147 | ||
|
|
75bef3ddb1 | ||
|
|
484082616f | ||
|
|
35617bdac5 | ||
|
|
0a912808e3 | ||
|
|
84a6d50ae7 | ||
|
|
5f8a8e6dad | ||
|
|
2198a70251 | ||
|
|
c478e55d7a | ||
|
|
76804d295b | ||
|
|
0d9fce29c6 | ||
|
|
302017c69e | ||
|
|
a0653cdfe0 | ||
|
|
89d584e474 | ||
|
|
39deef5d09 | ||
|
|
b3a2473853 | ||
|
|
b053fee3e5 | ||
|
|
8c614865bb | ||
|
|
d72afe7100 | ||
|
|
223f2f5523 | ||
|
|
31419c84a4 | ||
|
|
6bb1cce43f | ||
|
|
808f2a8ff0 | ||
|
|
7f1e95c04c | ||
|
|
86da3eb52c | ||
|
|
afb6d9c4da | ||
|
|
911eba3202 | ||
|
|
93cd13736a | ||
|
|
c554c3fdae | ||
|
|
be03dca774 | ||
|
|
04ec2f3893 | ||
|
|
afe4b2925e | ||
|
|
da2f0f94ca | ||
|
|
6a60a93092 | ||
|
|
76266f25ef | ||
|
|
3cc391e9a1 | ||
|
|
133167564c | ||
|
|
f30f2d3a3f | ||
|
|
ee1b13f219 | ||
|
|
c4572964ec | ||
|
|
16ae8449f4 | ||
|
|
c4e0b17de3 | ||
|
|
0265f4d809 | ||
|
|
06e65c1dc6 | ||
|
|
c7eef13b33 |
60
.github/workflows/build.yml
vendored
60
.github/workflows/build.yml
vendored
@@ -40,6 +40,10 @@ 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"
|
||||
@@ -49,12 +53,6 @@ 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
|
||||
@@ -65,11 +63,38 @@ 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:
|
||||
@@ -110,7 +135,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 -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - copy code above to release.yml -
|
||||
@@ -118,15 +143,36 @@ 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
|
||||
|
||||
54
.github/workflows/ctest.yml
vendored
Normal file
54
.github/workflows/ctest.yml
vendored
Normal file
@@ -0,0 +1,54 @@
|
||||
# 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
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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 -czvf ../dist/$TAR_NAME Archipelago && mv Archipelago "$DIR_NAME")
|
||||
(cd build && DIR_NAME="`ls | grep exe`" && mv "$DIR_NAME" Archipelago && tar -cv Archipelago | gzip -8 > ../dist/$TAR_NAME && mv Archipelago "$DIR_NAME")
|
||||
echo "APPIMAGE_NAME=$APPIMAGE_NAME" >> $GITHUB_ENV
|
||||
echo "TAR_NAME=$TAR_NAME" >> $GITHUB_ENV
|
||||
# - code above copied from build.yml -
|
||||
|
||||
31
.github/workflows/unittests.yml
vendored
31
.github/workflows/unittests.yml
vendored
@@ -24,7 +24,7 @@ on:
|
||||
- '.github/workflows/unittests.yml'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
unit:
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: Test Python ${{ matrix.python.version }} ${{ matrix.os }}
|
||||
|
||||
@@ -60,3 +60,32 @@ 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
2
.gitignore
vendored
@@ -62,6 +62,7 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/custom_worlds
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -177,6 +178,7 @@ dmypy.json
|
||||
cython_debug/
|
||||
|
||||
# Cython intermediates
|
||||
_speedups.c
|
||||
_speedups.cpp
|
||||
_speedups.html
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ class AdventureContext(CommonContext):
|
||||
self.local_item_locations = {}
|
||||
self.dragon_speed_info = {}
|
||||
|
||||
options = Utils.get_options()
|
||||
options = Utils.get_settings()
|
||||
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_options()["adventure_options"].get("death_link", False):
|
||||
if Utils.get_settings()["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_options()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_options()["adventure_options"].get("rom_args")
|
||||
auto_start = Utils.get_settings()["adventure_options"].get("rom_start", True)
|
||||
rom_args = Utils.get_settings()["adventure_options"].get("rom_args")
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
@@ -493,6 +493,11 @@ 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():
|
||||
|
||||
6
Fill.py
6
Fill.py
@@ -483,15 +483,15 @@ def distribute_items_restrictive(multiworld: MultiWorld,
|
||||
if panic_method == "swap":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=True,
|
||||
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||
name="Progression", single_player_placement=multiworld.players == 1)
|
||||
elif panic_method == "raise":
|
||||
fill_restrictive(multiworld, multiworld.state, defaultlocations, progitempool,
|
||||
swap=False,
|
||||
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||
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,
|
||||
on_place=mark_for_locking, name="Progression", single_player_placement=multiworld.players == 1)
|
||||
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.")
|
||||
|
||||
40
Generate.py
40
Generate.py
@@ -1,10 +1,12 @@
|
||||
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
|
||||
@@ -15,21 +17,16 @@ 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():
|
||||
options = get_settings()
|
||||
defaults = options.generator
|
||||
from settings import get_settings
|
||||
settings = get_settings()
|
||||
defaults = settings.generator
|
||||
|
||||
parser = argparse.ArgumentParser(description="CMD Generation Interface, defaults come from host.yaml.")
|
||||
parser.add_argument('--weights_file_path', default=defaults.weights_file_path,
|
||||
@@ -41,7 +38,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=options.general_options.output_path,
|
||||
parser.add_argument('--outputpath', default=settings.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)
|
||||
@@ -61,20 +58,23 @@ 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, options
|
||||
return args
|
||||
|
||||
|
||||
def get_seed_name(random_source) -> str:
|
||||
return f"{random_source.randint(0, pow(10, seeddigits) - 1)}".zfill(seeddigits)
|
||||
|
||||
|
||||
def main(args=None, callback=ERmain):
|
||||
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.")
|
||||
|
||||
if not args:
|
||||
args, options = mystery_argparse()
|
||||
else:
|
||||
options = get_settings()
|
||||
args = mystery_argparse()
|
||||
|
||||
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,6 +143,9 @@ def main(args=None, callback=ERmain):
|
||||
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
|
||||
@@ -234,7 +237,8 @@ def main(args=None, callback=ERmain):
|
||||
with open(os.path.join(args.outputpath if args.outputpath else ".", f"generate_{seed_name}.yaml"), "wt") as f:
|
||||
yaml.dump(important, f)
|
||||
|
||||
return callback(erargs, seed)
|
||||
from Main import main as ERmain
|
||||
return ERmain(erargs, seed)
|
||||
|
||||
|
||||
def read_weights_yamls(path) -> Tuple[Any, ...]:
|
||||
@@ -359,6 +363,8 @@ 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:
|
||||
@@ -436,10 +442,13 @@ 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)
|
||||
|
||||
@@ -466,6 +475,7 @@ 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}. "
|
||||
|
||||
82
Launcher.py
82
Launcher.py
@@ -19,7 +19,7 @@ import sys
|
||||
import webbrowser
|
||||
from os.path import isfile
|
||||
from shutil import which
|
||||
from typing import Sequence, Union, Optional
|
||||
from typing import Callable, Sequence, Union, Optional
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
@@ -160,8 +160,12 @@ 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
|
||||
|
||||
@@ -169,11 +173,8 @@ def run_gui():
|
||||
base_title: str = "Archipelago Launcher"
|
||||
container: ContainerLayout
|
||||
grid: GridLayout
|
||||
|
||||
_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}
|
||||
_tool_layout: Optional[ScrollBox] = None
|
||||
_client_layout: Optional[ScrollBox] = None
|
||||
|
||||
def __init__(self, ctx=None):
|
||||
self.title = self.base_title
|
||||
@@ -181,18 +182,7 @@ def run_gui():
|
||||
self.icon = r"data/icon.png"
|
||||
super().__init__()
|
||||
|
||||
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 _refresh_components(self) -> None:
|
||||
|
||||
def build_button(component: Component) -> Widget:
|
||||
"""
|
||||
@@ -217,14 +207,49 @@ 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(
|
||||
self._tools.items(), self._miscs.items(), self._adjusters.items()), self._clients.items()):
|
||||
_tools.items(), _miscs.items(), _adjusters.items()
|
||||
), _clients.items()):
|
||||
# column 1
|
||||
if tool:
|
||||
tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
self._tool_layout.layout.add_widget(build_button(tool[1]))
|
||||
# column 2
|
||||
if client:
|
||||
client_layout.layout.add_widget(build_button(client[1]))
|
||||
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)
|
||||
|
||||
return self.container
|
||||
|
||||
@@ -235,6 +260,14 @@ 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.
|
||||
@@ -243,10 +276,17 @@ 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:
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import copy
|
||||
import datetime
|
||||
import functools
|
||||
@@ -176,12 +177,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.Set[str]]
|
||||
public_stored_data_keys: typing.Set[str] # keys that can be retrieved by a client that has not reached "auth" yet
|
||||
non_hintable_names: typing.Dict[str, typing.AbstractSet[str]]
|
||||
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,
|
||||
@@ -231,7 +232,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 = None
|
||||
self.auto_saver_thread: typing.Optional[threading.Thread] = None
|
||||
self.save_dirty = False
|
||||
self.tags = ['AP']
|
||||
self.games: typing.Dict[int, str] = {}
|
||||
@@ -253,7 +254,6 @@ 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,6 +269,11 @@ 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:
|
||||
@@ -475,26 +480,10 @@ 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():
|
||||
_add_public_data_store_key(f"item_name_groups_{game_name}",
|
||||
lambda lgame=game_name: self.item_name_groups[lgame])
|
||||
self.read_data[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():
|
||||
_add_public_data_store_key(f"location_name_groups_{game_name}",
|
||||
lambda lgame=game_name: self.location_name_groups[lgame])
|
||||
self.read_data[f"location_name_groups_{game_name}"] = lambda lgame=game_name: self.location_name_groups[lgame]
|
||||
|
||||
# sorted access spheres
|
||||
self.spheres = decoded_obj.get("spheres", [])
|
||||
@@ -1645,25 +1634,10 @@ 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 Exception:
|
||||
except:
|
||||
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`"}])
|
||||
@@ -1766,9 +1740,6 @@ 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:
|
||||
@@ -1864,7 +1835,18 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict):
|
||||
await ctx.send_encoded_msgs(bounceclient, msg)
|
||||
|
||||
elif cmd == "Get":
|
||||
await process_get(ctx, client, args, cmd)
|
||||
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])
|
||||
|
||||
elif cmd == "Set":
|
||||
if "key" not in args or args["key"].startswith("_read_") or \
|
||||
@@ -1950,8 +1932,6 @@ 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
|
||||
|
||||
@@ -2309,7 +2289,8 @@ def parse_args() -> argparse.Namespace:
|
||||
|
||||
|
||||
async def auto_shutdown(ctx, to_cancel=None):
|
||||
await asyncio.sleep(ctx.auto_shutdown)
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ctx.exit_event.wait(), ctx.auto_shutdown)
|
||||
|
||||
def inactivity_shutdown():
|
||||
ctx.server.ws_server.close()
|
||||
@@ -2329,7 +2310,8 @@ async def auto_shutdown(ctx, to_cancel=None):
|
||||
if seconds < 0:
|
||||
inactivity_shutdown()
|
||||
else:
|
||||
await asyncio.sleep(seconds)
|
||||
with contextlib.suppress(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(ctx.exit_event.wait(), seconds)
|
||||
|
||||
|
||||
def load_server_cert(path: str, cert_key: typing.Optional[str]) -> "ssl.SSLContext":
|
||||
|
||||
@@ -198,7 +198,8 @@ class JSONtoTextParser(metaclass=HandlerMeta):
|
||||
"slateblue": "6D8BE8",
|
||||
"plum": "AF99EF",
|
||||
"salmon": "FA8072",
|
||||
"white": "FFFFFF"
|
||||
"white": "FFFFFF",
|
||||
"orange": "FF7700",
|
||||
}
|
||||
|
||||
def __init__(self, ctx):
|
||||
|
||||
1
Utils.py
1
Utils.py
@@ -553,6 +553,7 @@ 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 ''}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ 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")
|
||||
@@ -19,7 +22,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():
|
||||
def get_app() -> "Flask":
|
||||
from WebHostLib import register, cache, app as raw_app
|
||||
from WebHostLib.models import db
|
||||
|
||||
@@ -55,6 +58,7 @@ 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)
|
||||
|
||||
@@ -5,7 +5,6 @@ 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")
|
||||
@@ -49,21 +48,4 @@ def room_info(room: UUID):
|
||||
}
|
||||
|
||||
|
||||
@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
|
||||
from . import generate, user, datapackage # trigger registration
|
||||
|
||||
32
WebHostLib/api/datapackage.py
Normal file
32
WebHostLib/api/datapackage.py
Normal file
@@ -0,0 +1,32 @@
|
||||
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
|
||||
@@ -168,17 +168,28 @@ def get_random_port():
|
||||
def get_static_server_data() -> dict:
|
||||
import worlds
|
||||
data = {
|
||||
"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()},
|
||||
"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()
|
||||
},
|
||||
}
|
||||
|
||||
for world_name, world in worlds.AutoWorldRegister.world_types.items():
|
||||
data["non_hintable_names"][world_name] = world.hint_blacklist
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -266,12 +277,15 @@ 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)
|
||||
@@ -281,8 +295,12 @@ 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)
|
||||
@@ -294,13 +312,32 @@ 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)
|
||||
asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
task = asyncio.run_coroutine_threadsafe(start_room(next_room), loop)
|
||||
self._tasks.append(task)
|
||||
task.add_done_callback(self._done)
|
||||
logging.info(f"Starting room {next_room} on {name}.")
|
||||
|
||||
starter = Starter()
|
||||
starter.daemon = True
|
||||
starter.start()
|
||||
loop.run_forever()
|
||||
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()
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
flask>=3.0.0
|
||||
flask>=3.0.3
|
||||
werkzeug>=3.0.3
|
||||
pony>=0.7.17
|
||||
waitress>=2.1.2
|
||||
Flask-Caching>=2.1.0
|
||||
Flask-Compress>=1.14
|
||||
Flask-Limiter>=3.5.0
|
||||
waitress>=3.0.0
|
||||
Flask-Caching>=2.3.0
|
||||
Flask-Compress>=1.15
|
||||
Flask-Limiter>=3.7.0
|
||||
bokeh>=3.1.1; python_version <= '3.8'
|
||||
bokeh>=3.3.2; python_version >= '3.9'
|
||||
markupsafe>=2.1.3
|
||||
bokeh>=3.4.1; python_version >= '3.9'
|
||||
markupsafe>=2.1.5
|
||||
|
||||
@@ -15,7 +15,7 @@ html {
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
}
|
||||
#player-options #player-options-header h1 {
|
||||
margin-bottom: 0;
|
||||
|
||||
@@ -16,7 +16,7 @@ html{
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
color: #eeffeb;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
|
||||
#player-options-header{
|
||||
h1{
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{% macro Toggle(option_name, option) %}
|
||||
<table>
|
||||
<tbody>
|
||||
{{ RangeRow(option_name, option, "No", "false") }}
|
||||
{{ RangeRow(option_name, option, "Yes", "true") }}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
{{ 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) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -18,10 +18,14 @@
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -34,7 +38,7 @@
|
||||
Normal range: {{ option.range_start }} - {{ option.range_end }}
|
||||
{% if option.special_range_names %}
|
||||
<br /><br />
|
||||
The following values has special meaning, and may fall outside the normal range.
|
||||
The following values have special meanings, and may fall outside the normal range.
|
||||
<ul>
|
||||
{% for name, value in option.special_range_names.items() %}
|
||||
<li>{{ value }}: {{ name }}</li>
|
||||
@@ -72,7 +76,9 @@
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
<!-- This table to be filled by JS -->
|
||||
{% if option.default %}
|
||||
{{ RangeRow(option_name, option, option.default, option.default) }}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -90,10 +96,14 @@
|
||||
<tbody>
|
||||
{% for id, name in option.name_lookup.items() %}
|
||||
{% if name != 'random' %}
|
||||
{{ RangeRow(option_name, option, option.get_option_name(id), name) }}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{{ RandomRows(option_name, option) }}
|
||||
{{ RandomRow(option_name, option) }}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endmacro %}
|
||||
@@ -112,7 +122,7 @@
|
||||
type="number"
|
||||
id="{{ option_name }}-{{ item_name }}-qty"
|
||||
name="{{ option_name }}||{{ item_name }}"
|
||||
value="0"
|
||||
value="{{ option.default[item_name] if item_name in option.default else "0" }}"
|
||||
/>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -121,13 +131,14 @@
|
||||
|
||||
{% macro OptionList(option_name, option) %}
|
||||
<div class="list-container">
|
||||
{% for key in option.valid_keys|sort %}
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else 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 }}
|
||||
@@ -183,7 +194,7 @@
|
||||
|
||||
{% macro OptionSet(option_name, option) %}
|
||||
<div class="set-container">
|
||||
{% for key in option.valid_keys|sort %}
|
||||
{% for key in (option.valid_keys if option.valid_keys is ordered else 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>
|
||||
@@ -200,13 +211,17 @@
|
||||
</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) %}
|
||||
{% macro RangeRow(option_name, option, display_value, value, can_delete=False, default_override=None) %}
|
||||
<tr data-row="{{ option_name }}-{{ value }}-row" data-option-name="{{ option_name }}" data-value="{{ value }}">
|
||||
<td class="td-left">
|
||||
<label for="{{ option_name }}||{{ value }}">
|
||||
@@ -220,7 +235,7 @@
|
||||
name="{{ option_name }}||{{ value }}"
|
||||
min="0"
|
||||
max="50"
|
||||
{% if option.default == value %}
|
||||
{% if option.default == value or default_override == value %}
|
||||
value="25"
|
||||
{% else %}
|
||||
value="0"
|
||||
@@ -229,7 +244,7 @@
|
||||
</td>
|
||||
<td class="td-right">
|
||||
<span id="{{ option_name }}||{{ value }}-value">
|
||||
{% if option.default == value %}
|
||||
{% if option.default == value or default_override == value %}
|
||||
25
|
||||
{% else %}
|
||||
0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#cython: language_level=3
|
||||
#distutils: language = c++
|
||||
#distutils: language = c
|
||||
#distutils: depends = intset.h
|
||||
|
||||
"""
|
||||
Provides faster implementation of some core parts.
|
||||
@@ -13,7 +14,6 @@ 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,6 +31,27 @@ 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
|
||||
@@ -185,7 +206,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 std_set[ap_player_t] receivers
|
||||
cdef ap_player_set* receivers
|
||||
cdef size_t slot_count = len(slots)
|
||||
if slot_count == 1:
|
||||
# specialized implementation for single slot
|
||||
@@ -197,13 +218,20 @@ cdef class LocationStore:
|
||||
yield entry.sender, entry.location, entry.item, entry.receiver, entry.flags
|
||||
elif slot_count:
|
||||
# generic implementation with lookup in set
|
||||
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
|
||||
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)
|
||||
|
||||
def get_for_player(self, slot: int) -> Dict[int, Set[int]]:
|
||||
cdef ap_player_t receiver = slot
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 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.
|
||||
# This file is used when doing pyximport
|
||||
import os
|
||||
|
||||
def make_ext(modname, pyxfilename):
|
||||
from distutils.extension import Extension
|
||||
return Extension(name=modname,
|
||||
sources=[pyxfilename],
|
||||
language='c++')
|
||||
depends=["intset.h"],
|
||||
include_dirs=[os.getcwd()],
|
||||
language="c")
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
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>:
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
/worlds/heretic/ @Daivuk
|
||||
|
||||
# Hollow Knight
|
||||
/worlds/hk/ @BadMagic100 @ThePhar
|
||||
/worlds/hk/ @BadMagic100 @qwint
|
||||
|
||||
# Hylics 2
|
||||
/worlds/hylics2/ @TRPG0
|
||||
|
||||
@@ -86,17 +86,29 @@ class ExampleWorld(World):
|
||||
```
|
||||
|
||||
### Option Groups
|
||||
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.
|
||||
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.
|
||||
|
||||
```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,
|
||||
@@ -120,7 +132,8 @@ 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.
|
||||
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.
|
||||
```python
|
||||
# options.py
|
||||
class Logic(Choice):
|
||||
@@ -132,6 +145,12 @@ 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
|
||||
|
||||
@@ -145,6 +164,16 @@ 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
|
||||
|
||||
@@ -87,7 +87,14 @@ 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: 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}\SNI\lua*"
|
||||
Type: filesandordirs; Name: "{app}\EnemizerCLI*"
|
||||
#include "installdelete.iss"
|
||||
@@ -209,6 +216,11 @@ 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
Normal file
135
intset.h
Normal file
@@ -0,0 +1,135 @@
|
||||
/* 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
62
kvui.py
@@ -3,6 +3,7 @@ import logging
|
||||
import sys
|
||||
import typing
|
||||
import re
|
||||
from collections import deque
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
@@ -380,6 +381,57 @@ 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):
|
||||
@@ -415,7 +467,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 = {}
|
||||
self.log_panels: typing.Dict[str, Widget] = {}
|
||||
|
||||
# keep track of last used command to autofill on click
|
||||
self.last_autofillable_command = "hint"
|
||||
@@ -499,7 +551,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 = TextInput(size_hint_y=None, height=dp(30), multiline=False, write_tab=False)
|
||||
self.textinput = CommandPromptTextInput(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)
|
||||
@@ -557,14 +609,18 @@ class GameManager(App):
|
||||
|
||||
self.ctx.exit_event.set()
|
||||
|
||||
def on_message(self, textinput: TextInput):
|
||||
def on_message(self, textinput: CommandPromptTextInput):
|
||||
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)
|
||||
|
||||
|
||||
@@ -2,13 +2,13 @@ colorama>=0.4.6
|
||||
websockets>=12.0
|
||||
PyYAML>=6.0.1
|
||||
jellyfish>=1.0.3
|
||||
jinja2>=3.1.3
|
||||
schema>=0.7.5
|
||||
jinja2>=3.1.4
|
||||
schema>=0.7.7
|
||||
kivy>=2.3.0
|
||||
bsdiff4>=1.2.4
|
||||
platformdirs>=4.1.0
|
||||
certifi>=2023.11.17
|
||||
cython>=3.0.8
|
||||
platformdirs>=4.2.2
|
||||
certifi>=2024.6.2
|
||||
cython>=3.0.10
|
||||
cymem>=2.0.8
|
||||
orjson>=3.9.10
|
||||
typing_extensions>=4.7.0
|
||||
orjson>=3.10.3
|
||||
typing_extensions>=4.12.1
|
||||
|
||||
2
setup.py
2
setup.py
@@ -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),
|
||||
))
|
||||
|
||||
|
||||
49
test/cpp/CMakeLists.txt
Normal file
49
test/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,49 @@
|
||||
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()
|
||||
32
test/cpp/README.md
Normal file
32
test/cpp/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
```
|
||||
4
test/cpp/intset/CMakeLists.txt
Normal file
4
test/cpp/intset/CMakeLists.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
target_sources(test_default
|
||||
PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/test_intset.cpp
|
||||
)
|
||||
105
test/cpp/intset/test_intset.cpp
Normal file
105
test/cpp/intset/test_intset.cpp
Normal file
@@ -0,0 +1,105 @@
|
||||
#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);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ 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")
|
||||
@@ -60,6 +61,10 @@ 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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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
|
||||
|
||||
@@ -84,3 +85,4 @@ 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"])
|
||||
|
||||
0
test/hosting/__init__.py
Normal file
0
test/hosting/__init__.py
Normal file
191
test/hosting/__main__.py
Normal file
191
test/hosting/__main__.py
Normal file
@@ -0,0 +1,191 @@
|
||||
# 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)
|
||||
110
test/hosting/client.py
Normal file
110
test/hosting/client.py
Normal file
@@ -0,0 +1,110 @@
|
||||
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))])
|
||||
75
test/hosting/generate.py
Normal file
75
test/hosting/generate.py
Normal file
@@ -0,0 +1,75 @@
|
||||
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
|
||||
115
test/hosting/serve.py
Normal file
115
test/hosting/serve.py
Normal file
@@ -0,0 +1,115 @@
|
||||
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)
|
||||
208
test/hosting/webhost.py
Normal file
208
test/hosting/webhost.py
Normal file
@@ -0,0 +1,208 @@
|
||||
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()
|
||||
42
test/hosting/world.py
Normal file
42
test/hosting/world.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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]
|
||||
@@ -1,4 +1,5 @@
|
||||
# Tests for _speedups.LocationStore and NetUtils._LocationStore
|
||||
import os
|
||||
import typing
|
||||
import unittest
|
||||
import warnings
|
||||
@@ -7,6 +8,8 @@ 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),
|
||||
@@ -24,6 +27,9 @@ sample_data: RawLocations = {
|
||||
3: {
|
||||
9: (99, 4, 0),
|
||||
},
|
||||
5: {
|
||||
9: (99, 5, 0),
|
||||
}
|
||||
}
|
||||
|
||||
empty_state: State = {
|
||||
@@ -45,14 +51,14 @@ class Base:
|
||||
store: typing.Union[LocationStore, _LocationStore]
|
||||
|
||||
def test_len(self) -> None:
|
||||
self.assertEqual(len(self.store), 4)
|
||||
self.assertEqual(len(self.store), 5)
|
||||
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[5]
|
||||
_ = self.store[6]
|
||||
locations = self.store[1] # no Exception
|
||||
with self.assertRaises(KeyError):
|
||||
_ = locations[7]
|
||||
@@ -71,7 +77,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])
|
||||
self.assertEqual(sorted(self.store), [1, 2, 3, 4, 5])
|
||||
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]))
|
||||
@@ -85,13 +91,26 @@ 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)), [])
|
||||
self.assertEqual(sorted(self.store.find_item({5}, 99)), [])
|
||||
# valid matches
|
||||
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}})
|
||||
@@ -196,18 +215,20 @@ class TestPurePythonLocationStoreConstructor(Base.TestLocationStoreConstructor):
|
||||
super().setUp()
|
||||
|
||||
|
||||
@unittest.skipIf(LocationStore is _LocationStore, "_speedups not available")
|
||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_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, "_speedups not available")
|
||||
@unittest.skipIf(LocationStore is _LocationStore and not ci, "_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()
|
||||
|
||||
|
||||
0
test/options/__init__.py
Normal file
0
test/options/__init__.py
Normal file
67
test/options/test_option_classes.py
Normal file
67
test/options/test_option_classes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
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)
|
||||
15
typings/kivy/core/window.pyi
Normal file
15
typings/kivy/core/window.pyi
Normal file
@@ -0,0 +1,15 @@
|
||||
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):
|
||||
...
|
||||
2
typings/kivy/event.pyi
Normal file
2
typings/kivy/event.pyi
Normal file
@@ -0,0 +1,2 @@
|
||||
class EventDispatcher:
|
||||
...
|
||||
6
typings/kivy/uix/boxlayout.pyi
Normal file
6
typings/kivy/uix/boxlayout.pyi
Normal file
@@ -0,0 +1,6 @@
|
||||
from typing import Literal
|
||||
from .layout import Layout
|
||||
|
||||
|
||||
class BoxLayout(Layout):
|
||||
orientation: Literal['horizontal', 'vertical']
|
||||
@@ -1,8 +1,14 @@
|
||||
from typing import Any
|
||||
from typing import Any, Sequence
|
||||
|
||||
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: ...
|
||||
|
||||
17
typings/schema/__init__.pyi
Normal file
17
typings/schema/__init__.pyi
Normal file
@@ -0,0 +1,17 @@
|
||||
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):
|
||||
...
|
||||
@@ -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()
|
||||
if title_name in prebuilt_options:
|
||||
group.name = title_name
|
||||
assert title_name not in prebuilt_options or title_name == group.name, \
|
||||
f"Prebuilt group name \"{group.name}\" must be \"{title_name}\""
|
||||
|
||||
if group.name == "Item & Location Options":
|
||||
assert not any(option in item_and_loc_options for option in group.options), \
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import bisect
|
||||
import logging
|
||||
import pathlib
|
||||
import weakref
|
||||
from enum import Enum, auto
|
||||
from typing import Optional, Callable, List, Iterable
|
||||
from typing import Optional, Callable, List, Iterable, Tuple
|
||||
|
||||
from Utils import local_path
|
||||
from Utils import local_path, open_filename
|
||||
|
||||
|
||||
class Type(Enum):
|
||||
@@ -49,8 +52,10 @@ 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
|
||||
@@ -58,6 +63,7 @@ def launch_subprocess(func: Callable, name: str = None):
|
||||
process.start()
|
||||
processes.add(process)
|
||||
|
||||
|
||||
class SuffixIdentifier:
|
||||
suffixes: Iterable[str]
|
||||
|
||||
@@ -77,6 +83,80 @@ 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),
|
||||
@@ -84,6 +164,7 @@ 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')),
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
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, Optional
|
||||
from typing import Dict, List, TypedDict
|
||||
|
||||
from Utils import local_path, user_path
|
||||
|
||||
local_folder = os.path.dirname(__file__)
|
||||
user_folder = user_path("worlds") if user_path() != local_path() else None
|
||||
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
|
||||
|
||||
__all__ = {
|
||||
"network_data_package",
|
||||
@@ -44,7 +50,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: Optional[float] = None
|
||||
time_taken: float = -1.0
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}({self.path}, is_zip={self.is_zip}, relative={self.relative})"
|
||||
@@ -88,7 +94,6 @@ 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
|
||||
@@ -103,7 +108,12 @@ 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():
|
||||
world_sources.append(WorldSource(file_name, relative=relative))
|
||||
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")
|
||||
elif entry.is_file() and entry.name.endswith(".apworld"):
|
||||
world_sources.append(WorldSource(file_name, is_zip=True, relative=relative))
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ 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
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ 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")
|
||||
@@ -116,7 +121,7 @@ class SC2Process:
|
||||
async def __aexit__(self, *args):
|
||||
logger.exception("async exit")
|
||||
await self._close_connection()
|
||||
kill_switch.kill_all()
|
||||
kill_switch.kill(self)
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
|
||||
@property
|
||||
|
||||
@@ -2,7 +2,8 @@ from __future__ import annotations
|
||||
|
||||
from typing import Dict
|
||||
|
||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, Option, DefaultOnToggle, DeathLink, Range, Toggle, PerGameCommonOptions
|
||||
|
||||
|
||||
class FreeincarnateMax(Range):
|
||||
@@ -223,22 +224,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,
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from BaseClasses import MultiWorld, Region, Entrance, LocationProgressType
|
||||
from Options import PerGameCommonOptions
|
||||
from .Locations import location_table, LocationData, AdventureLocation, dragon_room_to_region
|
||||
|
||||
|
||||
@@ -24,7 +25,7 @@ def connect(world: MultiWorld, player: int, source: str, target: str, rule: call
|
||||
connect(world, player, target, source, rule, True)
|
||||
|
||||
|
||||
def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
|
||||
def create_regions(options: PerGameCommonOptions, multiworld: MultiWorld, player: int, dragon_rooms: []) -> None:
|
||||
|
||||
menu = Region("Menu", player, multiworld)
|
||||
|
||||
@@ -74,7 +75,7 @@ def create_regions(multiworld: MultiWorld, player: int, dragon_rooms: []) -> Non
|
||||
credits_room_far_side.exits.append(Entrance(player, "CreditsFromFarSide", credits_room_far_side))
|
||||
multiworld.regions.append(credits_room_far_side)
|
||||
|
||||
dragon_slay_check = multiworld.dragon_slay_check[player].value
|
||||
dragon_slay_check = options.dragon_slay_check.value
|
||||
priority_locations = determine_priority_locations(multiworld, dragon_slay_check)
|
||||
|
||||
for name, location_data in location_table.items():
|
||||
|
||||
@@ -6,7 +6,7 @@ from BaseClasses import LocationProgressType
|
||||
|
||||
def set_rules(self) -> None:
|
||||
world = self.multiworld
|
||||
use_bat_logic = world.bat_logic[self.player].value == BatLogic.option_use_logic
|
||||
use_bat_logic = self.options.bat_logic.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 = world.dragon_slay_check[self.player].value
|
||||
dragon_slay_check = self.options.dragon_slay_check.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),
|
||||
|
||||
@@ -15,7 +15,8 @@ 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 adventure_option_definitions, DragonRandoType, DifficultySwitchA, DifficultySwitchB
|
||||
from .Options import DragonRandoType, DifficultySwitchA, DifficultySwitchB, \
|
||||
AdventureOptions
|
||||
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
|
||||
@@ -109,7 +110,7 @@ class AdventureWorld(World):
|
||||
game: ClassVar[str] = "Adventure"
|
||||
web: ClassVar[WebWorld] = AdventureWeb()
|
||||
|
||||
option_definitions: ClassVar[Dict[str, AssembleOptions]] = adventure_option_definitions
|
||||
options_dataclass = AdventureOptions
|
||||
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()}
|
||||
@@ -149,18 +150,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.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.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.created_items = 0
|
||||
|
||||
if self.dragon_slay_check == 0:
|
||||
@@ -227,7 +228,7 @@ class AdventureWorld(World):
|
||||
extra_filler_count = num_locations - self.created_items
|
||||
|
||||
# traps would probably go here, if enabled
|
||||
freeincarnate_max = self.multiworld.freeincarnate_max[self.player].value
|
||||
freeincarnate_max = self.options.freeincarnate_max.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
|
||||
@@ -247,7 +248,7 @@ class AdventureWorld(World):
|
||||
self.created_items += 1
|
||||
|
||||
def create_regions(self) -> None:
|
||||
create_regions(self.multiworld, self.player, self.dragon_rooms)
|
||||
create_regions(self.options, self.multiworld, self.player, self.dragon_rooms)
|
||||
|
||||
set_rules = set_rules
|
||||
|
||||
@@ -354,7 +355,7 @@ class AdventureWorld(World):
|
||||
auto_collect_locations: [AdventureAutoCollectLocation] = []
|
||||
local_item_to_location: {int, int} = {}
|
||||
bat_no_touch_locs: [LocationData] = []
|
||||
bat_logic: int = self.multiworld.bat_logic[self.player].value
|
||||
bat_logic: int = self.options.bat_logic.value
|
||||
try:
|
||||
rom_deltas: { int, int } = {}
|
||||
self.place_dragons(rom_deltas)
|
||||
@@ -421,7 +422,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.multiworld.connector_multi_slot[self.player].value:
|
||||
if self.options.connector_multi_slot.value:
|
||||
rom_deltas[connector_port_offset] = (self.player & 0xff)
|
||||
else:
|
||||
rom_deltas[connector_port_offset] = 0
|
||||
|
||||
@@ -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.name)
|
||||
data = dw_requirements.get(loc.parent_region.name)
|
||||
else:
|
||||
data = dw_bonus_requirements.get(loc.name)
|
||||
data = dw_bonus_requirements.get(loc.parent_region.name)
|
||||
|
||||
if data is None:
|
||||
return
|
||||
|
||||
@@ -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 Cathedral flower tube entrance": 698128,
|
||||
"Mithalas City, urn in the Castle 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, Jelly Egg": 698185,
|
||||
"Kelp Forest top left area, Jelly Egg": 698185,
|
||||
}
|
||||
|
||||
locations_forest_tl_fp = {
|
||||
@@ -332,7 +332,7 @@ class AquariaLocations:
|
||||
}
|
||||
|
||||
locations_veil_tr_l = {
|
||||
"The Veil top right area, bulb in the top of the waterfall": 698080,
|
||||
"The Veil top right area, bulb at the top of the waterfall": 698080,
|
||||
"The Veil top right area, Transturtle": 698210,
|
||||
}
|
||||
|
||||
|
||||
@@ -771,6 +771,7 @@ 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))
|
||||
|
||||
@@ -983,7 +984,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 Cathedral flower tube entrance", self.player),
|
||||
add_rule(self.multiworld.get_location("Mithalas City, urn in the Castle 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))
|
||||
@@ -1023,7 +1024,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 in the top of the waterfall", self.player),
|
||||
add_rule(self.multiworld.get_location("The Veil top right area, bulb at 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:
|
||||
@@ -1175,7 +1176,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 in the top of the waterfall",
|
||||
self.multiworld.get_location("The Veil top right area, bulb at 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",
|
||||
|
||||
@@ -167,14 +167,10 @@ 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 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)
|
||||
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:
|
||||
"""
|
||||
|
||||
@@ -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 Cathedral flower tube entrance",
|
||||
"Mithalas City, urn in the Castle 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, Jelly Egg",
|
||||
"Kelp Forest top left area, 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 in the top of the waterfall",
|
||||
"The Veil top right area, bulb at 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",
|
||||
|
||||
@@ -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 Cathedral flower tube entrance",
|
||||
"Mithalas City, urn in the Castle 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 in the top of the waterfall",
|
||||
"The Veil top right area, bulb at 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",
|
||||
|
||||
@@ -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 in the top of the waterfall",
|
||||
"The Veil top right area, bulb at 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",
|
||||
|
||||
@@ -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 in the top of the waterfall",
|
||||
"The Veil top right area, bulb at 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",
|
||||
|
||||
@@ -18,6 +18,9 @@ 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"
|
||||
]
|
||||
|
||||
@@ -109,7 +109,7 @@ class BombRushCyberfunkWorld(World):
|
||||
def create_items(self):
|
||||
rep_locations: int = 87
|
||||
if self.options.skip_polo_photos:
|
||||
rep_locations -= 18
|
||||
rep_locations -= 17
|
||||
|
||||
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["name"]:
|
||||
if self.options.skip_polo_photos and "Polo" in loc["game_id"]:
|
||||
continue
|
||||
stage: Region = self.get_region(loc["stage"])
|
||||
stage.add_locations({loc["name"]: base_id + index})
|
||||
|
||||
@@ -64,3 +64,4 @@ 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']
|
||||
|
||||
@@ -2,6 +2,7 @@ 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
|
||||
@@ -212,7 +213,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 = 21
|
||||
range_end = 20
|
||||
default = 1
|
||||
|
||||
|
||||
@@ -296,6 +297,9 @@ 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():
|
||||
|
||||
@@ -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"
|
||||
verify_item_name = True
|
||||
valid_keys = default_itempool_option.keys()
|
||||
default = default_itempool_option
|
||||
|
||||
|
||||
|
||||
@@ -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 exclusion_item_table["StatUps"] \
|
||||
or item in SupportAbility_Table or item in 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
|
||||
|
||||
|
||||
@@ -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("Orange Tower Seventh Floor - MASTERY"))
|
||||
self.assertFalse(self.can_reach_location("The Fearless - MASTERY"))
|
||||
|
||||
self.collect_by_name("Gray")
|
||||
self.assertTrue(self.can_reach_location("Orange Tower Basement - THE LIBRARY"))
|
||||
self.assertTrue(self.can_reach_location("Orange Tower Seventh Floor - MASTERY"))
|
||||
self.assertTrue(self.can_reach_location("The Fearless - MASTERY"))
|
||||
|
||||
@@ -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 worlds.messenger.portals import CHECKPOINTS, PORTALS, SHOP_POINTS
|
||||
from .portals import CHECKPOINTS, PORTALS, SHOP_POINTS
|
||||
|
||||
|
||||
class MessengerAccessibility(Accessibility):
|
||||
|
||||
@@ -306,8 +306,7 @@ def write_tokens(world: "MLSSWorld", patch: MLSSProcedurePatch) -> None:
|
||||
if world.options.scale_stats:
|
||||
patch.write_token(APTokenTypes.WRITE, 0xD00002, bytes([0x1]))
|
||||
|
||||
if world.options.xp_multiplier:
|
||||
patch.write_token(APTokenTypes.WRITE, 0xD00003, bytes([world.options.xp_multiplier.value]))
|
||||
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]))
|
||||
|
||||
@@ -22,12 +22,15 @@ 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, # 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.
|
||||
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
|
||||
]
|
||||
|
||||
DIFF_OVERRIDES: List[str] = [
|
||||
@@ -50,7 +53,7 @@ class MuseDashCollections:
|
||||
song_items: Dict[str, SongData] = {}
|
||||
song_locations: Dict[str, int] = {}
|
||||
|
||||
vfx_trap_items: Dict[str, int] = {
|
||||
trap_items: Dict[str, int] = {
|
||||
"Bad Apple Trap": STARTING_CODE + 1,
|
||||
"Pixelate Trap": STARTING_CODE + 2,
|
||||
"Ripple Trap": STARTING_CODE + 3,
|
||||
@@ -58,14 +61,16 @@ class MuseDashCollections:
|
||||
"Chromatic Aberration Trap": STARTING_CODE + 5,
|
||||
"Background Freeze Trap": STARTING_CODE + 6,
|
||||
"Gray Scale Trap": STARTING_CODE + 7,
|
||||
"Focus Line Trap": STARTING_CODE + 10,
|
||||
}
|
||||
|
||||
sfx_trap_items: Dict[str, int] = {
|
||||
"Nyaa SFX Trap": STARTING_CODE + 8,
|
||||
"Error SFX Trap": STARTING_CODE + 9,
|
||||
"Focus Line Trap": STARTING_CODE + 10,
|
||||
}
|
||||
|
||||
sfx_trap_items: List[str] = [
|
||||
"Nyaa SFX Trap",
|
||||
"Error SFX Trap",
|
||||
]
|
||||
|
||||
filler_items: Dict[str, int] = {
|
||||
"Great To Perfect (10 Pack)": STARTING_CODE + 30,
|
||||
"Miss To Great (5 Pack)": STARTING_CODE + 31,
|
||||
@@ -78,7 +83,7 @@ class MuseDashCollections:
|
||||
"Extra Life": 1,
|
||||
}
|
||||
|
||||
item_names_to_id: ChainMap = ChainMap({}, filler_items, sfx_trap_items, vfx_trap_items)
|
||||
item_names_to_id: ChainMap = ChainMap({}, filler_items, trap_items)
|
||||
location_names_to_id: ChainMap = ChainMap(song_locations, album_locations)
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -171,6 +176,9 @@ 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
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
from typing import Dict
|
||||
from Options import Toggle, Option, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions
|
||||
from Options import Toggle, Range, Choice, DeathLink, ItemSet, OptionSet, PerGameCommonOptions, OptionGroup, Removed
|
||||
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):
|
||||
"""Which non-[Muse Plus] DLC packs can be chosen as randomised songs."""
|
||||
"""
|
||||
Choose which DLC Packs will be included in the pool of chooseable songs.
|
||||
|
||||
Note: The [Just As Planned] DLC contains all [Muse Plus] 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,7 +33,8 @@ 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.
|
||||
"""
|
||||
@@ -44,7 +45,8 @@ 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
|
||||
@@ -66,8 +68,11 @@ 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.
|
||||
- Difficulty Mode must be set to Manual."""
|
||||
"""
|
||||
Ensures that 1 difficulty has at least 1 this value or higher per song.
|
||||
|
||||
Note: Difficulty Mode must be set to Manual.
|
||||
"""
|
||||
display_name = "Manual Difficulty Min"
|
||||
range_start = 1
|
||||
range_end = 11
|
||||
@@ -76,8 +81,11 @@ 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.
|
||||
- Difficulty Mode must be set to Manual."""
|
||||
"""
|
||||
Ensures that 1 difficulty has at least 1 this value or lower per song.
|
||||
|
||||
Note: Difficulty Mode must be set to Manual.
|
||||
"""
|
||||
display_name = "Manual Difficulty Max"
|
||||
range_start = 1
|
||||
range_end = 11
|
||||
@@ -85,7 +93,8 @@ 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
|
||||
@@ -104,7 +113,9 @@ 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
|
||||
@@ -121,19 +132,18 @@ class MusicSheetWinCountPercentage(Range):
|
||||
display_name = "Music Sheets Needed to Win"
|
||||
|
||||
|
||||
class TrapTypes(Choice):
|
||||
"""This controls the types of traps that can be added to the pool.
|
||||
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.
|
||||
- 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 = "Available Trap Types"
|
||||
option_None = 0
|
||||
option_VFX = 1
|
||||
option_SFX = 2
|
||||
option_All = 3
|
||||
default = 3
|
||||
display_name = "Chosen Traps"
|
||||
default = {}
|
||||
valid_keys = {trap for trap in MuseDashCollections.trap_items.keys()}
|
||||
|
||||
|
||||
class TrapCountPercentage(Range):
|
||||
@@ -145,24 +155,49 @@ class TrapCountPercentage(Range):
|
||||
|
||||
|
||||
class IncludeSongs(ItemSet):
|
||||
"""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.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
verify_item_name = True
|
||||
display_name = "Include Songs"
|
||||
|
||||
|
||||
class ExcludeSongs(ItemSet):
|
||||
"""Any song listed here will be excluded from being a part of the seed."""
|
||||
"""
|
||||
These songs will be guaranteed to not show up within the seed.
|
||||
|
||||
Note: Does not affect songs within the "Include Songs" list.
|
||||
"""
|
||||
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
|
||||
@@ -173,8 +208,12 @@ class MuseDashOptions(PerGameCommonOptions):
|
||||
grade_needed: GradeNeeded
|
||||
music_sheet_count_percentage: MusicSheetCountPercentage
|
||||
music_sheet_win_count_percentage: MusicSheetWinCountPercentage
|
||||
available_trap_types: TrapTypes
|
||||
chosen_traps: ChosenTraps
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
MuseDashPresets: Dict[str, Dict[str, Any]] = {
|
||||
# An option to support Short Sync games. 40 songs.
|
||||
"No DLC - Short": {
|
||||
"allow_just_as_planned_dlc_songs": False,
|
||||
"dlc_packs": [],
|
||||
"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": {
|
||||
"allow_just_as_planned_dlc_songs": True,
|
||||
"dlc_packs": ["Muse Plus"],
|
||||
"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": {
|
||||
"allow_just_as_planned_dlc_songs": True,
|
||||
"dlc_packs": ["Muse Plus"],
|
||||
"starting_song_count": 8,
|
||||
"additional_song_count": 91,
|
||||
"music_sheet_count_percentage": 20,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from BaseClasses import Region, Item, ItemClassification, Entrance, Tutorial
|
||||
from typing import List, ClassVar, Type
|
||||
from BaseClasses import Region, Item, ItemClassification, Tutorial
|
||||
from typing import List, ClassVar, Type, Set
|
||||
from math import floor
|
||||
from Options import PerGameCommonOptions
|
||||
|
||||
from .Options import MuseDashOptions
|
||||
from .Options import MuseDashOptions, md_option_groups
|
||||
from .Items import MuseDashSongItem, MuseDashFixedItem
|
||||
from .Locations import MuseDashLocation
|
||||
from .MuseDashCollection import MuseDashCollections
|
||||
@@ -35,6 +35,7 @@ class MuseDashWebWorld(WebWorld):
|
||||
|
||||
tutorials = [setup_en, setup_es]
|
||||
options_presets = MuseDashPresets
|
||||
option_groups = md_option_groups
|
||||
|
||||
|
||||
class MuseDashWorld(World):
|
||||
@@ -72,8 +73,6 @@ 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()
|
||||
@@ -88,7 +87,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)
|
||||
available_song_keys = self.handle_plando(available_song_keys, dlc_songs)
|
||||
|
||||
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:
|
||||
@@ -109,7 +108,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]) -> List[str]:
|
||||
def handle_plando(self, available_song_keys: List[str], dlc_songs: Set[str]) -> List[str]:
|
||||
song_items = self.md_collection.song_items
|
||||
|
||||
start_items = self.options.start_inventory.value.keys()
|
||||
@@ -117,7 +116,9 @@ 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]
|
||||
@@ -148,7 +149,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 fufilled
|
||||
# Next, make sure the starting songs are fulfilled
|
||||
if len(self.starting_songs) < starting_song_count:
|
||||
for _ in range(len(self.starting_songs), starting_song_count):
|
||||
if len(available_song_keys) > 0:
|
||||
@@ -156,7 +157,7 @@ class MuseDashWorld(World):
|
||||
else:
|
||||
self.starting_songs.append(self.included_songs.pop())
|
||||
|
||||
# Then attempt to fufill any remaining songs for interim songs
|
||||
# Then attempt to fulfill 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:
|
||||
@@ -174,11 +175,7 @@ class MuseDashWorld(World):
|
||||
if filler:
|
||||
return MuseDashFixedItem(name, ItemClassification.filler, filler, self.player)
|
||||
|
||||
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)
|
||||
trap = self.md_collection.trap_items.get(name)
|
||||
if trap:
|
||||
return MuseDashFixedItem(name, ItemClassification.trap, trap, self.player)
|
||||
|
||||
@@ -252,9 +249,7 @@ class MuseDashWorld(World):
|
||||
|
||||
def create_regions(self) -> None:
|
||||
menu_region = Region("Menu", self.player, self.multiworld)
|
||||
song_select_region = Region("Song Select", self.player, self.multiworld)
|
||||
self.multiworld.regions += [menu_region, song_select_region]
|
||||
menu_region.connect(song_select_region)
|
||||
self.multiworld.regions += [menu_region]
|
||||
|
||||
# Make a collection of all songs available for this rando.
|
||||
# 1. All starting songs
|
||||
@@ -268,35 +263,27 @@ class MuseDashWorld(World):
|
||||
self.random.shuffle(included_song_copy)
|
||||
all_selected_locations.extend(included_song_copy)
|
||||
|
||||
# Make a region per song/album, then adds 1-2 item locations to them
|
||||
# Adds 2 item locations per song/album to the menu region.
|
||||
for i in range(0, len(all_selected_locations)):
|
||||
name = all_selected_locations[i]
|
||||
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))
|
||||
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)
|
||||
|
||||
# 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)
|
||||
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)
|
||||
|
||||
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]:
|
||||
sfx_traps_available = self.options.allow_just_as_planned_dlc_songs.value
|
||||
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]
|
||||
|
||||
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
|
||||
return [trap for trap in full_trap_list if trap in self.options.chosen_traps.value]
|
||||
|
||||
def get_trap_count(self) -> int:
|
||||
multiplier = self.options.trap_count_percentage.value / 100.0
|
||||
|
||||
@@ -9,25 +9,26 @@ 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()
|
||||
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()}
|
||||
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()}
|
||||
|
||||
collection.__init__()
|
||||
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()}
|
||||
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()}
|
||||
|
||||
self.assertDictEqual(itemsBefore, itemsAfter, "Item ID changed after secondary init.")
|
||||
self.assertDictEqual(locationsBefore, locationsAfter, "Location ID changed after secondary init.")
|
||||
self.assertDictEqual(items_before, items_after, "Item ID changed after secondary init.")
|
||||
self.assertDictEqual(locations_before, locations_after, "Location ID changed after secondary init.")
|
||||
|
||||
def test_free_dlc_included_in_base_songs(self) -> None:
|
||||
collection = MuseDashCollections()
|
||||
|
||||
@@ -3,31 +3,31 @@ from . import MuseDashTestBase
|
||||
|
||||
class DifficultyRanges(MuseDashTestBase):
|
||||
def test_all_difficulty_ranges(self) -> None:
|
||||
muse_dash_world = self.multiworld.worlds[1]
|
||||
muse_dash_world = self.get_world()
|
||||
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(inputRange, lower, upper):
|
||||
self.assertEqual(inputRange[0], lower)
|
||||
self.assertEqual(inputRange[1], upper)
|
||||
def test_range(input_range, lower, upper):
|
||||
self.assertEqual(input_range[0], lower)
|
||||
self.assertEqual(input_range[1], upper)
|
||||
|
||||
songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, inputRange[0], inputRange[1])
|
||||
songs = muse_dash_world.md_collection.get_songs_with_settings(dlc_set, False, input_range[0], input_range[1])
|
||||
for songKey in songs:
|
||||
song = muse_dash_world.md_collection.song_items[songKey]
|
||||
if (song.easy is not None and inputRange[0] <= song.easy <= inputRange[1]):
|
||||
if song.easy is not None and input_range[0] <= song.easy <= input_range[1]:
|
||||
continue
|
||||
|
||||
if (song.hard is not None and inputRange[0] <= song.hard <= inputRange[1]):
|
||||
if song.hard is not None and input_range[0] <= song.hard <= input_range[1]:
|
||||
continue
|
||||
|
||||
if (song.master is not None and inputRange[0] <= song.master <= inputRange[1]):
|
||||
if song.master is not None and input_range[0] <= song.master <= input_range[1]:
|
||||
continue
|
||||
|
||||
self.fail(f"Invalid song '{songKey}' was given for range '{inputRange[0]} to {inputRange[1]}'")
|
||||
self.fail(f"Invalid song '{songKey}' was given for range '{input_range[0]} to {input_range[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.multiworld.worlds[1]
|
||||
muse_dash_world = self.get_world()
|
||||
|
||||
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.")
|
||||
|
||||
@@ -4,7 +4,32 @@ from . import MuseDashTestBase
|
||||
class TestPlandoSettings(MuseDashTestBase):
|
||||
options = {
|
||||
"additional_song_count": 15,
|
||||
"allow_just_as_planned_dlc_songs": True,
|
||||
"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"},
|
||||
"include_songs": [
|
||||
"Operation Blade",
|
||||
"Autumn Moods",
|
||||
@@ -13,15 +38,15 @@ class TestPlandoSettings(MuseDashTestBase):
|
||||
}
|
||||
|
||||
def test_included_songs_didnt_grow_item_count(self) -> None:
|
||||
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)}")
|
||||
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.multiworld.worlds[1]
|
||||
# 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()
|
||||
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.assertIn("Fireflies", songs, "Logical songs is missing a plando song: Fireflies")
|
||||
self.assertNotIn("Fireflies", songs, "Logical songs has added a filtered a plando song: Fireflies")
|
||||
|
||||
33
worlds/musedash/test/TestTrapOption.py
Normal file
33
worlds/musedash/test/TestTrapOption.py
Normal file
@@ -0,0 +1,33 @@
|
||||
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.")
|
||||
@@ -4,30 +4,33 @@ 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,
|
||||
"allow_just_as_planned_dlc_songs": False,
|
||||
"dlc_packs": [],
|
||||
"streamer_mode_enabled": True,
|
||||
"song_difficulty_mode": 6,
|
||||
"song_difficulty_min": 11,
|
||||
"song_difficulty_max": 11,
|
||||
}
|
||||
|
||||
|
||||
class TestWorstCaseMidDifficulty(MuseDashTestBase):
|
||||
options = {
|
||||
"starting_song_count": 10,
|
||||
"allow_just_as_planned_dlc_songs": False,
|
||||
"dlc_packs": [],
|
||||
"streamer_mode_enabled": True,
|
||||
"song_difficulty_mode": 6,
|
||||
"song_difficulty_min": 6,
|
||||
"song_difficulty_max": 6,
|
||||
}
|
||||
|
||||
|
||||
class TestWorstCaseLowDifficulty(MuseDashTestBase):
|
||||
options = {
|
||||
"starting_song_count": 10,
|
||||
"allow_just_as_planned_dlc_songs": False,
|
||||
"dlc_packs": [],
|
||||
"streamer_mode_enabled": True,
|
||||
"song_difficulty_mode": 6,
|
||||
"song_difficulty_min": 1,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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])
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ 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
|
||||
|
||||
@@ -25,13 +25,20 @@ IGNORABLE_MAPS = {
|
||||
}
|
||||
"""These maps exist but don't show up in the rando or are unused, and so should be discarded"""
|
||||
|
||||
POSTGAME_MAPS = {
|
||||
OUT_OF_LOGIC_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. Those encounter slots should be ignored for logical access to a species."""
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
NUM_REAL_SPECIES = 386
|
||||
|
||||
|
||||
@@ -4,9 +4,8 @@ Functions related to pokemon species and moves
|
||||
import functools
|
||||
from typing import TYPE_CHECKING, Dict, List, Set, Optional, Tuple
|
||||
|
||||
from Options import Toggle
|
||||
|
||||
from .data import NUM_REAL_SPECIES, POSTGAME_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData, SpeciesData, data
|
||||
from .data import (NUM_REAL_SPECIES, OUT_OF_LOGIC_MAPS, EncounterTableData, LearnsetMove, MiscPokemonData,
|
||||
SpeciesData, data)
|
||||
from .options import (Goal, HmCompatibility, LevelUpMoves, RandomizeAbilities, RandomizeLegendaryEncounters,
|
||||
RandomizeMiscPokemon, RandomizeStarters, RandomizeTypes, RandomizeWildPokemon,
|
||||
TmTutorCompatibility)
|
||||
@@ -266,7 +265,8 @@ 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:
|
||||
if not placed_priority_species and len(priority_species) > 0 \
|
||||
and map_name not in OUT_OF_LOGIC_MAPS:
|
||||
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_data.name not in POSTGAME_MAPS:
|
||||
if world.options.dexsanity and map_name not in OUT_OF_LOGIC_MAPS:
|
||||
already_placed.add(new_species_id)
|
||||
|
||||
# Actually create the new list of slots and encounter table
|
||||
|
||||
@@ -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
|
||||
from .options import ItemWeights, ROR2Options, ror2_option_groups
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from .regions import create_explore_regions, create_classic_regions
|
||||
from typing import List, Dict, Any
|
||||
@@ -23,6 +23,8 @@ class RiskOfWeb(WebWorld):
|
||||
["Ijwu", "Kindasneaki"]
|
||||
)]
|
||||
|
||||
option_groups = ror2_option_groups
|
||||
|
||||
|
||||
class RiskOfRainWorld(World):
|
||||
"""
|
||||
@@ -44,7 +46,7 @@ class RiskOfRainWorld(World):
|
||||
}
|
||||
location_name_to_id = item_pickups
|
||||
|
||||
required_client_version = (0, 4, 5)
|
||||
required_client_version = (0, 5, 0)
|
||||
web = RiskOfWeb()
|
||||
total_revivals: int
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions
|
||||
from Options import Toggle, DefaultOnToggle, DeathLink, Range, Choice, PerGameCommonOptions, OptionGroup
|
||||
|
||||
|
||||
# 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 item_pool_presets to true if you want to use one of these presets.
|
||||
"""Set Use Item Weight Presets to yes 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,6 +375,44 @@ 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
|
||||
@@ -399,10 +437,10 @@ class ROR2Options(PerGameCommonOptions):
|
||||
item_weights: ItemWeights
|
||||
item_pool_presets: ItemPoolPresetToggle
|
||||
# define the weights of the generated item pool.
|
||||
green_scrap: GreenScrap
|
||||
red_scrap: RedScrap
|
||||
yellow_scrap: YellowScrap
|
||||
white_scrap: WhiteScrap
|
||||
green_scrap: GreenScrap
|
||||
yellow_scrap: YellowScrap
|
||||
red_scrap: RedScrap
|
||||
common_item: CommonItem
|
||||
uncommon_item: UncommonItem
|
||||
legendary_item: LegendaryItem
|
||||
|
||||
@@ -19,11 +19,13 @@ 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)"]),
|
||||
"Titanic Plains", "Titanic Plains (2)",
|
||||
"Verdant Falls"]),
|
||||
"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"]),
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
|
||||
@@ -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 = 0) -> 'ColouredMessage':
|
||||
def location(self, location_id: int, player_id: int) -> 'ColouredMessage':
|
||||
add_json_location(self.parts, location_id, player_id)
|
||||
return self
|
||||
def item(self, item_id: int, player_id: int = 0, flags: int = 0) -> 'ColouredMessage':
|
||||
def item(self, item_id: int, player_id: int, flags: int = 0) -> 'ColouredMessage':
|
||||
add_json_item(self.parts, item_id, player_id, flags)
|
||||
return self
|
||||
def player(self, player_id: int) -> 'ColouredMessage':
|
||||
@@ -122,7 +122,6 @@ 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"""
|
||||
@@ -257,7 +256,7 @@ class StarcraftClientProcessor(ClientCommandProcessor):
|
||||
for item in received_items_of_this_type:
|
||||
print_faction_title()
|
||||
has_printed_faction_title = True
|
||||
(ColouredMessage('* ').item(item.item, flags=item.flags)
|
||||
(ColouredMessage('* ').item(item.item, self.ctx.slot, flags=item.flags)
|
||||
(" from ").location(item.location, self.ctx.slot)
|
||||
(" by ").player(item.player)
|
||||
).send(self.ctx)
|
||||
@@ -278,7 +277,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, flags=item.flags)
|
||||
(ColouredMessage(' * ').item(item.item, self.ctx.slot, flags=item.flags)
|
||||
(" from ").location(item.location, self.ctx.slot)
|
||||
(" by ").player(item.player)
|
||||
).send(self.ctx)
|
||||
|
||||
@@ -10,15 +10,15 @@ class ItemDict(TypedDict):
|
||||
base_id = 82000
|
||||
|
||||
item_table: List[ItemDict] = [
|
||||
{"name": "Stick", "id": base_id + 1, "count": 8, "classification": ItemClassification.progression_skip_balancing},
|
||||
{"name": "Stick", "id": base_id + 1, "count": 0, "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": "Fishing Rod", "id": base_id + 7, "count": 2, "classification": ItemClassification.progression},
|
||||
{"name": "Progressive 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": 5, "classification": ItemClassification.progression_skip_balancing},
|
||||
{"name": "Toy Shovel", "id": base_id + 9, "count": 0, "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": 1, "classification": ItemClassification.useful},
|
||||
{"name": "Walkie Talkie", "id": base_id + 27, "count": 0, "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.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},
|
||||
{"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},
|
||||
|
||||
# Filler item determined by settings
|
||||
{"name": "13 Coins", "id": base_id + 37, "count": 0, "classification": ItemClassification.filler},
|
||||
|
||||
@@ -5,7 +5,7 @@ class LocationInfo(TypedDict):
|
||||
id: int
|
||||
inGameId: str
|
||||
needsShovel: bool
|
||||
purchase: bool
|
||||
purchase: int
|
||||
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": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beach Hut Seashell",
|
||||
"id": base_id + 2,
|
||||
"inGameId": "PickUps.2",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beach Umbrella Seashell",
|
||||
"id": base_id + 3,
|
||||
"inGameId": "PickUps.8",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sid Beach Mound Seashell",
|
||||
"id": base_id + 4,
|
||||
"inGameId": "PickUps.12",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sid Beach Seashell",
|
||||
"id": base_id + 5,
|
||||
"inGameId": "PickUps.11",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Shirley's Point Beach Seashell",
|
||||
"id": base_id + 6,
|
||||
"inGameId": "PickUps.18",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Shirley's Point Rock Seashell",
|
||||
"id": base_id + 7,
|
||||
"inGameId": "PickUps.17",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor's Center Beach Seashell",
|
||||
"id": base_id + 8,
|
||||
"inGameId": "PickUps.19",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "West River Seashell",
|
||||
"id": base_id + 9,
|
||||
"inGameId": "PickUps.10",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "West Riverbank Seashell",
|
||||
"id": base_id + 10,
|
||||
"inGameId": "PickUps.4",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Stone Tower Riverbank Seashell",
|
||||
"id": base_id + 11,
|
||||
"inGameId": "PickUps.23",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "North Beach Seashell",
|
||||
"id": base_id + 12,
|
||||
"inGameId": "PickUps.6",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "North Coast Seashell",
|
||||
"id": base_id + 13,
|
||||
"inGameId": "PickUps.7",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Boat Cliff Seashell",
|
||||
"id": base_id + 14,
|
||||
"inGameId": "PickUps.14",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Boat Isle Mound Seashell",
|
||||
"id": base_id + 15,
|
||||
"inGameId": "PickUps.22",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "East Coast Seashell",
|
||||
"id": base_id + 16,
|
||||
"inGameId": "PickUps.21",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "House North Beach Seashell",
|
||||
"id": base_id + 17,
|
||||
"inGameId": "PickUps.16",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Airstream Island North Seashell",
|
||||
"id": base_id + 18,
|
||||
"inGameId": "PickUps.13",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Airstream Island South Seashell",
|
||||
"id": base_id + 19,
|
||||
"inGameId": "PickUps.15",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Secret Island Beach Seashell",
|
||||
"id": base_id + 20,
|
||||
"inGameId": "PickUps.1",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Meteor Lake Seashell",
|
||||
"id": base_id + 126,
|
||||
"inGameId": "PickUps.20",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Good Creek Path Seashell",
|
||||
"id": base_id + 127,
|
||||
"inGameId": "PickUps.9",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"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": True,
|
||||
"needsShovel": False, "purchase": 40,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor's Center Shop Golden Feather 2",
|
||||
"id": base_id + 22,
|
||||
"inGameId": "CampRangerNPC[1]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 40,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor's Center Shop Hat",
|
||||
"id": base_id + 23,
|
||||
"inGameId": "CampRangerNPC[9]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 100,
|
||||
"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": True,
|
||||
"needsShovel": False, "purchase": 100,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Tough Bird Salesman Golden Feather 2",
|
||||
"id": base_id + 25,
|
||||
"inGameId": "ToughBirdNPC (1)[1]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 100,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Tough Bird Salesman Golden Feather 3",
|
||||
"id": base_id + 26,
|
||||
"inGameId": "ToughBirdNPC (1)[2]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 100,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Tough Bird Salesman Golden Feather 4",
|
||||
"id": base_id + 27,
|
||||
"inGameId": "ToughBirdNPC (1)[3]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 100,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Tough Bird Salesman (400 Coins)",
|
||||
"id": base_id + 28,
|
||||
"inGameId": "ToughBirdNPC (1)[9]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 400,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Beachstickball
|
||||
{"name": "Beachstickball (10 Hits)",
|
||||
"id": base_id + 29,
|
||||
"inGameId": "VolleyballOpponent[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beachstickball (20 Hits)",
|
||||
"id": base_id + 30,
|
||||
"inGameId": "VolleyballOpponent[1]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beachstickball (30 Hits)",
|
||||
"id": base_id + 31,
|
||||
"inGameId": "VolleyballOpponent[2]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Misc Item Locations
|
||||
{"name": "Shovel Kid Trade",
|
||||
"id": base_id + 32,
|
||||
"inGameId": "Frog_StandingNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Compass Guy",
|
||||
"id": base_id + 33,
|
||||
"inGameId": "Fox_WalkingNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Hawk Peak Bucket Rock",
|
||||
"id": base_id + 34,
|
||||
"inGameId": "Tools.23",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands Bucket Rock",
|
||||
"id": base_id + 35,
|
||||
"inGameId": "Tools.42",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Bill the Walrus Fisherman",
|
||||
"id": base_id + 36,
|
||||
"inGameId": "SittingNPC (1)[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Catch 3 Fish Reward",
|
||||
"id": base_id + 37,
|
||||
"inGameId": "FishBuyer[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Catch All Fish Reward",
|
||||
"id": base_id + 38,
|
||||
"inGameId": "FishBuyer[1]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7},
|
||||
{"name": "Permit Guy Bribe",
|
||||
"id": base_id + 39,
|
||||
"inGameId": "CamperNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Catch Fish with Permit",
|
||||
"id": base_id + 129,
|
||||
"inGameId": "Player[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Return Camping Permit",
|
||||
"id": base_id + 130,
|
||||
"inGameId": "CamperNPC[1]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Original Pickaxe Locations
|
||||
{"name": "Blocked Mine Pickaxe 1",
|
||||
"id": base_id + 40,
|
||||
"inGameId": "Tools.31",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Blocked Mine Pickaxe 2",
|
||||
"id": base_id + 41,
|
||||
"inGameId": "Tools.32",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Blocked Mine Pickaxe 3",
|
||||
"id": base_id + 42,
|
||||
"inGameId": "Tools.33",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"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": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Shirley's Point Beach Toy Shovel",
|
||||
"id": base_id + 44,
|
||||
"inGameId": "PickUps.30",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor's Center Beach Toy Shovel",
|
||||
"id": base_id + 45,
|
||||
"inGameId": "PickUps.29",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Blackwood Trail Rock Toy Shovel",
|
||||
"id": base_id + 46,
|
||||
"inGameId": "PickUps.26",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beach Hut Cliff Toy Shovel",
|
||||
"id": base_id + 128,
|
||||
"inGameId": "PickUps.28",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"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": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Below Lighthouse Walkway Stick",
|
||||
"id": base_id + 48,
|
||||
"inGameId": "Tools.3",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beach Hut Rocky Pool Sand Stick",
|
||||
"id": base_id + 49,
|
||||
"inGameId": "Tools.0",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Cliff Overlooking West River Waterfall Stick",
|
||||
"id": base_id + 50,
|
||||
"inGameId": "Tools.2",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Trail to Tough Bird Salesman Stick",
|
||||
"id": base_id + 51,
|
||||
"inGameId": "Tools.8",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "North Beach Stick",
|
||||
"id": base_id + 52,
|
||||
"inGameId": "Tools.4",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Beachstickball Court Stick",
|
||||
"id": base_id + 53,
|
||||
"inGameId": "VolleyballMinigame.4",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Stick Under Sid Beach Umbrella",
|
||||
"id": base_id + 54,
|
||||
"inGameId": "Tools.1",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"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": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"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": False,
|
||||
# "needsShovel": False, "purchase": 0,
|
||||
# "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": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Original Clothes Locations
|
||||
{"name": "Collect 15 Seashells",
|
||||
"id": base_id + 58,
|
||||
"inGameId": "LittleKidNPCVariant (1)[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Return to Shell Kid",
|
||||
"id": base_id + 132,
|
||||
"inGameId": "LittleKidNPCVariant (1)[1]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Taylor the Turtle Headband Gift",
|
||||
"id": base_id + 59,
|
||||
"inGameId": "Turtle_WalkingNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sue the Rabbit Shoes Reward",
|
||||
"id": base_id + 60,
|
||||
"inGameId": "Bunny_WalkingNPC (1)[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Purchase Sunhat",
|
||||
"id": base_id + 61,
|
||||
"inGameId": "SittingNPC[0]",
|
||||
"needsShovel": False, "purchase": True,
|
||||
"needsShovel": False, "purchase": 100,
|
||||
"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": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Ranger May Shell Necklace Golden Feather",
|
||||
"id": base_id + 63,
|
||||
"inGameId": "AuntMayNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sand Castle Golden Feather",
|
||||
"id": base_id + 64,
|
||||
"inGameId": "SandProvince.3",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Artist Golden Feather",
|
||||
"id": base_id + 65,
|
||||
"inGameId": "StandingNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor Camp Rock Golden Feather",
|
||||
"id": base_id + 66,
|
||||
"inGameId": "Feathers.8",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Outlook Cliff Golden Feather",
|
||||
"id": base_id + 67,
|
||||
"inGameId": "Feathers.2",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Meteor Lake Cliff Golden Feather",
|
||||
"id": base_id + 68,
|
||||
"inGameId": "Feathers.7",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Original Silver Feather Locations
|
||||
{"name": "Secret Island Peak",
|
||||
"id": base_id + 69,
|
||||
"inGameId": "PickUps.24",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 5, "minGoldenFeathersEasy": 7, "minGoldenFeathersBucket": 7},
|
||||
{"name": "Wristwatch Trade",
|
||||
"id": base_id + 70,
|
||||
"inGameId": "Goat_StandingNPC[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Golden Chests
|
||||
{"name": "Lighthouse Golden Chest",
|
||||
"id": base_id + 71,
|
||||
"inGameId": "Feathers.0",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Outlook Golden Chest",
|
||||
"id": base_id + 72,
|
||||
"inGameId": "Feathers.6",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Stone Tower Golden Chest",
|
||||
"id": base_id + 73,
|
||||
"inGameId": "Feathers.5",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "North Cliff Golden Chest",
|
||||
"id": base_id + 74,
|
||||
"inGameId": "Feathers.4",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 10, "minGoldenFeathersBucket": 10},
|
||||
|
||||
# Chests
|
||||
{"name": "Blackwood Cliff Chest",
|
||||
"id": base_id + 75,
|
||||
"inGameId": "Coins.22",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "White Coast Trail Chest",
|
||||
"id": base_id + 76,
|
||||
"inGameId": "Coins.6",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sid Beach Chest",
|
||||
"id": base_id + 77,
|
||||
"inGameId": "Coins.7",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sid Beach Buried Treasure Chest",
|
||||
"id": base_id + 78,
|
||||
"inGameId": "Coins.46",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Sid Beach Cliff Chest",
|
||||
"id": base_id + 79,
|
||||
"inGameId": "Coins.9",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor's Center Buried Chest",
|
||||
"id": base_id + 80,
|
||||
"inGameId": "Coins.94",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Visitor's Center Hidden Chest",
|
||||
"id": base_id + 81,
|
||||
"inGameId": "Coins.42",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Shirley's Point Chest",
|
||||
"id": base_id + 82,
|
||||
"inGameId": "Coins.10",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 2},
|
||||
{"name": "Caravan Cliff Chest",
|
||||
"id": base_id + 83,
|
||||
"inGameId": "Coins.12",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Caravan Arch Chest",
|
||||
"id": base_id + 84,
|
||||
"inGameId": "Coins.11",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "King Buried Treasure Chest",
|
||||
"id": base_id + 85,
|
||||
"inGameId": "Coins.41",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Good Creek Path Buried Chest",
|
||||
"id": base_id + 86,
|
||||
"inGameId": "Coins.48",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Good Creek Path West Chest",
|
||||
"id": base_id + 87,
|
||||
"inGameId": "Coins.33",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Good Creek Path East Chest",
|
||||
"id": base_id + 88,
|
||||
"inGameId": "Coins.62",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "West Waterfall Chest",
|
||||
"id": base_id + 89,
|
||||
"inGameId": "Coins.20",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Stone Tower West Cliff Chest",
|
||||
"id": base_id + 90,
|
||||
"inGameId": "PickUps.0",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Bucket Path Chest",
|
||||
"id": base_id + 91,
|
||||
"inGameId": "Coins.50",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Bucket Cliff Chest",
|
||||
"id": base_id + 92,
|
||||
"inGameId": "Coins.49",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
|
||||
{"name": "In Her Shadow Buried Treasure Chest",
|
||||
"id": base_id + 93,
|
||||
"inGameId": "Feathers.9",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Meteor Lake Buried Chest",
|
||||
"id": base_id + 94,
|
||||
"inGameId": "Coins.86",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Meteor Lake Chest",
|
||||
"id": base_id + 95,
|
||||
"inGameId": "Coins.64",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "House North Beach Chest",
|
||||
"id": base_id + 96,
|
||||
"inGameId": "Coins.65",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "East Coast Chest",
|
||||
"id": base_id + 97,
|
||||
"inGameId": "Coins.98",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Fisherman's Boat Chest 1",
|
||||
"id": base_id + 99,
|
||||
"inGameId": "Boat.0",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Fisherman's Boat Chest 2",
|
||||
"id": base_id + 100,
|
||||
"inGameId": "Boat.7",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Airstream Island Chest",
|
||||
"id": base_id + 101,
|
||||
"inGameId": "Coins.31",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "West River Waterfall Head Chest",
|
||||
"id": base_id + 102,
|
||||
"inGameId": "Coins.34",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Old Building Chest",
|
||||
"id": base_id + 103,
|
||||
"inGameId": "Coins.104",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Old Building West Chest",
|
||||
"id": base_id + 104,
|
||||
"inGameId": "Coins.109",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Old Building East Chest",
|
||||
"id": base_id + 105,
|
||||
"inGameId": "Coins.8",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Hawk Peak West Chest",
|
||||
"id": base_id + 106,
|
||||
"inGameId": "Coins.21",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
|
||||
{"name": "Hawk Peak East Buried Chest",
|
||||
"id": base_id + 107,
|
||||
"inGameId": "Coins.76",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
|
||||
{"name": "Hawk Peak Northeast Chest",
|
||||
"id": base_id + 108,
|
||||
"inGameId": "Coins.79",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 3, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 5},
|
||||
{"name": "Northern East Coast Chest",
|
||||
"id": base_id + 109,
|
||||
"inGameId": "Coins.45",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 2, "minGoldenFeathersBucket": 0},
|
||||
{"name": "North Coast Chest",
|
||||
"id": base_id + 110,
|
||||
"inGameId": "Coins.28",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "North Coast Buried Chest",
|
||||
"id": base_id + 111,
|
||||
"inGameId": "Coins.47",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Small South Island Buried Chest",
|
||||
"id": base_id + 112,
|
||||
"inGameId": "Coins.87",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Secret Island Bottom Chest",
|
||||
"id": base_id + 113,
|
||||
"inGameId": "Coins.88",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Secret Island Treehouse Chest",
|
||||
"id": base_id + 114,
|
||||
"inGameId": "Coins.89",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 1},
|
||||
{"name": "Sunhat Island Buried Chest",
|
||||
"id": base_id + 115,
|
||||
"inGameId": "Coins.112",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands South Buried Chest",
|
||||
"id": base_id + 116,
|
||||
"inGameId": "Coins.119",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands West Chest",
|
||||
"id": base_id + 117,
|
||||
"inGameId": "Coins.121",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands North Buried Chest",
|
||||
"id": base_id + 118,
|
||||
"inGameId": "Coins.117",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 1, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands East Chest",
|
||||
"id": base_id + 119,
|
||||
"inGameId": "Coins.120",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands South Hidden Chest",
|
||||
"id": base_id + 120,
|
||||
"inGameId": "Coins.124",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "A Stormy View Buried Treasure Chest",
|
||||
"id": base_id + 121,
|
||||
"inGameId": "Coins.113",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Orange Islands Ruins Buried Chest",
|
||||
"id": base_id + 122,
|
||||
"inGameId": "Coins.118",
|
||||
"needsShovel": True, "purchase": False,
|
||||
"needsShovel": True, "purchase": 0,
|
||||
"minGoldenFeathers": 2, "minGoldenFeathersEasy": 4, "minGoldenFeathersBucket": 0},
|
||||
|
||||
# Race Rewards
|
||||
{"name": "Lighthouse Race Reward",
|
||||
"id": base_id + 123,
|
||||
"inGameId": "RaceOpponent[0]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 2, "minGoldenFeathersEasy": 3, "minGoldenFeathersBucket": 1},
|
||||
{"name": "Old Building Race Reward",
|
||||
"id": base_id + 124,
|
||||
"inGameId": "RaceOpponent[1]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 1, "minGoldenFeathersEasy": 5, "minGoldenFeathersBucket": 0},
|
||||
{"name": "Hawk Peak Race Reward",
|
||||
"id": base_id + 125,
|
||||
"inGameId": "RaceOpponent[2]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 7, "minGoldenFeathersEasy": 9, "minGoldenFeathersBucket": 7},
|
||||
{"name": "Lose Race Gift",
|
||||
"id": base_id + 131,
|
||||
"inGameId": "RaceOpponent[9]",
|
||||
"needsShovel": False, "purchase": False,
|
||||
"needsShovel": False, "purchase": 0,
|
||||
"minGoldenFeathers": 0, "minGoldenFeathersEasy": 0, "minGoldenFeathersBucket": 0},
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from Options import Choice, PerGameCommonOptions, Range, StartInventoryPool, Toggle
|
||||
from Options import Choice, OptionGroup, PerGameCommonOptions, Range, StartInventoryPool, Toggle, DefaultOnToggle
|
||||
|
||||
class Goal(Choice):
|
||||
"""Choose the end goal.
|
||||
@@ -22,8 +22,10 @@ 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
|
||||
@@ -43,6 +45,20 @@ 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.
|
||||
@@ -76,6 +92,40 @@ 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
|
||||
@@ -84,6 +134,37 @@ 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
|
||||
])
|
||||
]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
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
|
||||
@@ -11,11 +13,23 @@ 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 != 2:
|
||||
if options.golden_feather_progression != GoldenFeatherProgression.option_hard:
|
||||
min_feathers = get_min_feathers(self, loc["minGoldenFeathers"], loc["minGoldenFeathersEasy"])
|
||||
|
||||
if options.buckets > 0 and loc["minGoldenFeathersBucket"] < min_feathers:
|
||||
@@ -32,11 +46,11 @@ def create_rules(self, location_table):
|
||||
|
||||
# Fishing Rules
|
||||
add_rule(multiworld.get_location("Catch 3 Fish Reward", player),
|
||||
lambda state: state.has("Fishing Rod", player))
|
||||
lambda state: state.has("Progressive Fishing Rod", player))
|
||||
add_rule(multiworld.get_location("Catch Fish with Permit", player),
|
||||
lambda state: state.has("Fishing Rod", player))
|
||||
lambda state: state.has("Progressive Fishing Rod", player))
|
||||
add_rule(multiworld.get_location("Catch All Fish Reward", player),
|
||||
lambda state: state.has("Fishing Rod", player))
|
||||
lambda state: state.has("Progressive Fishing Rod", player, 2))
|
||||
|
||||
# Misc Rules
|
||||
add_rule(multiworld.get_location("Return Camping Permit", player),
|
||||
@@ -59,15 +73,34 @@ 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 == 0:
|
||||
if options.golden_feather_progression == GoldenFeatherProgression.option_easy:
|
||||
min_feathers = min_golden_feathers_easy
|
||||
if min_feathers > options.golden_feathers:
|
||||
if options.goal != 1 and options.goal != 3:
|
||||
if options.goal not in {Goal.option_help_everyone, Goal.option_photo}:
|
||||
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
Reference in New Issue
Block a user