mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-23 14:43:21 -07:00
Merge pull request #2 from ArchipelagoMW/main
Adding main stuff to yacht dice fork
This commit is contained in:
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
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -62,6 +62,7 @@ Output Logs/
|
||||
/installdelete.iss
|
||||
/data/user.kv
|
||||
/datapackage
|
||||
/custom_worlds
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
|
||||
71
Launcher.py
71
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,6 +160,9 @@ 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
|
||||
@@ -170,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
|
||||
@@ -182,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:
|
||||
"""
|
||||
@@ -218,14 +207,47 @@ 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)
|
||||
|
||||
@@ -254,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,7 +177,7 @@ 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]]
|
||||
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
|
||||
@@ -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] = {}
|
||||
@@ -268,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:
|
||||
@@ -1926,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
|
||||
|
||||
@@ -2285,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()
|
||||
@@ -2305,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":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -132,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):
|
||||
@@ -144,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
|
||||
|
||||
@@ -157,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
|
||||
|
||||
@@ -213,6 +213,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";
|
||||
|
||||
@@ -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
|
||||
|
||||
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)
|
||||
201
test/hosting/webhost.py
Normal file
201
test/hosting/webhost.py
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
|
||||
poll_interval = .2
|
||||
|
||||
print(f"Starting room {room_id}")
|
||||
no_timeout = timeout <= 0
|
||||
while no_timeout or timeout > 0:
|
||||
response = app_client.get(f"/room/{room_id}")
|
||||
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]
|
||||
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)
|
||||
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):
|
||||
...
|
||||
@@ -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,21 @@
|
||||
import importlib
|
||||
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 +49,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 +93,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 +107,11 @@ 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))
|
||||
init_file_path = os.path.join(entry.path, '__init__.py')
|
||||
if os.path.isfile(init_file_path):
|
||||
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))
|
||||
|
||||
|
||||
@@ -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,5 @@
|
||||
from worlds.generic.Rules import forbid_items_for_player, add_rule
|
||||
from worlds.shorthike.Options import Goal, GoldenFeatherProgression, MinShopCheckLogic, ShopCheckLogic
|
||||
|
||||
def create_rules(self, location_table):
|
||||
multiworld = self.multiworld
|
||||
@@ -11,11 +12,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 +45,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 +72,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
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
from collections import Counter
|
||||
from typing import ClassVar, Dict, Any, Type
|
||||
from BaseClasses import Region, Location, Item, Tutorial
|
||||
from BaseClasses import ItemClassification, Region, Location, Item, Tutorial
|
||||
from Options import PerGameCommonOptions
|
||||
from worlds.AutoWorld import World, WebWorld
|
||||
from .Items import item_table, group_table, base_id
|
||||
from .Locations import location_table
|
||||
from .Rules import create_rules, get_min_feathers
|
||||
from .Options import ShortHikeOptions
|
||||
from .Options import ShortHikeOptions, shorthike_option_groups
|
||||
|
||||
class ShortHikeWeb(WebWorld):
|
||||
theme = "ocean"
|
||||
@@ -18,6 +17,7 @@ class ShortHikeWeb(WebWorld):
|
||||
"setup/en",
|
||||
["Chandler"]
|
||||
)]
|
||||
option_groups = shorthike_option_groups
|
||||
|
||||
class ShortHikeWorld(World):
|
||||
"""
|
||||
@@ -47,9 +47,14 @@ class ShortHikeWorld(World):
|
||||
item_id: int = self.item_name_to_id[name]
|
||||
id = item_id - base_id - 1
|
||||
|
||||
return ShortHikeItem(name, item_table[id]["classification"], item_id, player=self.player)
|
||||
classification = item_table[id]["classification"]
|
||||
if self.options.easier_races and name == "Running Shoes":
|
||||
classification = ItemClassification.progression
|
||||
|
||||
return ShortHikeItem(name, classification, item_id, player=self.player)
|
||||
|
||||
def create_items(self) -> None:
|
||||
itempool = []
|
||||
for item in item_table:
|
||||
count = item["count"]
|
||||
|
||||
@@ -57,18 +62,28 @@ class ShortHikeWorld(World):
|
||||
continue
|
||||
else:
|
||||
for i in range(count):
|
||||
self.multiworld.itempool.append(self.create_item(item["name"]))
|
||||
itempool.append(self.create_item(item["name"]))
|
||||
|
||||
feather_count = self.options.golden_feathers
|
||||
if self.options.goal == 1 or self.options.goal == 3:
|
||||
if feather_count < 12:
|
||||
feather_count = 12
|
||||
|
||||
junk = 45 - self.options.silver_feathers - feather_count - self.options.buckets
|
||||
self.multiworld.itempool += [self.create_item(self.get_filler_item_name()) for _ in range(junk)]
|
||||
self.multiworld.itempool += [self.create_item("Golden Feather") for _ in range(feather_count)]
|
||||
self.multiworld.itempool += [self.create_item("Silver Feather") for _ in range(self.options.silver_feathers)]
|
||||
self.multiworld.itempool += [self.create_item("Bucket") for _ in range(self.options.buckets)]
|
||||
itempool += [self.create_item("Golden Feather") for _ in range(feather_count)]
|
||||
itempool += [self.create_item("Silver Feather") for _ in range(self.options.silver_feathers)]
|
||||
itempool += [self.create_item("Bucket") for _ in range(self.options.buckets)]
|
||||
itempool += [self.create_item("Stick") for _ in range(self.options.sticks)]
|
||||
itempool += [self.create_item("Toy Shovel") for _ in range(self.options.toy_shovels)]
|
||||
|
||||
if self.options.random_walkie_talkie:
|
||||
itempool.append(self.create_item("Walkie Talkie"))
|
||||
else:
|
||||
self.multiworld.get_location("Lose Race Gift", self.player).place_locked_item(self.create_item("Walkie Talkie"))
|
||||
|
||||
junk = len(self.multiworld.get_unfilled_locations(self.player)) - len(itempool)
|
||||
itempool += [self.create_item(self.get_filler_item_name()) for _ in range(junk)]
|
||||
|
||||
self.multiworld.itempool += itempool
|
||||
|
||||
def create_regions(self) -> None:
|
||||
menu_region = Region("Menu", self.player, self.multiworld)
|
||||
@@ -92,20 +107,23 @@ class ShortHikeWorld(World):
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.has("Golden Feather", self.player, 12)
|
||||
elif self.options.goal == "races":
|
||||
# Races
|
||||
self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, get_min_feathers(self, 7, 9))
|
||||
or (state.has("Bucket", self.player) and state.has("Golden Feather", self.player, 7)))
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.can_reach_location("Hawk Peak Race Reward", self.player)
|
||||
elif self.options.goal == "help_everyone":
|
||||
# Help Everyone
|
||||
self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, 12)
|
||||
and state.has("Toy Shovel", self.player) and state.has("Camping Permit", self.player)
|
||||
and state.has("Motorboat Key", self.player) and state.has("Headband", self.player)
|
||||
and state.has("Wristwatch", self.player) and state.has("Seashell", self.player, 15)
|
||||
and state.has("Shell Necklace", self.player))
|
||||
self.multiworld.completion_condition[self.player] = lambda state: (state.can_reach_location("Collect 15 Seashells", self.player)
|
||||
and state.has("Golden Feather", self.player, 12)
|
||||
and state.can_reach_location("Tough Bird Salesman (400 Coins)", self.player)
|
||||
and state.can_reach_location("Ranger May Shell Necklace Golden Feather", self.player)
|
||||
and state.can_reach_location("Sue the Rabbit Shoes Reward", self.player)
|
||||
and state.can_reach_location("Wristwatch Trade", self.player)
|
||||
and state.can_reach_location("Return Camping Permit", self.player)
|
||||
and state.can_reach_location("Boat Challenge Reward", self.player)
|
||||
and state.can_reach_location("Shovel Kid Trade", self.player)
|
||||
and state.can_reach_location("Purchase Sunhat", self.player)
|
||||
and state.can_reach_location("Artist Golden Feather", self.player))
|
||||
elif self.options.goal == "fishmonger":
|
||||
# Fishmonger
|
||||
self.multiworld.completion_condition[self.player] = lambda state: (state.has("Golden Feather", self.player, get_min_feathers(self, 7, 9))
|
||||
or (state.has("Bucket", self.player) and state.has("Golden Feather", self.player, 7))
|
||||
and state.has("Fishing Rod", self.player))
|
||||
self.multiworld.completion_condition[self.player] = lambda state: state.can_reach_location("Catch All Fish Reward", self.player)
|
||||
|
||||
def set_rules(self):
|
||||
create_rules(self, location_table)
|
||||
@@ -117,6 +135,9 @@ class ShortHikeWorld(World):
|
||||
"goal": int(options.goal),
|
||||
"logicLevel": int(options.golden_feather_progression),
|
||||
"costMultiplier": int(options.cost_multiplier),
|
||||
"shopCheckLogic": int(options.shop_check_logic),
|
||||
"minShopCheckLogic": int(options.min_shop_check_logic),
|
||||
"easierRaces": bool(options.easier_races),
|
||||
}
|
||||
|
||||
slot_data = {
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
|
||||
- A Short Hike: [Steam](https://store.steampowered.com/app/1055540/A_Short_Hike/)
|
||||
- The Epic Games Store or itch.io version of A Short Hike will also work.
|
||||
- A Short Hike Modding Tools: [GitHub](https://github.com/BrandenEK/AShortHike.ModdingTools)
|
||||
- A Short Hike Randomizer: [GitHub](https://github.com/BrandenEK/AShortHike.Randomizer)
|
||||
|
||||
## Optional Software
|
||||
@@ -14,18 +13,13 @@
|
||||
|
||||
## Installation
|
||||
|
||||
1. Open the [Modding Tools GitHub page](https://github.com/BrandenEK/AShortHike.ModdingTools/), and follow
|
||||
the installation instructions. After this step, your `A Short Hike/` folder should have an empty `Modding/` subfolder.
|
||||
|
||||
2. After the Modding Tools have been installed, download the
|
||||
[Randomizer](https://github.com/BrandenEK/AShortHike.Randomizer/releases) zip, extract it, and move the contents
|
||||
of the `Randomizer/` folder into your `Modding/` folder. After this step, your `Modding/` folder should have
|
||||
`data/` and `plugins/` subfolders.
|
||||
Open the [Randomizer Repository](https://github.com/BrandenEK/AShortHike.Randomizer) and follow
|
||||
the installation instructions listed there.
|
||||
|
||||
## Connecting
|
||||
|
||||
A Short Hike will prompt you with the server details when a new game is started or a previous one is continued.
|
||||
Enter in the Server Port, Name, and Password (optional) in the popup menu that appears and hit connect.
|
||||
Enter in the Server Address and Port, Name, and Password (optional) in the popup menu that appears and hit connect.
|
||||
|
||||
## Tracking
|
||||
|
||||
|
||||
@@ -1462,8 +1462,6 @@ def set_er_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int])
|
||||
# Quarry
|
||||
set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player),
|
||||
lambda state: state.has(laurels, player))
|
||||
set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player),
|
||||
lambda state: has_mask(state, player, options))
|
||||
|
||||
# Ziggurat
|
||||
set_rule(multiworld.get_location("Rooted Ziggurat Upper - Near Bridge Switch", player),
|
||||
|
||||
@@ -304,8 +304,6 @@ def set_location_rules(world: "TunicWorld", ability_unlocks: Dict[str, int]) ->
|
||||
# Quarry
|
||||
set_rule(multiworld.get_location("Quarry - [Central] Above Ladder Dash Chest", player),
|
||||
lambda state: state.has(laurels, player))
|
||||
set_rule(multiworld.get_location("Quarry - [West] Upper Area Bombable Wall", player),
|
||||
lambda state: has_mask(state, player, options))
|
||||
# nmg - kill boss scav with orb + firecracker, or similar
|
||||
set_rule(multiworld.get_location("Rooted Ziggurat Lower - Hexagon Blue", player),
|
||||
lambda state: has_sword(state, player) or (state.has(grapple, player) and options.logic_rules))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from collections import defaultdict
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Set, Tuple
|
||||
|
||||
from Utils import cache_argsless
|
||||
|
||||
from .item_definition_classes import (
|
||||
CATEGORY_NAME_MAPPINGS,
|
||||
DoorItemDefinition,
|
||||
@@ -260,17 +261,17 @@ def get_parent_progressive_item(item_name: str) -> str:
|
||||
return _progressive_lookup.get(item_name, item_name)
|
||||
|
||||
|
||||
@lru_cache
|
||||
@cache_argsless
|
||||
def get_vanilla() -> StaticWitnessLogicObj:
|
||||
return StaticWitnessLogicObj(get_vanilla_logic())
|
||||
|
||||
|
||||
@lru_cache
|
||||
@cache_argsless
|
||||
def get_sigma_normal() -> StaticWitnessLogicObj:
|
||||
return StaticWitnessLogicObj(get_sigma_normal_logic())
|
||||
|
||||
|
||||
@lru_cache
|
||||
@cache_argsless
|
||||
def get_sigma_expert() -> StaticWitnessLogicObj:
|
||||
return StaticWitnessLogicObj(get_sigma_expert_logic())
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
from functools import lru_cache
|
||||
from math import floor
|
||||
from pkgutil import get_data
|
||||
from random import random
|
||||
@@ -103,10 +102,15 @@ def parse_lambda(lambda_string) -> WitnessRule:
|
||||
return lambda_set
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
_adjustment_file_cache = dict()
|
||||
|
||||
|
||||
def get_adjustment_file(adjustment_file: str) -> List[str]:
|
||||
data = get_data(__name__, adjustment_file).decode("utf-8")
|
||||
return [line.strip() for line in data.split("\n")]
|
||||
if adjustment_file not in _adjustment_file_cache:
|
||||
data = get_data(__name__, adjustment_file).decode("utf-8")
|
||||
_adjustment_file_cache[adjustment_file] = [line.strip() for line in data.split("\n")]
|
||||
|
||||
return _adjustment_file_cache[adjustment_file]
|
||||
|
||||
|
||||
def get_disable_unrandomized_list() -> List[str]:
|
||||
|
||||
Reference in New Issue
Block a user