mirror of
https://github.com/ArchipelagoMW/Archipelago.git
synced 2026-03-31 19:33:33 -07:00
Merge branch 'ArchipelagoMW:main' into Satisfactory_ToBeVerified
This commit is contained in:
14
.github/workflows/release.yml
vendored
14
.github/workflows/release.yml
vendored
@@ -9,7 +9,12 @@ on:
|
||||
|
||||
env:
|
||||
ENEMIZER_VERSION: 7.1
|
||||
APPIMAGETOOL_VERSION: 13
|
||||
# NOTE: since appimage/appimagetool and appimage/type2-runtime does not have tags anymore,
|
||||
# we check the sha256 and require manual intervention if it was updated.
|
||||
APPIMAGETOOL_VERSION: continuous
|
||||
APPIMAGETOOL_X86_64_HASH: '363dafac070b65cc36ca024b74db1f043c6f5cd7be8fca760e190dce0d18d684'
|
||||
APPIMAGE_RUNTIME_VERSION: continuous
|
||||
APPIMAGE_RUNTIME_X86_64_HASH: 'e3c4dfb70eddf42e7e5a1d28dff396d30563aa9a901970aebe6f01f3fecf9f8e'
|
||||
|
||||
permissions: # permissions required for attestation
|
||||
id-token: 'write'
|
||||
@@ -122,10 +127,13 @@ jobs:
|
||||
- name: Install build-time dependencies
|
||||
run: |
|
||||
echo "PYTHON=python3.12" >> $GITHUB_ENV
|
||||
wget -nv https://github.com/AppImage/AppImageKit/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
wget -nv https://github.com/AppImage/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-x86_64.AppImage
|
||||
echo "$APPIMAGETOOL_X86_64_HASH appimagetool-x86_64.AppImage" | sha256sum -c
|
||||
wget -nv https://github.com/AppImage/type2-runtime/releases/download/$APPIMAGE_RUNTIME_VERSION/runtime-x86_64
|
||||
echo "$APPIMAGE_RUNTIME_X86_64_HASH runtime-x86_64" | sha256sum -c
|
||||
chmod a+rx appimagetool-x86_64.AppImage
|
||||
./appimagetool-x86_64.AppImage --appimage-extract
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun "$@"' > appimagetool
|
||||
echo -e '#/bin/sh\n./squashfs-root/AppRun --runtime-file runtime-x86_64 "$@"' > appimagetool
|
||||
chmod a+rx appimagetool
|
||||
- name: Download run-time dependencies
|
||||
run: |
|
||||
|
||||
@@ -407,6 +407,7 @@ async def atari_sync_task(ctx: AdventureContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.atari_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
except CancelledError:
|
||||
pass
|
||||
|
||||
@@ -154,17 +154,11 @@ class MultiWorld():
|
||||
self.algorithm = 'balanced'
|
||||
self.groups = {}
|
||||
self.regions = self.RegionManager(players)
|
||||
self.shops = []
|
||||
self.itempool = []
|
||||
self.seed = None
|
||||
self.seed_name: str = "Unavailable"
|
||||
self.precollected_items = {player: [] for player in self.player_ids}
|
||||
self.required_locations = []
|
||||
self.light_world_light_cone = False
|
||||
self.dark_world_light_cone = False
|
||||
self.rupoor_cost = 10
|
||||
self.aga_randomness = True
|
||||
self.save_and_quit_from_boss = True
|
||||
self.custom = False
|
||||
self.customitemarray = []
|
||||
self.shuffle_ganon = True
|
||||
@@ -183,7 +177,7 @@ class MultiWorld():
|
||||
set_player_attr('completion_condition', lambda state: True)
|
||||
self.worlds = {}
|
||||
self.per_slot_randoms = Utils.DeprecateDict("Using per_slot_randoms is now deprecated. Please use the "
|
||||
"world's random object instead (usually self.random)")
|
||||
"world's random object instead (usually self.random)", True)
|
||||
self.plando_options = PlandoOptions.none
|
||||
|
||||
def get_all_ids(self) -> Tuple[int, ...]:
|
||||
@@ -228,17 +222,8 @@ class MultiWorld():
|
||||
self.seed_name = name if name else str(self.seed)
|
||||
|
||||
def set_options(self, args: Namespace) -> None:
|
||||
# TODO - remove this section once all worlds use options dataclasses
|
||||
from worlds import AutoWorld
|
||||
|
||||
all_keys: Set[str] = {key for player in self.player_ids for key in
|
||||
AutoWorld.AutoWorldRegister.world_types[self.game[player]].options_dataclass.type_hints}
|
||||
for option_key in all_keys:
|
||||
option = Utils.DeprecateDict(f"Getting options from multiworld is now deprecated. "
|
||||
f"Please use `self.options.{option_key}` instead.", True)
|
||||
option.update(getattr(args, option_key, {}))
|
||||
setattr(self, option_key, option)
|
||||
|
||||
for player in self.player_ids:
|
||||
world_type = AutoWorld.AutoWorldRegister.world_types[self.game[player]]
|
||||
self.worlds[player] = world_type(self, player)
|
||||
@@ -1586,7 +1571,7 @@ class ItemClassification(IntFlag):
|
||||
|
||||
def as_flag(self) -> int:
|
||||
"""As Network API flag int."""
|
||||
return int(self & 0b0111)
|
||||
return int(self & 0b00111)
|
||||
|
||||
|
||||
class Item:
|
||||
@@ -1941,7 +1926,7 @@ class Tutorial(NamedTuple):
|
||||
description: str
|
||||
language: str
|
||||
file_name: str
|
||||
link: str
|
||||
link: str # unused
|
||||
authors: List[str]
|
||||
|
||||
|
||||
|
||||
7
Fill.py
7
Fill.py
@@ -358,7 +358,12 @@ def fast_fill(multiworld: MultiWorld,
|
||||
return item_pool[placing:], fill_locations[placing:]
|
||||
|
||||
|
||||
def accessibility_corrections(multiworld: MultiWorld, state: CollectionState, locations, pool=[]):
|
||||
def accessibility_corrections(multiworld: MultiWorld,
|
||||
state: CollectionState,
|
||||
locations: list[Location],
|
||||
pool: list[Item] | None = None) -> None:
|
||||
if pool is None:
|
||||
pool = []
|
||||
maximum_exploration_state = sweep_from_pool(state, pool)
|
||||
minimal_players = {player for player in multiworld.player_ids if
|
||||
multiworld.worlds[player].options.accessibility == "minimal"}
|
||||
|
||||
@@ -32,6 +32,7 @@ GAME_ALTTP = "A Link to the Past"
|
||||
WINDOW_MIN_HEIGHT = 525
|
||||
WINDOW_MIN_WIDTH = 425
|
||||
|
||||
|
||||
class AdjusterWorld(object):
|
||||
class AdjusterSubWorld(object):
|
||||
def __init__(self, random):
|
||||
@@ -40,7 +41,6 @@ class AdjusterWorld(object):
|
||||
def __init__(self, sprite_pool):
|
||||
import random
|
||||
self.sprite_pool = {1: sprite_pool}
|
||||
self.per_slot_randoms = {1: random}
|
||||
self.worlds = {1: self.AdjusterSubWorld(random)}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ class ArgumentDefaultsHelpFormatter(argparse.RawTextHelpFormatter):
|
||||
def _get_help_string(self, action):
|
||||
return textwrap.dedent(action.help)
|
||||
|
||||
|
||||
# See argparse.BooleanOptionalAction
|
||||
class BooleanOptionalActionWithDisable(argparse.Action):
|
||||
def __init__(self,
|
||||
@@ -364,10 +365,10 @@ def run_sprite_update():
|
||||
logging.info("Done updating sprites")
|
||||
|
||||
|
||||
def update_sprites(task, on_finish=None):
|
||||
def update_sprites(task, on_finish=None, repository_url: str = "https://alttpr.com/sprites"):
|
||||
resultmessage = ""
|
||||
successful = True
|
||||
sprite_dir = user_path("data", "sprites", "alttpr")
|
||||
sprite_dir = user_path("data", "sprites", "alttp", "remote")
|
||||
os.makedirs(sprite_dir, exist_ok=True)
|
||||
ctx = get_cert_none_ssl_context()
|
||||
|
||||
@@ -377,11 +378,11 @@ def update_sprites(task, on_finish=None):
|
||||
on_finish(successful, resultmessage)
|
||||
|
||||
try:
|
||||
task.update_status("Downloading alttpr sprites list")
|
||||
with urlopen('https://alttpr.com/sprites', context=ctx) as response:
|
||||
task.update_status("Downloading remote sprites list")
|
||||
with urlopen(repository_url, context=ctx) as response:
|
||||
sprites_arr = json.loads(response.read().decode("utf-8"))
|
||||
except Exception as e:
|
||||
resultmessage = "Error getting list of alttpr sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
resultmessage = "Error getting list of remote sprites. Sprites not updated.\n\n%s: %s" % (type(e).__name__, e)
|
||||
successful = False
|
||||
task.queue_event(finished)
|
||||
return
|
||||
@@ -389,13 +390,13 @@ def update_sprites(task, on_finish=None):
|
||||
try:
|
||||
task.update_status("Determining needed sprites")
|
||||
current_sprites = [os.path.basename(file) for file in glob(sprite_dir + '/*')]
|
||||
alttpr_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||
remote_sprites = [(sprite['file'], os.path.basename(urlparse(sprite['file']).path))
|
||||
for sprite in sprites_arr if sprite["author"] != "Nintendo"]
|
||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in alttpr_sprites if
|
||||
needed_sprites = [(sprite_url, filename) for (sprite_url, filename) in remote_sprites if
|
||||
filename not in current_sprites]
|
||||
|
||||
alttpr_filenames = [filename for (_, filename) in alttpr_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in alttpr_filenames]
|
||||
remote_filenames = [filename for (_, filename) in remote_sprites]
|
||||
obsolete_sprites = [sprite for sprite in current_sprites if sprite not in remote_filenames]
|
||||
except Exception as e:
|
||||
resultmessage = "Error Determining which sprites to update. Sprites not updated.\n\n%s: %s" % (
|
||||
type(e).__name__, e)
|
||||
@@ -447,7 +448,7 @@ def update_sprites(task, on_finish=None):
|
||||
successful = False
|
||||
|
||||
if successful:
|
||||
resultmessage = "alttpr sprites updated successfully"
|
||||
resultmessage = "Remote sprites updated successfully"
|
||||
|
||||
task.queue_event(finished)
|
||||
|
||||
@@ -868,7 +869,7 @@ class SpriteSelector():
|
||||
def open_custom_sprite_dir(_evt):
|
||||
open_file(self.custom_sprite_dir)
|
||||
|
||||
alttpr_frametitle = Label(self.window, text='ALTTPR Sprites')
|
||||
remote_frametitle = Label(self.window, text='Remote Sprites')
|
||||
|
||||
custom_frametitle = Frame(self.window)
|
||||
title_text = Label(custom_frametitle, text="Custom Sprites")
|
||||
@@ -877,8 +878,8 @@ class SpriteSelector():
|
||||
title_link.pack(side=LEFT)
|
||||
title_link.bind("<Button-1>", open_custom_sprite_dir)
|
||||
|
||||
self.icon_section(alttpr_frametitle, self.alttpr_sprite_dir,
|
||||
'ALTTPR sprites not found. Click "Update alttpr sprites" to download them.')
|
||||
self.icon_section(remote_frametitle, self.remote_sprite_dir,
|
||||
'Remote sprites not found. Click "Update remote sprites" to download them.')
|
||||
self.icon_section(custom_frametitle, self.custom_sprite_dir,
|
||||
'Put sprites in the custom sprites folder (see open link above) to have them appear here.')
|
||||
if not randomOnEvent:
|
||||
@@ -891,11 +892,18 @@ class SpriteSelector():
|
||||
button = Button(frame, text="Browse for file...", command=self.browse_for_sprite)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
button = Button(frame, text="Update alttpr sprites", command=self.update_alttpr_sprites)
|
||||
button = Button(frame, text="Update remote sprites", command=self.update_remote_sprites)
|
||||
button.pack(side=RIGHT, padx=(5, 0))
|
||||
|
||||
repository_label = Label(frame, text='Sprite Repository:')
|
||||
self.repository_url = StringVar(frame, "https://alttpr.com/sprites")
|
||||
repository_entry = Entry(frame, textvariable=self.repository_url)
|
||||
|
||||
repository_entry.pack(side=RIGHT, expand=True, fill=BOTH, pady=1)
|
||||
repository_label.pack(side=RIGHT, expand=False, padx=(0, 5))
|
||||
|
||||
button = Button(frame, text="Do not adjust sprite",command=self.use_default_sprite)
|
||||
button.pack(side=LEFT,padx=(0,5))
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
|
||||
button = Button(frame, text="Default Link sprite", command=self.use_default_link_sprite)
|
||||
button.pack(side=LEFT, padx=(0, 5))
|
||||
@@ -1055,7 +1063,7 @@ class SpriteSelector():
|
||||
for i, button in enumerate(frame.buttons):
|
||||
button.grid(row=i // self.spritesPerRow, column=i % self.spritesPerRow)
|
||||
|
||||
def update_alttpr_sprites(self):
|
||||
def update_remote_sprites(self):
|
||||
# need to wrap in try catch. We don't want errors getting the json or downloading the files to break us.
|
||||
self.window.destroy()
|
||||
self.parent.update()
|
||||
@@ -1068,7 +1076,8 @@ class SpriteSelector():
|
||||
messagebox.showerror("Sprite Updater", resultmessage)
|
||||
SpriteSelector(self.parent, self.callback, self.adjuster)
|
||||
|
||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites", on_finish)
|
||||
BackgroundTaskProgress(self.parent, update_sprites, "Updating Sprites",
|
||||
on_finish, self.repository_url.get())
|
||||
|
||||
def browse_for_sprite(self):
|
||||
sprite = filedialog.askopenfilename(
|
||||
@@ -1158,12 +1167,13 @@ class SpriteSelector():
|
||||
os.makedirs(self.custom_sprite_dir)
|
||||
|
||||
@property
|
||||
def alttpr_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttpr")
|
||||
def remote_sprite_dir(self):
|
||||
return user_path("data", "sprites", "alttp", "remote")
|
||||
|
||||
@property
|
||||
def custom_sprite_dir(self):
|
||||
return user_path("data", "sprites", "custom")
|
||||
return user_path("data", "sprites", "alttp", "custom")
|
||||
|
||||
|
||||
def get_image_for_sprite(sprite, gif_only: bool = False):
|
||||
if not sprite.valid:
|
||||
|
||||
@@ -286,6 +286,7 @@ async def gba_sync_task(ctx: MMBN3Context):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.gba_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
2
Main.py
2
Main.py
@@ -176,7 +176,7 @@ def main(args, seed=None, baked_server_options: dict[str, object] | None = None)
|
||||
|
||||
multiworld.link_items()
|
||||
|
||||
if any(multiworld.item_links.values()):
|
||||
if any(world.options.item_links for world in multiworld.worlds.values()):
|
||||
multiworld._all_state = None
|
||||
|
||||
logger.info("Running Item Plando.")
|
||||
|
||||
@@ -277,6 +277,7 @@ async def n64_sync_task(ctx: OoTContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.n64_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
26
Options.py
26
Options.py
@@ -494,6 +494,30 @@ class Choice(NumericOption):
|
||||
else:
|
||||
raise TypeError(f"Can't compare {self.__class__.__name__} with {other.__class__.__name__}")
|
||||
|
||||
def __lt__(self, other: typing.Union[Choice, int, str]):
|
||||
if isinstance(other, str):
|
||||
assert other in self.options, f"compared against an unknown string. {self} < {other}"
|
||||
other = self.options[other]
|
||||
return super(Choice, self).__lt__(other)
|
||||
|
||||
def __gt__(self, other: typing.Union[Choice, int, str]):
|
||||
if isinstance(other, str):
|
||||
assert other in self.options, f"compared against an unknown string. {self} > {other}"
|
||||
other = self.options[other]
|
||||
return super(Choice, self).__gt__(other)
|
||||
|
||||
def __le__(self, other: typing.Union[Choice, int, str]):
|
||||
if isinstance(other, str):
|
||||
assert other in self.options, f"compared against an unknown string. {self} <= {other}"
|
||||
other = self.options[other]
|
||||
return super(Choice, self).__le__(other)
|
||||
|
||||
def __ge__(self, other: typing.Union[Choice, int, str]):
|
||||
if isinstance(other, str):
|
||||
assert other in self.options, f"compared against an unknown string. {self} >= {other}"
|
||||
other = self.options[other]
|
||||
return super(Choice, self).__ge__(other)
|
||||
|
||||
__hash__ = Option.__hash__ # see https://docs.python.org/3/reference/datamodel.html#object.__hash__
|
||||
|
||||
|
||||
@@ -1094,7 +1118,7 @@ class PlandoConnection(typing.NamedTuple):
|
||||
|
||||
entrance: str
|
||||
exit: str
|
||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.8 is dropped
|
||||
direction: typing.Literal["entrance", "exit", "both"] # TODO: convert Direction to StrEnum once 3.10 is dropped
|
||||
percentage: int = 100
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ from json import loads, dumps
|
||||
from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui_enabled, get_base_parser
|
||||
|
||||
import Utils
|
||||
from settings import Settings
|
||||
from Utils import async_start
|
||||
from MultiServer import mark_raw
|
||||
if typing.TYPE_CHECKING:
|
||||
@@ -285,7 +286,7 @@ class SNESState(enum.IntEnum):
|
||||
|
||||
|
||||
def launch_sni() -> None:
|
||||
sni_path = Utils.get_settings()["sni_options"]["sni_path"]
|
||||
sni_path = Settings.sni_options.sni_path
|
||||
|
||||
if not os.path.isdir(sni_path):
|
||||
sni_path = Utils.local_path(sni_path)
|
||||
@@ -668,8 +669,7 @@ async def game_watcher(ctx: SNIContext) -> None:
|
||||
|
||||
|
||||
async def run_game(romfile: str) -> None:
|
||||
auto_start = typing.cast(typing.Union[bool, str],
|
||||
Utils.get_settings()["sni_options"].get("snes_rom_start", True))
|
||||
auto_start = Settings.sni_options.snes_rom_start
|
||||
if auto_start is True:
|
||||
import webbrowser
|
||||
webbrowser.open(romfile)
|
||||
|
||||
3
Utils.py
3
Utils.py
@@ -953,8 +953,7 @@ def _extend_freeze_support() -> None:
|
||||
# Handle the first process that MP will create
|
||||
if (
|
||||
len(sys.argv) >= 2 and sys.argv[-2] == '-c' and sys.argv[-1].startswith((
|
||||
'from multiprocessing.semaphore_tracker import main', # Py<3.8
|
||||
'from multiprocessing.resource_tracker import main', # Py>=3.8
|
||||
'from multiprocessing.resource_tracker import main',
|
||||
'from multiprocessing.forkserver import main'
|
||||
)) and set(sys.argv[1:-2]) == set(_args_from_interpreter_flags())
|
||||
):
|
||||
|
||||
48
WebHost.py
48
WebHost.py
@@ -54,16 +54,15 @@ def get_app() -> "Flask":
|
||||
return app
|
||||
|
||||
|
||||
def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]:
|
||||
import json
|
||||
def copy_tutorials_files_to_static() -> None:
|
||||
import shutil
|
||||
import zipfile
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
zfile: zipfile.ZipInfo
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
worlds = {}
|
||||
data = []
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if hasattr(world.web, 'tutorials') and (not world.hidden or game == 'Archipelago'):
|
||||
worlds[game] = world
|
||||
@@ -72,7 +71,7 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
shutil.rmtree(base_target_path, ignore_errors=True)
|
||||
for game, world in worlds.items():
|
||||
# copy files from world's docs folder to the generated folder
|
||||
target_path = os.path.join(base_target_path, get_file_safe_name(game))
|
||||
target_path = os.path.join(base_target_path, secure_filename(game))
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
|
||||
if world.zip_path:
|
||||
@@ -85,45 +84,14 @@ def create_ordered_tutorials_file() -> typing.List[typing.Dict[str, typing.Any]]
|
||||
for zfile in zf.infolist():
|
||||
if not zfile.is_dir() and "/docs/" in zfile.filename:
|
||||
zfile.filename = os.path.basename(zfile.filename)
|
||||
zf.extract(zfile, target_path)
|
||||
with open(os.path.join(target_path, secure_filename(zfile.filename)), "wb") as f:
|
||||
f.write(zf.read(zfile))
|
||||
else:
|
||||
source_path = Utils.local_path(os.path.dirname(world.__file__), "docs")
|
||||
files = os.listdir(source_path)
|
||||
for file in files:
|
||||
shutil.copyfile(Utils.local_path(source_path, file), Utils.local_path(target_path, file))
|
||||
|
||||
# build a json tutorial dict per game
|
||||
game_data = {'gameTitle': game, 'tutorials': []}
|
||||
for tutorial in world.web.tutorials:
|
||||
# build dict for the json file
|
||||
current_tutorial = {
|
||||
'name': tutorial.tutorial_name,
|
||||
'description': tutorial.description,
|
||||
'files': [{
|
||||
'language': tutorial.language,
|
||||
'filename': game + '/' + tutorial.file_name,
|
||||
'link': f'{game}/{tutorial.link}',
|
||||
'authors': tutorial.authors
|
||||
}]
|
||||
}
|
||||
|
||||
# check if the name of the current guide exists already
|
||||
for guide in game_data['tutorials']:
|
||||
if guide and tutorial.tutorial_name == guide['name']:
|
||||
guide['files'].append(current_tutorial['files'][0])
|
||||
break
|
||||
else:
|
||||
game_data['tutorials'].append(current_tutorial)
|
||||
|
||||
data.append(game_data)
|
||||
with open(Utils.local_path("WebHostLib", "static", "generated", "tutorials.json"), 'w', encoding='utf-8-sig') as json_target:
|
||||
generic_data = {}
|
||||
for games in data:
|
||||
if 'Archipelago' in games['gameTitle']:
|
||||
generic_data = data.pop(data.index(games))
|
||||
sorted_data = [generic_data] + Utils.title_sorted(data, key=lambda entry: entry["gameTitle"])
|
||||
json.dump(sorted_data, json_target, indent=2, ensure_ascii=False)
|
||||
return sorted_data
|
||||
shutil.copyfile(Utils.local_path(source_path, file),
|
||||
Utils.local_path(target_path, secure_filename(file)))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -142,7 +110,7 @@ if __name__ == "__main__":
|
||||
logging.warning("Could not update LttP sprites.")
|
||||
app = get_app()
|
||||
create_options_files()
|
||||
create_ordered_tutorials_file()
|
||||
copy_tutorials_files_to_static()
|
||||
if app.config["SELFLAUNCH"]:
|
||||
autohost(app.config)
|
||||
if app.config["SELFGEN"]:
|
||||
|
||||
@@ -87,12 +87,17 @@ app.jinja_env.filters["title_sorted"] = title_sorted
|
||||
def register():
|
||||
"""Import submodules, triggering their registering on flask routing.
|
||||
Note: initializes worlds subsystem."""
|
||||
import importlib
|
||||
|
||||
from werkzeug.utils import find_modules
|
||||
# has automatic patch integration
|
||||
import worlds.Files
|
||||
app.jinja_env.filters['is_applayercontainer'] = worlds.Files.is_ap_player_container
|
||||
|
||||
from WebHostLib.customserver import run_server_process
|
||||
# to trigger app routing picking up on it
|
||||
from . import tracker, upload, landing, check, generate, downloads, api, stats, misc, robots, options, session
|
||||
|
||||
for module in find_modules("WebHostLib", include_packages=True):
|
||||
importlib.import_module(module)
|
||||
|
||||
from . import api
|
||||
app.register_blueprint(api.api_endpoints)
|
||||
|
||||
@@ -14,7 +14,7 @@ def update_sprites_lttp():
|
||||
from LttPAdjuster import update_sprites
|
||||
|
||||
# Target directories
|
||||
input_dir = user_path("data", "sprites", "alttpr")
|
||||
input_dir = user_path("data", "sprites", "alttp", "remote")
|
||||
output_dir = local_path("WebHostLib", "static", "generated") # TODO: move to user_path
|
||||
|
||||
os.makedirs(os.path.join(output_dir, "sprites"), exist_ok=True)
|
||||
|
||||
@@ -7,17 +7,69 @@ from flask import request, redirect, url_for, render_template, Response, session
|
||||
from pony.orm import count, commit, db_session
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
from worlds.AutoWorld import AutoWorldRegister, World
|
||||
from . import app, cache
|
||||
from .models import Seed, Room, Command, UUID, uuid4
|
||||
from Utils import title_sorted
|
||||
|
||||
|
||||
def get_world_theme(game_name: str):
|
||||
def get_world_theme(game_name: str) -> str:
|
||||
if game_name in AutoWorldRegister.world_types:
|
||||
return AutoWorldRegister.world_types[game_name].web.theme
|
||||
return 'grass'
|
||||
|
||||
|
||||
def get_visible_worlds() -> dict[str, type(World)]:
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return worlds
|
||||
|
||||
|
||||
def render_markdown(path: str) -> str:
|
||||
import mistune
|
||||
from collections import Counter
|
||||
|
||||
markdown = mistune.create_markdown(
|
||||
escape=False,
|
||||
plugins=[
|
||||
"strikethrough",
|
||||
"footnotes",
|
||||
"table",
|
||||
"speedup",
|
||||
],
|
||||
)
|
||||
|
||||
heading_id_count: Counter[str] = Counter()
|
||||
|
||||
def heading_id(text: str) -> str:
|
||||
nonlocal heading_id_count
|
||||
import re # there is no good way to do this without regex
|
||||
|
||||
s = re.sub(r"[^\w\- ]", "", text.lower()).replace(" ", "-").strip("-")
|
||||
n = heading_id_count[s]
|
||||
heading_id_count[s] += 1
|
||||
if n > 0:
|
||||
s += f"-{n}"
|
||||
return s
|
||||
|
||||
def id_hook(_: mistune.Markdown, state: mistune.BlockState) -> None:
|
||||
for tok in state.tokens:
|
||||
if tok["type"] == "heading" and tok["attrs"]["level"] < 4:
|
||||
text = tok["text"]
|
||||
assert isinstance(text, str)
|
||||
unique_id = heading_id(text)
|
||||
tok["attrs"]["id"] = unique_id
|
||||
tok["text"] = f"<a href=\"#{unique_id}\">{text}</a>" # make header link to itself
|
||||
|
||||
markdown.before_render_hooks.append(id_hook)
|
||||
|
||||
with open(path, encoding="utf-8-sig") as f:
|
||||
document = f.read()
|
||||
return markdown(document)
|
||||
|
||||
|
||||
@app.errorhandler(404)
|
||||
@app.errorhandler(jinja2.exceptions.TemplateNotFound)
|
||||
def page_not_found(err):
|
||||
@@ -31,83 +83,94 @@ def start_playing():
|
||||
return render_template(f"startPlaying.html")
|
||||
|
||||
|
||||
# Game Info Pages
|
||||
@app.route('/games/<string:game>/info/<string:lang>')
|
||||
@cache.cached()
|
||||
def game_info(game, lang):
|
||||
"""Game Info Pages"""
|
||||
try:
|
||||
world = AutoWorldRegister.world_types[game]
|
||||
if lang not in world.web.game_info_languages:
|
||||
raise KeyError("Sorry, this game's info page is not available in that language yet.")
|
||||
except KeyError:
|
||||
theme = get_world_theme(game)
|
||||
secure_game_name = secure_filename(game)
|
||||
lang = secure_filename(lang)
|
||||
document = render_markdown(os.path.join(
|
||||
app.static_folder, "generated", "docs",
|
||||
secure_game_name, f"{lang}_{secure_game_name}.md"
|
||||
))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title=f"{game} Guide",
|
||||
html_from_markdown=document,
|
||||
theme=theme,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return abort(404)
|
||||
return render_template('gameInfo.html', game=game, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
# List of supported games
|
||||
@app.route('/games')
|
||||
@cache.cached()
|
||||
def games():
|
||||
worlds = {}
|
||||
for game, world in AutoWorldRegister.world_types.items():
|
||||
if not world.hidden:
|
||||
worlds[game] = world
|
||||
return render_template("supportedGames.html", worlds=worlds)
|
||||
"""List of supported games"""
|
||||
return render_template("supportedGames.html", worlds=get_visible_worlds())
|
||||
|
||||
|
||||
@app.route('/tutorial/<string:game>/<string:file>/<string:lang>')
|
||||
@app.route('/tutorial/<string:game>/<string:file>')
|
||||
@cache.cached()
|
||||
def tutorial(game, file, lang):
|
||||
def tutorial(game: str, file: str):
|
||||
try:
|
||||
world = AutoWorldRegister.world_types[game]
|
||||
if lang not in [tut.link.split("/")[1] for tut in world.web.tutorials]:
|
||||
raise KeyError("Sorry, the tutorial is not available in that language yet.")
|
||||
except KeyError:
|
||||
theme = get_world_theme(game)
|
||||
secure_game_name = secure_filename(game)
|
||||
file = secure_filename(file)
|
||||
document = render_markdown(os.path.join(
|
||||
app.static_folder, "generated", "docs",
|
||||
secure_game_name, file+".md"
|
||||
))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title=f"{game} Guide",
|
||||
html_from_markdown=document,
|
||||
theme=theme,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return abort(404)
|
||||
return render_template("tutorial.html", game=game, file=file, lang=lang, theme=get_world_theme(game))
|
||||
|
||||
|
||||
@app.route('/tutorial/')
|
||||
@cache.cached()
|
||||
def tutorial_landing():
|
||||
return render_template("tutorialLanding.html")
|
||||
tutorials = {}
|
||||
worlds = AutoWorldRegister.world_types
|
||||
for world_name, world_type in worlds.items():
|
||||
current_world = tutorials[world_name] = {}
|
||||
for tutorial in world_type.web.tutorials:
|
||||
current_tutorial = current_world.setdefault(tutorial.tutorial_name, {
|
||||
"description": tutorial.description, "files": {}})
|
||||
current_tutorial["files"][secure_filename(tutorial.file_name).rsplit(".", 1)[0]] = {
|
||||
"authors": tutorial.authors,
|
||||
"language": tutorial.language
|
||||
}
|
||||
tutorials = {world_name: tutorials for world_name, tutorials in title_sorted(
|
||||
tutorials.items(), key=lambda element: "\x00" if element[0] == "Archipelago" else worlds[element[0]].game)}
|
||||
return render_template("tutorialLanding.html", worlds=worlds, tutorials=tutorials)
|
||||
|
||||
|
||||
@app.route('/faq/<string:lang>/')
|
||||
@cache.cached()
|
||||
def faq(lang: str):
|
||||
import markdown
|
||||
with open(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md")) as f:
|
||||
document = f.read()
|
||||
document = render_markdown(os.path.join(app.static_folder, "assets", "faq", secure_filename(lang)+".md"))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title="Frequently Asked Questions",
|
||||
html_from_markdown=markdown.markdown(
|
||||
document,
|
||||
extensions=["toc", "mdx_breakless_lists"],
|
||||
extension_configs={
|
||||
"toc": {"anchorlink": True}
|
||||
}
|
||||
),
|
||||
html_from_markdown=document,
|
||||
)
|
||||
|
||||
|
||||
@app.route('/glossary/<string:lang>/')
|
||||
@cache.cached()
|
||||
def glossary(lang: str):
|
||||
import markdown
|
||||
with open(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md")) as f:
|
||||
document = f.read()
|
||||
document = render_markdown(os.path.join(app.static_folder, "assets", "glossary", secure_filename(lang)+".md"))
|
||||
return render_template(
|
||||
"markdown_document.html",
|
||||
title="Glossary",
|
||||
html_from_markdown=markdown.markdown(
|
||||
document,
|
||||
extensions=["toc", "mdx_breakless_lists"],
|
||||
extension_configs={
|
||||
"toc": {"anchorlink": True}
|
||||
}
|
||||
),
|
||||
html_from_markdown=document,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,6 +7,5 @@ Flask-Compress>=1.17
|
||||
Flask-Limiter>=3.12
|
||||
bokeh>=3.6.3
|
||||
markupsafe>=3.0.2
|
||||
Markdown>=3.7
|
||||
mdx-breakless-lists>=1.0.1
|
||||
setproctitle>=1.3.5
|
||||
mistune>=3.1.3
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
const gameInfo = document.getElementById('game-info');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, this game's info page is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the info page.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/${gameInfo.getAttribute('data-game')}/` +
|
||||
`${gameInfo.getAttribute('data-lang')}_${gameInfo.getAttribute('data-game')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
gameInfo.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,52 +0,0 @@
|
||||
window.addEventListener('load', () => {
|
||||
const tutorialWrapper = document.getElementById('tutorial-wrapper');
|
||||
new Promise((resolve, reject) => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
if (ajax.status === 404) {
|
||||
reject("Sorry, the tutorial is not available in that language yet.");
|
||||
return;
|
||||
}
|
||||
if (ajax.status !== 200) {
|
||||
reject("Something went wrong while loading the tutorial.");
|
||||
return;
|
||||
}
|
||||
resolve(ajax.responseText);
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/docs/` +
|
||||
`${tutorialWrapper.getAttribute('data-game')}/${tutorialWrapper.getAttribute('data-file')}_` +
|
||||
`${tutorialWrapper.getAttribute('data-lang')}.md`, true);
|
||||
ajax.send();
|
||||
}).then((results) => {
|
||||
// Populate page with HTML generated from markdown
|
||||
showdown.setOption('tables', true);
|
||||
showdown.setOption('strikethrough', true);
|
||||
showdown.setOption('literalMidWordUnderscores', true);
|
||||
showdown.setOption('disableForced4SpacesIndentedSublists', true);
|
||||
tutorialWrapper.innerHTML += (new showdown.Converter()).makeHtml(results);
|
||||
|
||||
const title = document.querySelector('h1')
|
||||
if (title) {
|
||||
document.title = title.textContent;
|
||||
}
|
||||
|
||||
// Reset the id of all header divs to something nicer
|
||||
for (const header of document.querySelectorAll('h1, h2, h3, h4, h5, h6')) {
|
||||
const headerId = header.innerText.replace(/\s+/g, '-').toLowerCase();
|
||||
header.setAttribute('id', headerId);
|
||||
header.addEventListener('click', () => {
|
||||
window.location.hash = `#${headerId}`;
|
||||
header.scrollIntoView();
|
||||
});
|
||||
}
|
||||
|
||||
// Manually scroll the user to the appropriate header if anchor navigation is used
|
||||
document.fonts.ready.finally(() => {
|
||||
if (window.location.hash) {
|
||||
const scrollTarget = document.getElementById(window.location.hash.substring(1));
|
||||
scrollTarget?.scrollIntoView();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
const showError = () => {
|
||||
const tutorial = document.getElementById('tutorial-landing');
|
||||
document.getElementById('page-title').innerText = 'This page is out of logic!';
|
||||
tutorial.removeChild(document.getElementById('loading'));
|
||||
const userMessage = document.createElement('h3');
|
||||
const homepageLink = document.createElement('a');
|
||||
homepageLink.innerText = 'Click here';
|
||||
homepageLink.setAttribute('href', '/');
|
||||
userMessage.append(homepageLink);
|
||||
userMessage.append(' to go back to safety!');
|
||||
tutorial.append(userMessage);
|
||||
};
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const ajax = new XMLHttpRequest();
|
||||
ajax.onreadystatechange = () => {
|
||||
if (ajax.readyState !== 4) { return; }
|
||||
const tutorialDiv = document.getElementById('tutorial-landing');
|
||||
if (ajax.status !== 200) { return showError(); }
|
||||
|
||||
try {
|
||||
const games = JSON.parse(ajax.responseText);
|
||||
games.forEach((game) => {
|
||||
const gameTitle = document.createElement('h2');
|
||||
gameTitle.innerText = game.gameTitle;
|
||||
gameTitle.id = `${encodeURIComponent(game.gameTitle)}`;
|
||||
tutorialDiv.appendChild(gameTitle);
|
||||
|
||||
game.tutorials.forEach((tutorial) => {
|
||||
const tutorialName = document.createElement('h3');
|
||||
tutorialName.innerText = tutorial.name;
|
||||
tutorialDiv.appendChild(tutorialName);
|
||||
|
||||
const tutorialDescription = document.createElement('p');
|
||||
tutorialDescription.innerText = tutorial.description;
|
||||
tutorialDiv.appendChild(tutorialDescription);
|
||||
|
||||
const intro = document.createElement('p');
|
||||
intro.innerText = 'This guide is available in the following languages:';
|
||||
tutorialDiv.appendChild(intro);
|
||||
|
||||
const fileList = document.createElement('ul');
|
||||
tutorial.files.forEach((file) => {
|
||||
const listItem = document.createElement('li');
|
||||
const anchor = document.createElement('a');
|
||||
anchor.innerText = file.language;
|
||||
anchor.setAttribute('href', `${window.location.origin}/tutorial/${file.link}`);
|
||||
listItem.appendChild(anchor);
|
||||
|
||||
listItem.append(' by ');
|
||||
for (let author of file.authors) {
|
||||
listItem.append(author);
|
||||
if (file.authors.indexOf(author) !== (file.authors.length -1)) {
|
||||
listItem.append(', ');
|
||||
}
|
||||
}
|
||||
|
||||
fileList.appendChild(listItem);
|
||||
});
|
||||
tutorialDiv.appendChild(fileList);
|
||||
});
|
||||
});
|
||||
|
||||
tutorialDiv.removeChild(document.getElementById('loading'));
|
||||
} catch (error) {
|
||||
showError();
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
// Check if we are on an anchor when coming in, and scroll to it.
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const offset = 128; // To account for navbar banner at top of page.
|
||||
window.scrollTo(0, 0);
|
||||
const rect = document.getElementById(hash.slice(1)).getBoundingClientRect();
|
||||
window.scrollTo(rect.left, rect.top - offset);
|
||||
}
|
||||
};
|
||||
ajax.open('GET', `${window.location.origin}/static/generated/tutorials.json`, true);
|
||||
ajax.send();
|
||||
});
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
<title>{{ game }} Info</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/gameInfo.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
{% include 'header/'+theme+'Header.html' %}
|
||||
<div id="game-info" class="markdown" data-lang="{{ lang }}" data-game="{{ game | get_file_safe_name }}">
|
||||
<!-- Populated my JS / MD -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,7 +1,8 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
{% set theme_name = theme|default("grass", true) %}
|
||||
{% include "header/"+theme_name+"Header.html" %}
|
||||
<title>{{ title }}</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{% extends 'pageWrapper.html' %}
|
||||
|
||||
{% block head %}
|
||||
{% include 'header/'+theme+'Header.html' %}
|
||||
<title>Archipelago</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/1.9.1/showdown.min.js"
|
||||
integrity="sha512-L03kznCrNOfVxOUovR6ESfCz9Gfny7gihUX/huVbQB9zjODtYpxaVtIaAkpetoiyV2eqWbvxMH9fiSv5enX7bw=="
|
||||
crossorigin="anonymous"></script>
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorial.js") }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="tutorial-wrapper" class="markdown" data-game="{{ game | get_file_safe_name }}" data-file="{{ file | get_file_safe_name }}" data-lang="{{ lang }}">
|
||||
<!-- Content generated by JavaScript -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,14 +3,32 @@
|
||||
{% block head %}
|
||||
{% include 'header/grassHeader.html' %}
|
||||
<title>Archipelago Guides</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}" />
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}" />
|
||||
<script type="application/ecmascript" src="{{ url_for('static', filename="assets/tutorialLanding.js") }}"></script>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/markdown.css") }}"/>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename="styles/tutorialLanding.css") }}"/>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="tutorial-landing" class="markdown" data-game="{{ game }}" data-file="{{ file }}" data-lang="{{ lang }}">
|
||||
<h1 id="page-title">Archipelago Guides</h1>
|
||||
<p id="loading">Loading...</p>
|
||||
<div id="tutorial-landing" class="markdown">
|
||||
<h1>Archipelago Guides</h1>
|
||||
{% for world_name, world_type in worlds.items() %}
|
||||
<h2 id="{{ world_type.game | urlencode }}">{{ world_type.game }}</h2>
|
||||
{% for tutorial_name, tutorial_data in tutorials[world_name].items() %}
|
||||
<h3>{{ tutorial_name }}</h3>
|
||||
<p>{{ tutorial_data.description }}</p>
|
||||
<p>This guide is available in the following languages:</p>
|
||||
<ul>
|
||||
{% for file_name, file_data in tutorial_data.files.items() %}
|
||||
<li>
|
||||
<a href="{{ url_for("tutorial", game=world_name, file=file_name) }}">{{ file_data.language }}</a>
|
||||
by
|
||||
{% for author in file_data.authors %}
|
||||
{{ author }}
|
||||
{% if not loop.last %}, {% endif %}
|
||||
{% endfor %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -333,6 +333,7 @@ async def nes_sync_task(ctx: ZeldaContext):
|
||||
except ConnectionRefusedError:
|
||||
logger.debug("Connection Refused, Trying Again")
|
||||
ctx.nes_status = CONNECTION_REFUSED_STATUS
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
|
||||
|
||||
|
||||
@@ -203,7 +203,7 @@
|
||||
/worlds/timespinner/ @Jarno458
|
||||
|
||||
# The Legend of Zelda (1)
|
||||
/worlds/tloz/ @Rosalie-A @t3hf1gm3nt
|
||||
/worlds/tloz/ @Rosalie-A
|
||||
|
||||
# TUNIC
|
||||
/worlds/tunic/ @silent-destroyer @ScipioWright
|
||||
|
||||
@@ -181,10 +181,3 @@ circular / partial imports. Instead, the code should fetch from settings on dema
|
||||
|
||||
"Global" settings are populated immediately, while worlds settings are lazy loaded, so if really necessary,
|
||||
"global" settings could be used in global scope of worlds.
|
||||
|
||||
|
||||
### APWorld Backwards Compatibility
|
||||
|
||||
APWorlds that want to be compatible with both stable and dev versions, have two options:
|
||||
1. use the old Utils.get_options() API until Archipelago 0.4.2 is out
|
||||
2. add some sort of compatibility code to your world that mimics the new API
|
||||
|
||||
@@ -612,17 +612,10 @@ def create_items(self) -> None:
|
||||
# If there are two of the same item, the item has to be twice in the pool.
|
||||
# Which items are added to the pool may depend on player options, e.g. custom win condition like triforce hunt.
|
||||
# Having an item in the start inventory won't remove it from the pool.
|
||||
# If an item can't have duplicates it has to be excluded manually.
|
||||
|
||||
# List of items to exclude, as a copy since it will be destroyed below
|
||||
exclude = [item for item in self.multiworld.precollected_items[self.player]]
|
||||
# If you want to do that, use start_inventory_from_pool
|
||||
|
||||
for item in map(self.create_item, mygame_items):
|
||||
if item in exclude:
|
||||
exclude.remove(item) # this is destructive. create unique list above
|
||||
self.multiworld.itempool.append(self.create_item("nothing"))
|
||||
else:
|
||||
self.multiworld.itempool.append(item)
|
||||
self.multiworld.itempool.append(item)
|
||||
|
||||
# itempool and number of locations should match up.
|
||||
# If this is not the case we want to fill the itempool with junk.
|
||||
|
||||
@@ -53,10 +53,6 @@ Name: "full"; Description: "Full installation"
|
||||
Name: "minimal"; Description: "Minimal installation"
|
||||
Name: "custom"; Description: "Custom installation"; Flags: iscustom
|
||||
|
||||
[Components]
|
||||
Name: "core"; Description: "Archipelago"; Types: full minimal custom; Flags: fixed
|
||||
Name: "lttp_sprites"; Description: "Download ""A Link to the Past"" player sprites"; Types: full;
|
||||
|
||||
[Dirs]
|
||||
NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify;
|
||||
|
||||
@@ -76,7 +72,6 @@ Name: "{commondesktop}\{#MyAppName} Launcher"; Filename: "{app}\ArchipelagoLaunc
|
||||
[Run]
|
||||
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..."
|
||||
Filename: "{app}\ArchipelagoLttPAdjuster"; Parameters: "--update_sprites"; StatusMsg: "Updating Sprite Library..."; Components: lttp_sprites
|
||||
Filename: "{app}\ArchipelagoLauncher"; Parameters: "--update_settings"; StatusMsg: "Updating host.yaml..."; Flags: runasoriginaluser runhidden
|
||||
Filename: "{app}\ArchipelagoLauncher"; Description: "{cm:LaunchProgram,{#StringChange('Launcher', '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[pytest]
|
||||
python_files = test_*.py Test*.py __init__.py # TODO: remove Test* once all worlds have been ported
|
||||
python_files = test_*.py Test*.py **/test*/**/__init__.py # TODO: remove Test* once all worlds have been ported
|
||||
python_classes = Test
|
||||
python_functions = test
|
||||
testpaths =
|
||||
|
||||
38
setup.py
38
setup.py
@@ -9,6 +9,7 @@ import subprocess
|
||||
import sys
|
||||
import sysconfig
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
import warnings
|
||||
import zipfile
|
||||
@@ -61,7 +62,6 @@ from Utils import version_tuple, is_windows, is_linux
|
||||
from Cython.Build import cythonize
|
||||
|
||||
|
||||
# On Python < 3.10 LogicMixin is not currently supported.
|
||||
non_apworlds: set[str] = {
|
||||
"A Link to the Past",
|
||||
"Adventure",
|
||||
@@ -78,9 +78,6 @@ non_apworlds: set[str] = {
|
||||
"Wargroove",
|
||||
}
|
||||
|
||||
# LogicMixin is broken before 3.10 import revamp
|
||||
if sys.version_info < (3,10):
|
||||
non_apworlds.add("Hollow Knight")
|
||||
|
||||
def download_SNI() -> None:
|
||||
print("Updating SNI")
|
||||
@@ -108,8 +105,8 @@ def download_SNI() -> None:
|
||||
# prefer "many" builds
|
||||
if "many" in download_url:
|
||||
break
|
||||
# prefer the correct windows or windows7 build
|
||||
if platform_name == "windows" and ("windows7" in download_url) == (sys.version_info < (3, 9)):
|
||||
# prefer non-windows7 builds to get up-to-date dependencies
|
||||
if platform_name == "windows" and "windows7" not in download_url:
|
||||
break
|
||||
|
||||
if source_url and source_url.endswith(".zip"):
|
||||
@@ -148,15 +145,16 @@ def download_SNI() -> None:
|
||||
print(f"No SNI found for system spec {platform_name} {machine_name}")
|
||||
|
||||
|
||||
signtool: str | None
|
||||
if os.path.exists("X:/pw.txt"):
|
||||
print("Using signtool")
|
||||
with open("X:/pw.txt", encoding="utf-8-sig") as f:
|
||||
pw = f.read()
|
||||
signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p "' + pw + \
|
||||
r'" /fd sha256 /td sha256 /tr http://timestamp.digicert.com/ '
|
||||
else:
|
||||
signtool = None
|
||||
signtool: str | None = None
|
||||
try:
|
||||
with urllib.request.urlopen('http://192.168.206.4:12345/connector/status') as response:
|
||||
html = response.read()
|
||||
if b"status=OK\n" in html:
|
||||
signtool = (r'signtool sign /sha1 6df76fe776b82869a5693ddcb1b04589cffa6faf /fd sha256 /td sha256 '
|
||||
r'/tr http://timestamp.digicert.com/ ')
|
||||
print("Using signtool")
|
||||
except (ConnectionError, TimeoutError, urllib.error.URLError) as e:
|
||||
pass
|
||||
|
||||
|
||||
build_platform = sysconfig.get_platform()
|
||||
@@ -201,9 +199,10 @@ extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else []
|
||||
|
||||
|
||||
def remove_sprites_from_folder(folder: Path) -> None:
|
||||
for file in os.listdir(folder):
|
||||
if file != ".gitignore":
|
||||
os.remove(folder / file)
|
||||
if os.path.isdir(folder):
|
||||
for file in os.listdir(folder):
|
||||
if file != ".gitignore":
|
||||
os.remove(folder / file)
|
||||
|
||||
|
||||
def _threaded_hash(filepath: str | Path) -> str:
|
||||
@@ -412,13 +411,14 @@ class BuildExeCommand(cx_Freeze.command.build_exe.build_exe):
|
||||
os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path))
|
||||
|
||||
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr")
|
||||
remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttp" / "remote")
|
||||
|
||||
self.create_manifest()
|
||||
|
||||
if is_windows:
|
||||
# Inno setup stuff
|
||||
with open("setup.ini", "w") as f:
|
||||
min_supported_windows = "6.2.9200" if sys.version_info > (3, 9) else "6.0.6000"
|
||||
min_supported_windows = "6.2.9200"
|
||||
f.write(f"[Data]\nsource_path={self.buildfolder}\nmin_windows={min_supported_windows}\n")
|
||||
with open("installdelete.iss", "w") as f:
|
||||
f.writelines("Type: filesandordirs; Name: \"{app}\\lib\\worlds\\"+world_directory+"\"\n"
|
||||
|
||||
@@ -29,14 +29,9 @@ def run_locations_benchmark():
|
||||
|
||||
rule_iterations: int = 100_000
|
||||
|
||||
if sys.version_info >= (3, 9):
|
||||
@staticmethod
|
||||
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
|
||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||
else:
|
||||
@staticmethod
|
||||
def format_times_from_counter(counter: collections.Counter, top: int = 5) -> str:
|
||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||
@staticmethod
|
||||
def format_times_from_counter(counter: collections.Counter[str], top: int = 5) -> str:
|
||||
return "\n".join(f" {time:.4f} in {name}" for name, time in counter.most_common(top))
|
||||
|
||||
def location_test(self, test_location: Location, state: CollectionState, state_name: str) -> float:
|
||||
with TimeIt(f"{test_location.game} {self.rule_iterations} "
|
||||
|
||||
@@ -8,7 +8,12 @@ class TestPackages(unittest.TestCase):
|
||||
to indicate full package rather than namespace package."""
|
||||
import Utils
|
||||
|
||||
# Ignore directories with these names.
|
||||
ignore_dirs = {".github"}
|
||||
|
||||
worlds_path = Utils.local_path("worlds")
|
||||
for dirpath, dirnames, filenames in os.walk(worlds_path):
|
||||
# Drop ignored directories from dirnames, excluding them from walking.
|
||||
dirnames[:] = [d for d in dirnames if d not in ignore_dirs]
|
||||
with self.subTest(directory=dirpath):
|
||||
self.assertEqual("__init__.py" in filenames, any(file.endswith(".py") for file in filenames))
|
||||
|
||||
@@ -33,6 +33,15 @@ class TestNumericOptions(unittest.TestCase):
|
||||
self.assertEqual(choice_option_alias, TestChoice.alias_three)
|
||||
self.assertEqual(choice_option_attr, TestChoice.non_option_attr)
|
||||
|
||||
self.assertLess(choice_option_string, "two")
|
||||
self.assertGreater(choice_option_string, "zero")
|
||||
self.assertLessEqual(choice_option_string, "one")
|
||||
self.assertLessEqual(choice_option_string, "two")
|
||||
self.assertGreaterEqual(choice_option_string, "one")
|
||||
self.assertGreaterEqual(choice_option_string, "zero")
|
||||
|
||||
self.assertGreaterEqual(choice_option_alias, "three")
|
||||
|
||||
self.assertRaises(KeyError, TestChoice.from_any, "four")
|
||||
|
||||
self.assertIn(choice_option_int, [1, 2, 3])
|
||||
|
||||
@@ -2,6 +2,8 @@ import unittest
|
||||
import Utils
|
||||
import os
|
||||
|
||||
from werkzeug.utils import secure_filename
|
||||
|
||||
import WebHost
|
||||
from worlds.AutoWorld import AutoWorldRegister
|
||||
|
||||
@@ -9,36 +11,30 @@ from worlds.AutoWorld import AutoWorldRegister
|
||||
class TestDocs(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
cls.tutorials_data = WebHost.create_ordered_tutorials_file()
|
||||
WebHost.copy_tutorials_files_to_static()
|
||||
|
||||
def test_has_tutorial(self):
|
||||
games_with_tutorial = set(entry["gameTitle"] for entry in self.tutorials_data)
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
with self.subTest(game_name):
|
||||
try:
|
||||
self.assertIn(game_name, games_with_tutorial)
|
||||
except AssertionError:
|
||||
# look for partial name in the tutorial name
|
||||
for game in games_with_tutorial:
|
||||
if game_name in game:
|
||||
break
|
||||
else:
|
||||
self.fail(f"{game_name} has no setup tutorial. "
|
||||
f"Games with Tutorial: {games_with_tutorial}")
|
||||
tutorials = world_type.web.tutorials
|
||||
self.assertGreater(len(tutorials), 0, msg=f"{game_name} has no setup tutorial.")
|
||||
|
||||
safe_name = secure_filename(game_name)
|
||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
|
||||
for tutorial in tutorials:
|
||||
self.assertTrue(
|
||||
os.path.isfile(Utils.local_path(target_path, secure_filename(tutorial.file_name))),
|
||||
f'{game_name} missing tutorial file {tutorial.file_name}.'
|
||||
)
|
||||
|
||||
def test_has_game_info(self):
|
||||
for game_name, world_type in AutoWorldRegister.world_types.items():
|
||||
if not world_type.hidden:
|
||||
safe_name = Utils.get_file_safe_name(game_name)
|
||||
safe_name = secure_filename(game_name)
|
||||
target_path = Utils.local_path("WebHostLib", "static", "generated", "docs", safe_name)
|
||||
for game_info_lang in world_type.web.game_info_languages:
|
||||
with self.subTest(game_name):
|
||||
self.assertTrue(
|
||||
safe_name == game_name or
|
||||
not os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{game_name}.md')),
|
||||
f'Info docs have be named <lang>_{safe_name}.md for {game_name}.'
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(Utils.local_path(target_path, f'{game_info_lang}_{safe_name}.md')),
|
||||
f'{game_name} missing game info file for "{game_info_lang}" language.'
|
||||
|
||||
@@ -29,8 +29,3 @@ class TestFileGeneration(unittest.TestCase):
|
||||
with open(file, encoding="utf-8-sig") as f:
|
||||
for value in roll_options({file.name: f.read()})[0].values():
|
||||
self.assertTrue(value is True, f"Default Options for template {file.name} cannot be run.")
|
||||
|
||||
def test_tutorial(self):
|
||||
WebHost.create_ordered_tutorials_file()
|
||||
self.assertTrue(os.path.exists(os.path.join(self.correct_path, "static", "generated", "tutorials.json")))
|
||||
self.assertFalse(os.path.exists(os.path.join(self.incorrect_path, "static", "generated", "tutorials.json")))
|
||||
|
||||
@@ -72,15 +72,6 @@ class AutoWorldRegister(type):
|
||||
dct["required_client_version"] = max(dct["required_client_version"],
|
||||
base.__dict__["required_client_version"])
|
||||
|
||||
# create missing options_dataclass from legacy option_definitions
|
||||
# TODO - remove this once all worlds use options dataclasses
|
||||
if "options_dataclass" not in dct and "option_definitions" in dct:
|
||||
# TODO - switch to deprecate after a version
|
||||
deprecate(f"{name} Assigned options through option_definitions which is now deprecated. "
|
||||
"Please use options_dataclass instead.")
|
||||
dct["options_dataclass"] = make_dataclass(f"{name}Options", dct["option_definitions"].items(),
|
||||
bases=(PerGameCommonOptions,))
|
||||
|
||||
# construct class
|
||||
new_class = super().__new__(mcs, name, bases, dct)
|
||||
new_class.__file__ = sys.modules[new_class.__module__].__file__
|
||||
@@ -493,9 +484,6 @@ class World(metaclass=AutoWorldRegister):
|
||||
Creates a group, which is an instance of World that is responsible for multiple others.
|
||||
An example case is ItemLinks creating these.
|
||||
"""
|
||||
# TODO remove loop when worlds use options dataclass
|
||||
for option_key, option in cls.options_dataclass.type_hints.items():
|
||||
getattr(multiworld, option_key)[new_player_id] = option.from_any(option.default)
|
||||
group = cls(multiworld, new_player_id)
|
||||
group.options = cls.options_dataclass(**{option_key: option.from_any(option.default)
|
||||
for option_key, option in cls.options_dataclass.type_hints.items()})
|
||||
|
||||
@@ -63,9 +63,7 @@ class WorldSource:
|
||||
sys.modules[mod.__name__] = mod
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", message="__package__ != __spec__.parent")
|
||||
# Found no equivalent for < 3.10
|
||||
if hasattr(importer, "exec_module"):
|
||||
importer.exec_module(mod)
|
||||
importer.exec_module(mod)
|
||||
else:
|
||||
importlib.import_module(f".{self.path}", "worlds")
|
||||
self.time_taken = time.perf_counter()-start
|
||||
|
||||
@@ -9,6 +9,7 @@ import enum
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
import settings
|
||||
from CommonClient import CommonContext, ClientCommandProcessor, get_base_parser, server_loop, logger, gui_enabled
|
||||
import Patch
|
||||
import Utils
|
||||
@@ -304,10 +305,10 @@ async def _game_watcher(ctx: BizHawkClientContext):
|
||||
|
||||
async def _run_game(rom: str):
|
||||
import os
|
||||
auto_start = Utils.get_settings().bizhawkclient_options.rom_start
|
||||
auto_start = settings.get_settings().bizhawkclient_options.rom_start
|
||||
|
||||
if auto_start is True:
|
||||
emuhawk_path = Utils.get_settings().bizhawkclient_options.emuhawk_path
|
||||
emuhawk_path = settings.get_settings().bizhawkclient_options.emuhawk_path
|
||||
subprocess.Popen(
|
||||
[
|
||||
emuhawk_path,
|
||||
|
||||
@@ -34,7 +34,7 @@ class AWebInTime(WebWorld):
|
||||
"Multiworld Setup Guide",
|
||||
"A guide for setting up A Hat in Time to be played in Archipelago.",
|
||||
"English",
|
||||
"ahit_en.md",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["CookieCat"]
|
||||
)]
|
||||
|
||||
@@ -209,8 +209,8 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
if localized:
|
||||
in_dungeon_items = [item for item in get_dungeon_item_pool(multiworld) if (item.player, item.name) in localized]
|
||||
if in_dungeon_items:
|
||||
restricted_players = {player for player, restricted in multiworld.restrict_dungeon_item_on_boss.items() if
|
||||
restricted}
|
||||
restricted_players = {world.player for world in multiworld.get_game_worlds("A Link to the Past") if
|
||||
world.options.restrict_dungeon_item_on_boss}
|
||||
locations: typing.List["ALttPLocation"] = [
|
||||
location for location in get_unfilled_dungeon_locations(multiworld)
|
||||
# filter boss
|
||||
@@ -255,8 +255,9 @@ def fill_dungeons_restrictive(multiworld: MultiWorld):
|
||||
if all_state_base.has("Triforce", player):
|
||||
all_state_base.remove(multiworld.worlds[player].create_item("Triforce"))
|
||||
|
||||
for (player, key_drop_shuffle) in multiworld.key_drop_shuffle.items():
|
||||
if not key_drop_shuffle and player not in multiworld.groups:
|
||||
for lttp_world in multiworld.get_game_worlds("A Link to the Past"):
|
||||
if not lttp_world.options.key_drop_shuffle and lttp_world.player not in multiworld.groups:
|
||||
player = lttp_world.player
|
||||
for key_loc in key_drop_data:
|
||||
key_data = key_drop_data[key_loc]
|
||||
all_state_base.remove(item_factory(key_data[3], multiworld.worlds[player]))
|
||||
|
||||
@@ -223,7 +223,7 @@ items_reduction_table = (
|
||||
|
||||
|
||||
def generate_itempool(world):
|
||||
player = world.player
|
||||
player: int = world.player
|
||||
multiworld = world.multiworld
|
||||
|
||||
if world.options.item_pool.current_key not in difficulties:
|
||||
@@ -280,7 +280,6 @@ def generate_itempool(world):
|
||||
if multiworld.custom:
|
||||
pool, placed_items, precollected_items, clock_mode, treasure_hunt_required = (
|
||||
make_custom_item_pool(multiworld, player))
|
||||
multiworld.rupoor_cost = min(multiworld.customitemarray[67], 9999)
|
||||
else:
|
||||
(pool, placed_items, precollected_items, clock_mode, treasure_hunt_required, treasure_hunt_total,
|
||||
additional_triforce_pieces) = get_pool_core(multiworld, player)
|
||||
@@ -386,8 +385,8 @@ def generate_itempool(world):
|
||||
|
||||
if world.options.retro_bow:
|
||||
shop_items = 0
|
||||
shop_locations = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if
|
||||
shop.type == ShopType.Shop and shop.region.player == player) for location in shop_locations if
|
||||
shop_locations = [location for shop_locations in (shop.region.locations for shop in world.shops if
|
||||
shop.type == ShopType.Shop) for location in shop_locations if
|
||||
location.shop_slot is not None]
|
||||
for location in shop_locations:
|
||||
if location.shop.inventory[location.shop_slot]["item"] == "Single Arrow":
|
||||
@@ -546,7 +545,7 @@ def set_up_take_anys(multiworld, world, player):
|
||||
connect_entrance(multiworld, entrance.name, old_man_take_any.name, player)
|
||||
entrance.target = 0x58
|
||||
old_man_take_any.shop = TakeAny(old_man_take_any, 0x0112, 0xE2, True, True, total_shop_slots)
|
||||
multiworld.shops.append(old_man_take_any.shop)
|
||||
world.shops.append(old_man_take_any.shop)
|
||||
|
||||
sword_indices = [
|
||||
index for index, item in enumerate(multiworld.itempool) if item.player == player and item.type == 'Sword'
|
||||
@@ -574,7 +573,7 @@ def set_up_take_anys(multiworld, world, player):
|
||||
connect_entrance(multiworld, entrance.name, take_any.name, player)
|
||||
entrance.target = target
|
||||
take_any.shop = TakeAny(take_any, room_id, 0xE3, True, True, total_shop_slots + num + 1)
|
||||
multiworld.shops.append(take_any.shop)
|
||||
world.shops.append(take_any.shop)
|
||||
take_any.shop.add_inventory(0, 'Blue Potion', 0, 0)
|
||||
take_any.shop.add_inventory(1, 'Boss Heart Container', 0, 0)
|
||||
location = ALttPLocation(player, take_any.name, shop_table_by_location[take_any.name], parent=take_any)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import Utils
|
||||
import settings
|
||||
import worlds.Files
|
||||
|
||||
LTTPJPN10HASH: str = "03a63945398191337e896e5771f77173"
|
||||
@@ -514,7 +515,8 @@ def _populate_sprite_table():
|
||||
logging.debug(f"Spritefile {file} could not be loaded as a valid sprite.")
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
sprite_paths = [user_path('data', 'sprites', 'alttpr'), user_path('data', 'sprites', 'custom')]
|
||||
sprite_paths = [user_path("data", "sprites", "alttp", "remote"),
|
||||
user_path("data", "sprites", "alttp", "custom")]
|
||||
for dir in [dir for dir in sprite_paths if os.path.isdir(dir)]:
|
||||
for file in os.listdir(dir):
|
||||
pool.submit(load_sprite_from_file, os.path.join(dir, file))
|
||||
@@ -1001,14 +1003,19 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
|
||||
# set light cones
|
||||
rom.write_byte(0x180038, 0x01 if local_world.options.mode == "standard" else 0x00)
|
||||
rom.write_byte(0x180039, 0x01 if world.light_world_light_cone else 0x00)
|
||||
rom.write_byte(0x18003A, 0x01 if world.dark_world_light_cone else 0x00)
|
||||
# light world light cone
|
||||
rom.write_byte(0x180039, local_world.light_world_light_cone)
|
||||
# dark world light cone
|
||||
rom.write_byte(0x18003A, local_world.dark_world_light_cone)
|
||||
|
||||
GREEN_TWENTY_RUPEES = 0x47
|
||||
GREEN_CLOCK = item_table["Green Clock"].item_code
|
||||
|
||||
rom.write_byte(0x18004F, 0x01) # Byrna Invulnerability: on
|
||||
|
||||
# Rupoor negative value
|
||||
rom.write_int16(0x180036, local_world.rupoor_cost)
|
||||
|
||||
# handle item_functionality
|
||||
if local_world.options.item_functionality == 'hard':
|
||||
rom.write_byte(0x180181, 0x01) # Make silver arrows work only on ganon
|
||||
@@ -1026,8 +1033,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
# Disable catching fairies
|
||||
rom.write_byte(0x34FD6, 0x80)
|
||||
overflow_replacement = GREEN_TWENTY_RUPEES
|
||||
# Rupoor negative value
|
||||
rom.write_int16(0x180036, world.rupoor_cost)
|
||||
# Set stun items
|
||||
rom.write_byte(0x180180, 0x02) # Hookshot only
|
||||
elif local_world.options.item_functionality == 'expert':
|
||||
@@ -1046,8 +1051,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
# Disable catching fairies
|
||||
rom.write_byte(0x34FD6, 0x80)
|
||||
overflow_replacement = GREEN_TWENTY_RUPEES
|
||||
# Rupoor negative value
|
||||
rom.write_int16(0x180036, world.rupoor_cost)
|
||||
# Set stun items
|
||||
rom.write_byte(0x180180, 0x00) # Nothing
|
||||
else:
|
||||
@@ -1065,8 +1068,6 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
rom.write_byte(0x18004F, 0x01)
|
||||
# Enable catching fairies
|
||||
rom.write_byte(0x34FD6, 0xF0)
|
||||
# Rupoor negative value
|
||||
rom.write_int16(0x180036, world.rupoor_cost)
|
||||
# Set stun items
|
||||
rom.write_byte(0x180180, 0x03) # All standard items
|
||||
# Set overflow items for progressive equipment
|
||||
@@ -1312,7 +1313,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
rom.write_byte(0x18008C, 0x01 if local_world.options.crystals_needed_for_gt == 0 else 0x00) # GT pre-opened if crystal requirement is 0
|
||||
rom.write_byte(0xF5D73, 0xF0) # bees are catchable
|
||||
rom.write_byte(0xF5F10, 0xF0) # bees are catchable
|
||||
rom.write_byte(0x180086, 0x00 if world.aga_randomness else 0x01) # set blue ball and ganon warp randomness
|
||||
rom.write_byte(0x180086, 0x00) # set blue ball and ganon warp randomness
|
||||
rom.write_byte(0x1800A0, 0x01) # return to light world on s+q without mirror
|
||||
rom.write_byte(0x1800A1, 0x01) # enable overworld screen transition draining for water level inside swamp
|
||||
rom.write_byte(0x180174, 0x01 if local_world.fix_fake_world else 0x00)
|
||||
@@ -1617,7 +1618,7 @@ def patch_rom(world: MultiWorld, rom: LocalRom, player: int, enemized: bool):
|
||||
rom.write_byte(0x1800A3, 0x01) # enable correct world setting behaviour after agahnim kills
|
||||
rom.write_byte(0x1800A4, 0x01 if local_world.options.glitches_required != 'no_logic' else 0x00) # enable POD EG fix
|
||||
rom.write_byte(0x186383, 0x01 if local_world.options.glitches_required == 'no_logic' else 0x00) # disable glitching to Triforce from Ganons Room
|
||||
rom.write_byte(0x180042, 0x01 if world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill
|
||||
rom.write_byte(0x180042, 0x01 if local_world.save_and_quit_from_boss else 0x00) # Allow Save and Quit after boss kill
|
||||
|
||||
# remove shield from uncle
|
||||
rom.write_bytes(0x6D253, [0x00, 0x00, 0xf6, 0xff, 0x00, 0x0E])
|
||||
@@ -1738,8 +1739,7 @@ def get_price_data(price: int, price_type: int) -> List[int]:
|
||||
|
||||
|
||||
def write_custom_shops(rom, world, player):
|
||||
shops = sorted([shop for shop in world.shops if shop.custom and shop.region.player == player],
|
||||
key=lambda shop: shop.sram_offset)
|
||||
shops = sorted([shop for shop in world.worlds[player].shops if shop.custom], key=lambda shop: shop.sram_offset)
|
||||
|
||||
shop_data = bytearray()
|
||||
items_data = bytearray()
|
||||
@@ -3023,7 +3023,7 @@ def get_base_rom_bytes(file_name: str = "") -> bytes:
|
||||
|
||||
|
||||
def get_base_rom_path(file_name: str = "") -> str:
|
||||
options = Utils.get_settings()
|
||||
options = settings.get_settings()
|
||||
if not file_name:
|
||||
file_name = options["lttp_options"]["rom_file"]
|
||||
if not os.path.exists(file_name):
|
||||
|
||||
@@ -147,7 +147,6 @@ def set_defeat_dungeon_boss_rule(location):
|
||||
add_rule(location, lambda state: location.parent_region.dungeon.boss.can_defeat(state))
|
||||
|
||||
|
||||
|
||||
def set_always_allow(spot, rule):
|
||||
spot.always_allow = rule
|
||||
|
||||
@@ -980,18 +979,19 @@ def check_is_dark_world(region):
|
||||
return False
|
||||
|
||||
|
||||
def add_conditional_lamps(world, player):
|
||||
def add_conditional_lamps(multiworld, player):
|
||||
# Light cones in standard depend on which world we actually are in, not which one the location would normally be
|
||||
# We add Lamp requirements only to those locations which lie in the dark world (or everything if open
|
||||
local_world = multiworld.worlds[player]
|
||||
|
||||
def add_conditional_lamp(spot, region, spottype='Location', accessible_torch=False):
|
||||
if (not world.dark_world_light_cone and check_is_dark_world(world.get_region(region, player))) or (
|
||||
not world.light_world_light_cone and not check_is_dark_world(world.get_region(region, player))):
|
||||
if (not local_world.dark_world_light_cone and check_is_dark_world(local_world.get_region(region))) or (
|
||||
not local_world.light_world_light_cone and not check_is_dark_world(local_world.get_region(region))):
|
||||
if spottype == 'Location':
|
||||
spot = world.get_location(spot, player)
|
||||
spot = local_world.get_location(spot)
|
||||
else:
|
||||
spot = world.get_entrance(spot, player)
|
||||
add_lamp_requirement(world, spot, player, accessible_torch)
|
||||
spot = local_world.get_entrance(spot)
|
||||
add_lamp_requirement(multiworld, spot, player, accessible_torch)
|
||||
|
||||
add_conditional_lamp('Misery Mire (Vitreous)', 'Misery Mire (Entrance)', 'Entrance')
|
||||
add_conditional_lamp('Turtle Rock (Dark Room) (North)', 'Turtle Rock (Entrance)', 'Entrance')
|
||||
@@ -1002,7 +1002,7 @@ def add_conditional_lamps(world, player):
|
||||
'Location', True)
|
||||
add_conditional_lamp('Palace of Darkness - Dark Basement - Right', 'Palace of Darkness (Entrance)',
|
||||
'Location', True)
|
||||
if world.worlds[player].options.mode != 'inverted':
|
||||
if multiworld.worlds[player].options.mode != 'inverted':
|
||||
add_conditional_lamp('Agahnim 1', 'Agahnims Tower', 'Entrance')
|
||||
add_conditional_lamp('Castle Tower - Dark Maze', 'Agahnims Tower')
|
||||
add_conditional_lamp('Castle Tower - Dark Archer Key Drop', 'Agahnims Tower')
|
||||
@@ -1024,10 +1024,10 @@ def add_conditional_lamps(world, player):
|
||||
add_conditional_lamp('Eastern Palace - Boss', 'Eastern Palace', 'Location', True)
|
||||
add_conditional_lamp('Eastern Palace - Prize', 'Eastern Palace', 'Location', True)
|
||||
|
||||
if not world.worlds[player].options.mode == "standard":
|
||||
add_lamp_requirement(world, world.get_location('Sewers - Dark Cross', player), player)
|
||||
add_lamp_requirement(world, world.get_entrance('Sewers Back Door', player), player)
|
||||
add_lamp_requirement(world, world.get_entrance('Throne Room', player), player)
|
||||
if not multiworld.worlds[player].options.mode == "standard":
|
||||
add_lamp_requirement(multiworld, local_world.get_location("Sewers - Dark Cross"), player)
|
||||
add_lamp_requirement(multiworld, local_world.get_entrance("Sewers Back Door"), player)
|
||||
add_lamp_requirement(multiworld, local_world.get_entrance("Throne Room"), player)
|
||||
|
||||
|
||||
def open_rules(world, player):
|
||||
|
||||
@@ -14,8 +14,6 @@ from .Items import item_name_groups
|
||||
|
||||
from .StateHelpers import has_hearts, can_use_bombs, can_hold_arrows
|
||||
|
||||
logger = logging.getLogger("Shops")
|
||||
|
||||
|
||||
@unique
|
||||
class ShopType(IntEnum):
|
||||
@@ -162,7 +160,10 @@ shop_class_mapping = {ShopType.UpgradeShop: UpgradeShop,
|
||||
|
||||
|
||||
def push_shop_inventories(multiworld):
|
||||
shop_slots = [location for shop_locations in (shop.region.locations for shop in multiworld.shops if shop.type
|
||||
all_shops = []
|
||||
for world in multiworld.get_game_worlds(ALttPLocation.game):
|
||||
all_shops.extend(world.shops)
|
||||
shop_slots = [location for shop_locations in (shop.region.locations for shop in all_shops if shop.type
|
||||
!= ShopType.TakeAny) for location in shop_locations if location.shop_slot is not None]
|
||||
|
||||
for location in shop_slots:
|
||||
@@ -178,7 +179,7 @@ def push_shop_inventories(multiworld):
|
||||
get_price(multiworld, location.shop.inventory[location.shop_slot], location.player,
|
||||
location.shop_price_type)[1])
|
||||
|
||||
for world in multiworld.get_game_worlds("A Link to the Past"):
|
||||
for world in multiworld.get_game_worlds(ALttPLocation.game):
|
||||
world.pushed_shop_inventories.set()
|
||||
|
||||
|
||||
@@ -225,7 +226,7 @@ def create_shops(multiworld, player: int):
|
||||
if locked is None:
|
||||
shop.locked = True
|
||||
region.shop = shop
|
||||
multiworld.shops.append(shop)
|
||||
multiworld.worlds[player].shops.append(shop)
|
||||
for index, item in enumerate(inventory):
|
||||
shop.add_inventory(index, *item)
|
||||
if not locked and (num_slots or type == ShopType.UpgradeShop):
|
||||
@@ -309,50 +310,50 @@ def set_up_shops(multiworld, player: int):
|
||||
from .Options import small_key_shuffle
|
||||
# TODO: move hard+ mode changes for shields here, utilizing the new shops
|
||||
|
||||
if multiworld.worlds[player].options.retro_bow:
|
||||
local_world = multiworld.worlds[player]
|
||||
|
||||
if local_world.options.retro_bow:
|
||||
rss = multiworld.get_region('Red Shield Shop', player).shop
|
||||
# Can't just replace the single arrow with 10 arrows as retro doesn't need them.
|
||||
replacement_items = [['Red Potion', 150], ['Green Potion', 75], ['Blue Potion', 200], ['Bombs (10)', 50],
|
||||
['Blue Shield', 50], ['Small Heart',
|
||||
10]] # Can't just replace the single arrow with 10 arrows as retro doesn't need them.
|
||||
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
|
||||
['Blue Shield', 50], ['Small Heart', 10]]
|
||||
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal:
|
||||
replacement_items.append(['Small Key (Universal)', 100])
|
||||
replacement_item = multiworld.random.choice(replacement_items)
|
||||
rss.add_inventory(2, 'Single Arrow', 80, 1, replacement_item[0], replacement_item[1])
|
||||
rss.locked = True
|
||||
|
||||
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal or multiworld.worlds[player].options.retro_bow:
|
||||
for shop in multiworld.random.sample([s for s in multiworld.shops if
|
||||
s.custom and not s.locked and s.type == ShopType.Shop
|
||||
and s.region.player == player], 5):
|
||||
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal or local_world.options.retro_bow:
|
||||
for shop in multiworld.random.sample([s for s in local_world.shops if
|
||||
s.custom and not s.locked and s.type == ShopType.Shop], 5):
|
||||
shop.locked = True
|
||||
slots = [0, 1, 2]
|
||||
multiworld.random.shuffle(slots)
|
||||
slots = iter(slots)
|
||||
if multiworld.worlds[player].options.small_key_shuffle == small_key_shuffle.option_universal:
|
||||
if local_world.options.small_key_shuffle == small_key_shuffle.option_universal:
|
||||
shop.add_inventory(next(slots), 'Small Key (Universal)', 100)
|
||||
if multiworld.worlds[player].options.retro_bow:
|
||||
if local_world.options.retro_bow:
|
||||
shop.push_inventory(next(slots), 'Single Arrow', 80)
|
||||
|
||||
if multiworld.worlds[player].options.shuffle_capacity_upgrades:
|
||||
for shop in multiworld.shops:
|
||||
if shop.type == ShopType.UpgradeShop and shop.region.player == player and \
|
||||
if local_world.options.shuffle_capacity_upgrades:
|
||||
for shop in local_world.shops:
|
||||
if shop.type == ShopType.UpgradeShop and \
|
||||
shop.region.name == "Capacity Upgrade":
|
||||
shop.clear_inventory()
|
||||
|
||||
if (multiworld.worlds[player].options.shuffle_shop_inventories or multiworld.worlds[player].options.randomize_shop_prices
|
||||
or multiworld.worlds[player].options.randomize_cost_types):
|
||||
if (local_world.options.shuffle_shop_inventories or local_world.options.randomize_shop_prices
|
||||
or local_world.options.randomize_cost_types):
|
||||
shops = []
|
||||
total_inventory = []
|
||||
for shop in multiworld.shops:
|
||||
if shop.region.player == player:
|
||||
if shop.type == ShopType.Shop and not shop.locked:
|
||||
shops.append(shop)
|
||||
total_inventory.extend(shop.inventory)
|
||||
for shop in local_world.shops:
|
||||
if shop.type == ShopType.Shop and not shop.locked:
|
||||
shops.append(shop)
|
||||
total_inventory.extend(shop.inventory)
|
||||
|
||||
for item in total_inventory:
|
||||
item["price_type"], item["price"] = get_price(multiworld, item, player)
|
||||
|
||||
if multiworld.worlds[player].options.shuffle_shop_inventories:
|
||||
if local_world.options.shuffle_shop_inventories:
|
||||
multiworld.random.shuffle(total_inventory)
|
||||
|
||||
i = 0
|
||||
@@ -407,7 +408,7 @@ price_rate_display = {
|
||||
}
|
||||
|
||||
|
||||
def get_price_modifier(item):
|
||||
def get_price_modifier(item) -> float:
|
||||
if item.game == "A Link to the Past":
|
||||
if any(x in item.name for x in
|
||||
['Compass', 'Map', 'Single Bomb', 'Single Arrow', 'Piece of Heart']):
|
||||
@@ -418,9 +419,9 @@ def get_price_modifier(item):
|
||||
elif any(x in item.name for x in ['Small Key', 'Heart']):
|
||||
return 0.5
|
||||
else:
|
||||
return 1
|
||||
return 1.0
|
||||
if item.advancement:
|
||||
return 1
|
||||
return 1.0
|
||||
elif item.useful:
|
||||
return 0.5
|
||||
else:
|
||||
@@ -471,7 +472,7 @@ def get_price(multiworld, item, player: int, price_type=None):
|
||||
|
||||
def shop_price_rules(state: CollectionState, player: int, location: ALttPLocation):
|
||||
if location.shop_price_type == ShopPriceType.Hearts:
|
||||
return has_hearts(state, player, (location.shop_price / 8) + 1)
|
||||
return has_hearts(state, player, (location.shop_price // 8) + 1)
|
||||
elif location.shop_price_type == ShopPriceType.Bombs:
|
||||
return can_use_bombs(state, player, location.shop_price)
|
||||
elif location.shop_price_type == ShopPriceType.Arrows:
|
||||
|
||||
@@ -14,13 +14,13 @@ def can_bomb_clip(state: CollectionState, region: LTTPRegion, player: int) -> bo
|
||||
|
||||
|
||||
def can_buy_unlimited(state: CollectionState, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has_unlimited(item) and shop.region.can_reach(state) for
|
||||
shop in state.multiworld.shops)
|
||||
return any(shop.has_unlimited(item) and shop.region.can_reach(state) for
|
||||
shop in state.multiworld.worlds[player].shops)
|
||||
|
||||
|
||||
def can_buy(state: CollectionState, item: str, player: int) -> bool:
|
||||
return any(shop.region.player == player and shop.has(item) and shop.region.can_reach(state) for
|
||||
shop in state.multiworld.shops)
|
||||
return any(shop.has(item) and shop.region.can_reach(state) for
|
||||
shop in state.multiworld.worlds[player].shops)
|
||||
|
||||
|
||||
def can_shoot_arrows(state: CollectionState, player: int, count: int = 0) -> bool:
|
||||
|
||||
@@ -236,6 +236,8 @@ class ALTTPWorld(World):
|
||||
required_client_version = (0, 4, 1)
|
||||
web = ALTTPWeb()
|
||||
|
||||
shops: list[Shop]
|
||||
|
||||
pedestal_credit_texts: typing.Dict[int, str] = \
|
||||
{data.item_code: data.pedestal_credit for data in item_table.values() if data.pedestal_credit}
|
||||
sickkid_credit_texts: typing.Dict[int, str] = \
|
||||
@@ -282,6 +284,10 @@ class ALTTPWorld(World):
|
||||
clock_mode: str = ""
|
||||
treasure_hunt_required: int = 0
|
||||
treasure_hunt_total: int = 0
|
||||
light_world_light_cone: bool = False
|
||||
dark_world_light_cone: bool = False
|
||||
save_and_quit_from_boss: bool = True
|
||||
rupoor_cost: int = 10
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.dungeon_local_item_names = set()
|
||||
@@ -298,6 +304,7 @@ class ALTTPWorld(World):
|
||||
self.fix_trock_exit = None
|
||||
self.required_medallions = ["Ether", "Quake"]
|
||||
self.escape_assist = []
|
||||
self.shops = []
|
||||
super(ALTTPWorld, self).__init__(*args, **kwargs)
|
||||
|
||||
@classmethod
|
||||
@@ -800,7 +807,7 @@ class ALTTPWorld(World):
|
||||
|
||||
return shop_data
|
||||
|
||||
if shop_info := [build_shop_info(shop) for shop in self.multiworld.shops if shop.custom]:
|
||||
if shop_info := [build_shop_info(shop) for shop in self.shops if shop.custom]:
|
||||
spoiler_handle.write('\n\nShops:\n\n')
|
||||
for shop_data in shop_info:
|
||||
spoiler_handle.write("{} [{}]\n {}\n".format(shop_data['location'], shop_data['type'], "\n ".join(
|
||||
|
||||
@@ -34,62 +34,75 @@ business!
|
||||
|
||||
## I don't know what to do!
|
||||
|
||||
That's not a question - but I'd suggest clicking the crow icon on your client, which will load an AP compatible autotracker for LADXR.
|
||||
That's not a question - but I'd suggest clicking the **Open Tracker** button in your client, which will load an AP compatible autotracker for LADXR.
|
||||
|
||||
## What is this randomizer based on?
|
||||
|
||||
This randomizer is based on (forked from) the wonderful work daid did on LADXR - https://github.com/daid/LADXR
|
||||
This randomizer is based on (forked from) the wonderful work daid did on [LADXR](https://github.com/daid/LADXR)
|
||||
|
||||
The autotracker code for communication with magpie tracker is directly copied from kbranch's repo - https://github.com/kbranch/Magpie/tree/master/autotracking
|
||||
The autotracker code for communication with magpie tracker is directly copied from [kbranch's repo](https://github.com/kbranch/Magpie)
|
||||
|
||||
### Graphics
|
||||
|
||||
The following sprite sheets have been included with permission of their respective authors:
|
||||
|
||||
* by Madam Materia (https://www.twitch.tv/isabelle_zephyr)
|
||||
* by [Madam Materia](https://www.twitch.tv/isabelle_zephyr)
|
||||
* Matty_LA
|
||||
* by Linker (https://twitter.com/BenjaminMaksym)
|
||||
* Bowwow
|
||||
* Bunny
|
||||
* Luigi
|
||||
* Mario
|
||||
* Richard
|
||||
* Tarin
|
||||
|
||||
Title screen graphics by toomanyteeth✨ (https://instagram.com/toomanyyyteeth)
|
||||
Title screen graphics by [toomanyteeth✨](https://instagram.com/toomanyyyteeth)
|
||||
|
||||
## Some tips from LADXR...
|
||||
|
||||
<h3>Locations</h3>
|
||||
<p>All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed.</p>
|
||||
<p>The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations.</p>
|
||||
<p>Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5.</p>
|
||||
### Locations
|
||||
|
||||
<h3>Color Dungeon</h3>
|
||||
<p>The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items.</p>
|
||||
<p>To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right.</p>
|
||||
All chests and dungeon keys are always randomized. Also, the 3 songs (Marin, Mambo, and Manu) give a you an item if you present them the Ocarina. The seashell mansion 20 shells reward is also shuffled, but the 5 and 10 shell reward is not, as those can be missed.
|
||||
|
||||
<h3>Bowwow</h3>
|
||||
<p>Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else.</p>
|
||||
The moblin cave with Bowwow contains a chest instead. The color dungeon gives 2 items at the end instead of a choice of tunic. Other item locations are: The toadstool, the reward for delivering the toadstool, hidden seashells, heart pieces, heart containers, golden leaves, the Mad Batters (capacity upgrades), the shovel/bow in the shop, the rooster's grave, and all of the keys' (tail,slime,angler,face,bird) locations.
|
||||
|
||||
<h3>Added things</h3>
|
||||
<p>In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando).</p>
|
||||
<p>If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out.</p>
|
||||
<p>The flying rooster is (optionally) available as an item.</p>
|
||||
<p>You can access the Bird Key cave item with the L2 Power Bracelet.</p>
|
||||
<p>Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool.</p>
|
||||
<p>Your inventory has been increased by four, to accommodate these items now coexisting with eachother.</p>
|
||||
Finally, new players often forget the following locations: the heart piece hidden in the water at the castle, the heart piece hidden in the bomb cave (screen before the honey), bonk seashells (run with pegasus boots against the tree in at the Tail Cave, and the tree right of Mabe Village, next to the phone booth), and the hookshop drop from Master Stalfos in D5.
|
||||
|
||||
<h3>Removed things</h3>
|
||||
<p>The ghost mini-quest after D4 never shows up, his seashell reward is always available.</p>
|
||||
<p>The walrus is moved a bit, so that you can access the desert without taking Marin on a date.</p>
|
||||
### Color Dungeon
|
||||
|
||||
<h3>Logic</h3>
|
||||
<p>Depending on your options, you can only steal after you find the sword, always, or never.</p>
|
||||
<p>Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers.</p>
|
||||
<p>Killing enemies with bombs is in normal logic. You can switch to casual logic if you do not want this.</p>
|
||||
<p>D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet.</p>
|
||||
The Color Dungeon is part of the item shuffle, and the red/blue tunics are shuffled in the item pool. Which means the fairy at the end of the color dungeon gives out two random items.
|
||||
|
||||
<h3>Tech</h3>
|
||||
<p>The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang.</p>
|
||||
<p>The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.</p>
|
||||
To access the color dungeon, you need the power bracelet, and you need to push the gravestones in the right order: "down, left, up, right, up", going from the lower right gravestone, to the one left of it, above it, and then to the right.
|
||||
|
||||
### Bowwow
|
||||
|
||||
Bowwow is in a chest, somewhere. After you find him, he will always be in the swamp with you, but not anywhere else.
|
||||
|
||||
### Added things
|
||||
|
||||
In your save and quit menu, there is a 3rd option to return to your home. This has two main uses: it speeds up the game, and prevents softlocks (common in entrance rando).
|
||||
|
||||
If you have weapons that require ammunition (bombs, powder, arrows), a ghost will show up inside Marin's house. He will refill you up to 10 ammunition, so you do not run out.
|
||||
|
||||
The flying rooster is (optionally) available as an item.
|
||||
|
||||
If the rooster is disabled, you can access the Bird Key cave item with the L2 Power Bracelet.
|
||||
|
||||
Boomerang cave is now a random item gift by default (available post-bombs), and boomerang is in the item pool.
|
||||
|
||||
Your inventory has been increased by four, to accommodate these items now coexisting with eachother.
|
||||
|
||||
### Removed things
|
||||
|
||||
The ghost mini-quest after D4 never shows up, his seashell reward is always available.
|
||||
|
||||
The walrus is moved a bit, so that you can access the desert without taking Marin on a date.
|
||||
|
||||
### Logic
|
||||
|
||||
You can only steal after you find the sword.
|
||||
|
||||
Do not forget that there are two items in the rafting ride. You can access this with just Hookshot or Flippers.
|
||||
|
||||
Killing enemies with bombs is in logic.
|
||||
|
||||
D7 confuses some people, but by dropping down pits on the 2nd floor you can access almost all of this dungeon, even without feather and power bracelet.
|
||||
|
||||
### Tech
|
||||
|
||||
The toadstool and magic powder used to be the same type of item. LADXR turns this into two items that you can have a the same time. 4 extra item slots in your inventory were added to support this extra item, and have the ability to own the boomerang.
|
||||
|
||||
The glitch where the slime key is effectively a 6th golden leaf is fixed, and golden leaves can be collected fine next to the slime key.
|
||||
|
||||
@@ -25,7 +25,7 @@ class OSRSWeb(WebWorld):
|
||||
"Multiworld Setup Guide",
|
||||
"A guide to setting up the Old School Runescape Randomizer connected to an Archipelago Multiworld",
|
||||
"English",
|
||||
"docs/setup_en.md",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["digiholic"]
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import math
|
||||
from typing import Any, List, Dict, Tuple, Mapping
|
||||
from typing import Mapping, Any
|
||||
|
||||
from Options import OptionError
|
||||
from .data.strings import OTHER, ITEMS, CATEGORY, LOCATIONS, SLOTDATA, GOALS, OPTIONS
|
||||
@@ -123,23 +123,23 @@ class ShapezWorld(World):
|
||||
# Defining instance attributes for each shapez world
|
||||
# These are set to default values that should fail unit tests if not replaced with correct values
|
||||
self.location_count: int = 0
|
||||
self.level_logic: List[str] = []
|
||||
self.upgrade_logic: List[str] = []
|
||||
self.level_logic: list[str] = []
|
||||
self.upgrade_logic: list[str] = []
|
||||
self.level_logic_type: str = ""
|
||||
self.upgrade_logic_type: str = ""
|
||||
self.random_logic_phase_length: List[int] = []
|
||||
self.category_random_logic_amounts: Dict[str, int] = {}
|
||||
self.random_logic_phase_length: list[int] = []
|
||||
self.category_random_logic_amounts: dict[str, int] = {}
|
||||
self.maxlevel: int = 0
|
||||
self.finaltier: int = 0
|
||||
self.included_locations: Dict[str, Tuple[str, LocationProgressType]] = {}
|
||||
self.included_locations: dict[str, tuple[str, LocationProgressType]] = {}
|
||||
self.client_seed: int = 0
|
||||
self.shapesanity_names: List[str] = []
|
||||
self.shapesanity_names: list[str] = []
|
||||
self.upgrade_traps_allowed: bool = False
|
||||
|
||||
# Universal Tracker support
|
||||
self.ut_active: bool = False
|
||||
self.passthrough: Dict[str, any] = {}
|
||||
self.location_id_to_alias: Dict[int, str] = {}
|
||||
self.passthrough: dict[str, Any] = {}
|
||||
self.location_id_to_alias: dict[int, str] = {}
|
||||
|
||||
@classmethod
|
||||
def stage_generate_early(cls, multiworld: MultiWorld) -> None:
|
||||
@@ -315,7 +315,7 @@ class ShapezWorld(World):
|
||||
|
||||
def create_items(self) -> None:
|
||||
# Include guaranteed items (game mechanic unlocks and 7x4 big upgrades)
|
||||
included_items: List[Item] = ([self.create_item(name) for name in buildings_processing.keys()]
|
||||
included_items: list[Item] = ([self.create_item(name) for name in buildings_processing.keys()]
|
||||
+ [self.create_item(name) for name in buildings_routing.keys()]
|
||||
+ [self.create_item(name) for name in buildings_other.keys()]
|
||||
+ [self.create_item(name) for name in buildings_top_row.keys()]
|
||||
@@ -412,6 +412,6 @@ class ShapezWorld(World):
|
||||
**logic_type_cat_random_data, SLOTDATA.seed: self.client_seed,
|
||||
SLOTDATA.shapesanity: self.shapesanity_names}
|
||||
|
||||
def interpret_slot_data(self, slot_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def interpret_slot_data(self, slot_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Helper function for Universal Tracker"""
|
||||
return slot_data
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import random
|
||||
import typing
|
||||
from typing import cast, Any
|
||||
|
||||
from Options import FreeText, NumericOption
|
||||
|
||||
@@ -47,7 +47,7 @@ class FloatRangeText(FreeText, NumericOption):
|
||||
raise Exception(f"{value} is higher than maximum {self.range_end} for option {self.__class__.__name__}")
|
||||
|
||||
@classmethod
|
||||
def from_text(cls, text: str) -> typing.Any:
|
||||
def from_text(cls, text: str) -> Any:
|
||||
return cls(text)
|
||||
|
||||
@classmethod
|
||||
@@ -99,31 +99,31 @@ class FloatRangeText(FreeText, NumericOption):
|
||||
def get_option_name(cls, value: float) -> str:
|
||||
return str(value)
|
||||
|
||||
def __eq__(self, other: typing.Any):
|
||||
def __eq__(self, other: Any):
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value == other.value
|
||||
else:
|
||||
return typing.cast(bool, self.value == other)
|
||||
return cast(bool, self.value == other)
|
||||
|
||||
def __lt__(self, other: typing.Union[int, float, NumericOption]) -> bool:
|
||||
def __lt__(self, other: int | float | NumericOption) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value < other.value
|
||||
else:
|
||||
return self.value < other
|
||||
|
||||
def __le__(self, other: typing.Union[int, float, NumericOption]) -> bool:
|
||||
def __le__(self, other: int | float | NumericOption) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value <= other.value
|
||||
else:
|
||||
return self.value <= other
|
||||
|
||||
def __gt__(self, other: typing.Union[int, float, NumericOption]) -> bool:
|
||||
def __gt__(self, other: int | float | NumericOption) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value > other.value
|
||||
else:
|
||||
return self.value > other
|
||||
|
||||
def __ge__(self, other: typing.Union[int, float, NumericOption]) -> bool:
|
||||
def __ge__(self, other: int | float | NumericOption) -> bool:
|
||||
if isinstance(other, NumericOption):
|
||||
return self.value >= other.value
|
||||
else:
|
||||
@@ -132,59 +132,59 @@ class FloatRangeText(FreeText, NumericOption):
|
||||
def __int__(self) -> int:
|
||||
return int(self.value)
|
||||
|
||||
def __and__(self, other: typing.Any) -> int:
|
||||
def __and__(self, other: Any) -> int:
|
||||
raise TypeError("& operator not supported for float values")
|
||||
|
||||
def __floordiv__(self, other: typing.Any) -> int:
|
||||
def __floordiv__(self, other: Any) -> int:
|
||||
return int(self.value // float(other))
|
||||
|
||||
def __invert__(self) -> int:
|
||||
raise TypeError("~ operator not supported for float values")
|
||||
|
||||
def __lshift__(self, other: typing.Any) -> int:
|
||||
def __lshift__(self, other: Any) -> int:
|
||||
raise TypeError("<< operator not supported for float values")
|
||||
|
||||
def __mod__(self, other: typing.Any) -> float:
|
||||
def __mod__(self, other: Any) -> float:
|
||||
return self.value % float(other)
|
||||
|
||||
def __neg__(self) -> float:
|
||||
return -self.value
|
||||
|
||||
def __or__(self, other: typing.Any) -> int:
|
||||
def __or__(self, other: Any) -> int:
|
||||
raise TypeError("| operator not supported for float values")
|
||||
|
||||
def __pos__(self) -> float:
|
||||
return +self.value
|
||||
|
||||
def __rand__(self, other: typing.Any) -> int:
|
||||
def __rand__(self, other: Any) -> int:
|
||||
raise TypeError("& operator not supported for float values")
|
||||
|
||||
def __rfloordiv__(self, other: typing.Any) -> int:
|
||||
def __rfloordiv__(self, other: Any) -> int:
|
||||
return int(float(other) // self.value)
|
||||
|
||||
def __rlshift__(self, other: typing.Any) -> int:
|
||||
def __rlshift__(self, other: Any) -> int:
|
||||
raise TypeError("<< operator not supported for float values")
|
||||
|
||||
def __rmod__(self, other: typing.Any) -> float:
|
||||
def __rmod__(self, other: Any) -> float:
|
||||
return float(other) % self.value
|
||||
|
||||
def __ror__(self, other: typing.Any) -> int:
|
||||
def __ror__(self, other: Any) -> int:
|
||||
raise TypeError("| operator not supported for float values")
|
||||
|
||||
def __round__(self, ndigits: typing.Optional[int] = None) -> float:
|
||||
def __round__(self, ndigits: int | None = None) -> float:
|
||||
return round(self.value, ndigits)
|
||||
|
||||
def __rpow__(self, base: typing.Any) -> typing.Any:
|
||||
def __rpow__(self, base: Any) -> Any:
|
||||
return base ** self.value
|
||||
|
||||
def __rrshift__(self, other: typing.Any) -> int:
|
||||
def __rrshift__(self, other: Any) -> int:
|
||||
raise TypeError(">> operator not supported for float values")
|
||||
|
||||
def __rshift__(self, other: typing.Any) -> int:
|
||||
def __rshift__(self, other: Any) -> int:
|
||||
raise TypeError(">> operator not supported for float values")
|
||||
|
||||
def __rxor__(self, other: typing.Any) -> int:
|
||||
def __rxor__(self, other: Any) -> int:
|
||||
raise TypeError("^ operator not supported for float values")
|
||||
|
||||
def __xor__(self, other: typing.Any) -> int:
|
||||
def __xor__(self, other: Any) -> int:
|
||||
raise TypeError("^ operator not supported for float values")
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import itertools
|
||||
import time
|
||||
from typing import Dict, List
|
||||
|
||||
from worlds.shapez.data.strings import SHAPESANITY, REGIONS
|
||||
|
||||
shapesanity_simple: Dict[str, str] = {}
|
||||
shapesanity_1_4: Dict[str, str] = {}
|
||||
shapesanity_two_sided: Dict[str, str] = {}
|
||||
shapesanity_three_parts: Dict[str, str] = {}
|
||||
shapesanity_four_parts: Dict[str, str] = {}
|
||||
shapesanity_simple: dict[str, str] = {}
|
||||
shapesanity_1_4: dict[str, str] = {}
|
||||
shapesanity_two_sided: dict[str, str] = {}
|
||||
shapesanity_three_parts: dict[str, str] = {}
|
||||
shapesanity_four_parts: dict[str, str] = {}
|
||||
subshape_names = [SHAPESANITY.circle, SHAPESANITY.square, SHAPESANITY.star, SHAPESANITY.windmill]
|
||||
color_names = [SHAPESANITY.red, SHAPESANITY.blue, SHAPESANITY.green, SHAPESANITY.yellow, SHAPESANITY.purple,
|
||||
SHAPESANITY.cyan, SHAPESANITY.white, SHAPESANITY.uncolored]
|
||||
@@ -16,7 +15,7 @@ short_subshapes = ["C", "R", "S", "W"]
|
||||
short_colors = ["b", "c", "g", "p", "r", "u", "w", "y"]
|
||||
|
||||
|
||||
def color_to_needed_building(color_list: List[str]) -> str:
|
||||
def color_to_needed_building(color_list: list[str]) -> str:
|
||||
for next_color in color_list:
|
||||
if next_color in [SHAPESANITY.yellow, SHAPESANITY.purple, SHAPESANITY.cyan, SHAPESANITY.white,
|
||||
"y", "p", "c", "w"]:
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
Die Maximalwerte von `goal_amount` und `shapesanity_amount` sind fest eingebaute Einstellungen, die das Datenpaket des
|
||||
Spiels beeinflussen. Sie sind in einer Datei names `options.json` innerhalb der APWorld festgelegt. Durch das Ändern
|
||||
dieser Werte erschaffst du eine custom APWorld, die nur auf deinem PC existiert.
|
||||
dieser Werte erschaffst du eine custom Version der APWorld, die nur auf deinem PC existiert.
|
||||
|
||||
## Wie du die Datenpaket-Einstellungen änderst
|
||||
|
||||
@@ -18,17 +18,18 @@ ordnungsgemäß befolgt wird. Anwendung auf eigene Gefahr.
|
||||
- `max_shapesanity` kann nicht weniger als `4` sein, da dies die benötigte Mindestanzahl zum Verhindern von
|
||||
FillErrors ist.
|
||||
- `max_shapesanity` kann auch nicht mehr als `75800` sein, da dies die maximale Anzahl an möglichen Shapesanity-Namen
|
||||
ist. Ansonsten könnte die Generierung der Multiworld fehlschlagen.
|
||||
ist. Das Generieren der Multiworld wird fehlschlagen, falls die `shapesanity_amount`-Option auf einen höheren Wert
|
||||
gesetzt wird.
|
||||
- `max_levels_and_upgrades` kann nicht weniger als `27` sein, da dies die Mindestanzahl für das `mam`-Ziel ist.
|
||||
5. Schließe die Zip-Datei und benenne sie zurück zu `shapez.apworld`.
|
||||
|
||||
## Warum muss ich das ganze selbst machen?
|
||||
|
||||
Alle Spiele in Archipelago müssen eine Liste aller möglichen Locations **unabhängig der Spieler-Optionen**
|
||||
bereitstellen. Diese Listen aller in einer Multiworld inkludierten Spiele werden in den Daten der Multiworld gespeichert
|
||||
bereitstellen. Diese Listen aller in einer Multiworld inkludierten Spiele werden in den Daten der Multiworld gespeichert
|
||||
und an alle verbundenen Clients gesendet. Je mehr mögliche Locations, desto größer das Datenpaket. Und mit ~80000
|
||||
möglichen Locations hatte shapez zu einem gewissen Zeitpunkt ein (von der Datenmenge her) größeres Datenpaket als alle
|
||||
supporteten Spiele zusammen. Um also diese Datenmenge zu reduzieren wurden die ausgeschriebenen
|
||||
Core-verifizierten Spiele zusammen. Um also diese Datenmenge zu reduzieren, wurden die ausgeschriebenen
|
||||
Shapesanity-Locations-Namen (`Shapesanity Uncolored Circle`, `Shapesanity Blue Rectangle`, ...) durch standardisierte
|
||||
Namen (`Shapesanity 1`, `Shapesanity 2`, ...) ersetzt. Durch das Ändern dieser Maximalwerte, und damit das Erstellen
|
||||
einer custom APWorld, kannst du die Anzahl der möglichen Locations erhöhen, wirst aber auch gleichzeitig das Datenpaket
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# Guide to change maximum locations in shapez
|
||||
# Guide to change the maximum amount of locations in shapez
|
||||
|
||||
## Where do I find the settings to increase/decrease the amount of possible locations?
|
||||
|
||||
The maximum values of the `goal_amount` and `shapesanity_amount` are hardcoded settings that affect the datapackage.
|
||||
They are stored in a file called `options.json` inside the apworld. By changing them, you will create a custom apworld
|
||||
on your local machine.
|
||||
The maximum values of the `goal_amount` and `shapesanity_amount` options are hardcoded settings that affect the
|
||||
datapackage. They are stored in a file called `options.json` inside the apworld. By changing them, you will create a
|
||||
custom version on your local machine.
|
||||
|
||||
## How to change datapackage options
|
||||
## How to change datapackage settings
|
||||
|
||||
This tutorial is for advanced users and can result in the software not working properly, if not read carefully.
|
||||
This tutorial is intended for advanced users and can result in the software not working properly, if not read carefully.
|
||||
Proceed at your own risk.
|
||||
|
||||
1. Go to `<AP installation>/lib/worlds`.
|
||||
@@ -17,17 +17,17 @@ Proceed at your own risk.
|
||||
4. Edit the values in this file to your desire and save the file.
|
||||
- `max_shapesanity` cannot be lower than `4`, as this is the minimum amount to prevent FillErrors.
|
||||
- `max_shapesanity` also cannot be higher than `75800`, as this is the maximum amount of possible shapesanity names.
|
||||
Else the multiworld generation might fail.
|
||||
Multiworld generation will fail if the `shapesanity_amount` options is set to a higher value.
|
||||
- `max_levels_and_upgrades` cannot be lower than `27`, as this is the minimum amount for the `mam` goal to properly
|
||||
work.
|
||||
5. Close the zip and rename it back to `shapez.apworld`.
|
||||
5. Close the zip file and rename it back to `shapez.apworld`.
|
||||
|
||||
## Why do I have to do this manually?
|
||||
|
||||
For every game in Archipelago, there must be a list of all possible locations, **regardless of player options**. When
|
||||
generating a multiworld, a list of all locations of all included games will be saved in the multiworld data and sent to
|
||||
all clients. The higher the amount of possible locations, the bigger the datapackage. And having ~80000 possible
|
||||
locations at one point made the datapackage for shapez bigger than all other supported games combined. So to reduce the
|
||||
datapackage of shapez, the locations for shapesanity are named `Shapesanity 1`, `Shapesanity 2` etc. instead of their
|
||||
actual names. By creating a custom apworld, you can increase the amount of possible locations, but you will also
|
||||
increase the size of the datapackage at the same time.
|
||||
generating a multiworld, a list of all locations of all included games will be saved in the multiworld's data and sent
|
||||
to all clients. The higher the amount of possible locations, the bigger the datapackage. And having ~80000 possible
|
||||
locations at one point made the datapackage for shapez bigger than all other core-verified games combined. So, to reduce
|
||||
the datapackage size of shapez, the locations for shapesanity are named `Shapesanity 1`, `Shapesanity 2` etc. instead of
|
||||
their actual names. By creating a custom version of the apworld, you can increase the amount of possible locations, but
|
||||
you will also increase the size of the datapackage at the same time.
|
||||
|
||||
@@ -19,25 +19,27 @@ Zusätzlich gibt es zu diesem Spiel "Datenpaket-Einstellungen", die du nach
|
||||
|
||||
Alle Belohnungen aus den Tutorial-Level (das Freischalten von Gebäuden und Spielmechaniken) und Verbesserungen durch
|
||||
Upgrades werden dem Itempool der Multiworld hinzugefügt. Außerdem werden, wenn so in den Spieler-Optionen festgelegt,
|
||||
die Bedingungen zum Abschließen eines Levels und zum Kaufen der Upgrades randomisiert.
|
||||
die Bedingungen zum Abschließen eines Levels und zum Kaufen der Upgrades randomisiert und die Reihenfolge der Gebäude
|
||||
in deinen Toolbars (Haupt- und Kabelebene) gemischt.
|
||||
|
||||
## Was ist das Ziel von shapez in Archipelago?
|
||||
|
||||
Da das Spiel eigentlich kein konkretes Ziel (nach dem Tutorial) hat, kann man sich zwischen (momentan) 4 verschiedenen
|
||||
Zielen entscheiden:
|
||||
Da das Spiel eigentlich kein konkretes Ziel, welches das Ende des Spiels bedeuten würde, hat, kann man sich zwischen
|
||||
(aktuell) 4 verschiedenen Zielen entscheiden:
|
||||
1. Vanilla: Schließe Level 26 ab (eigentlich das Ende des Tutorials).
|
||||
2. MAM: Schließe ein bestimmtes Level nach Level 26 ab, das zuvor in den Spieler-Optionen festgelegt wurde. Es ist
|
||||
empfohlen, eine Maschine zu bauen, die alles automatisch herstellt ("Make-Anything-Machine", kurz MAM).
|
||||
3. Even Fasterer: Kaufe alle Upgrades bis zu einer in den Spieler-Optionen festgelegten Stufe (nach Stufe 8).
|
||||
3. Even Fasterer: Kaufe alle Upgrades bis zu einer in den Spieler-Optionen festgelegten Stufe (nach Stufe VIII (8)).
|
||||
4. Efficiency III: Liefere 256 Blaupausen-Formen pro Sekunde ins Zentrum.
|
||||
|
||||
## Welche Items können in den Welten anderer Spieler erscheinen?
|
||||
|
||||
- Freischalten verschiedener Gebäude
|
||||
- Gebäude
|
||||
- Blaupausen freischalten
|
||||
- Große Upgrades (addiert 1 zum Geschwindigkeitsmultiplikator)
|
||||
- Kleine Upgrades (addiert 0.1 zum Geschwindigkeitsmultiplikator)
|
||||
- Andere ungewöhnliche Upgrades (optional)
|
||||
- Upgrades
|
||||
- Große Upgrades (addiert 1 zum Geschwindigkeitsmultiplikator)
|
||||
- Kleine Upgrades (addiert 0.1 zum Geschwindigkeitsmultiplikator)
|
||||
- Andere ungewöhnliche (auch negative) Upgrades (optional)
|
||||
- Verschiedene Bündel, die bestimmte Formen enthalten
|
||||
- Fallen, die bestimmte Formen aus dem Zentrum dränieren (ja, das Wort gibt es)
|
||||
- Fallen, die zufällige Gebäude oder andere Spielmechaniken betreffen
|
||||
@@ -45,7 +47,7 @@ empfohlen, eine Maschine zu bauen, die alles automatisch herstellt ("Make-Anythi
|
||||
## Was ist eine Location / ein Check?
|
||||
|
||||
- Level (minimum 1-25, bis zu 499 je nach Spieler-Optionen, mit zusätzlichen Checks für Level 1 und 20)
|
||||
- Upgrades (minimum Stufen II-VIII (2-8), bis zu D (500) je nach Spieler-Optionen)
|
||||
- Upgrades (minimum Stufen II-VIII (2-8), bis zu D (500), je nach Spieler-Optionen)
|
||||
- Bestimmte Formen mindestens einmal ins Zentrum liefern ("Shapesanity", bis zu 1000 zufällig gewählte Definitionen)
|
||||
- Errungenschaften (bis zu 45)
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
|
||||
shapez is an automation game about cutting, rotating, stacking, and painting shapes, that you extract from randomly
|
||||
generated patches on an infinite canvas, and sending them to the hub to complete levels. The "tutorial", where you
|
||||
unlock a new building or game mechanic (almost) each level, lasts until level 26, where you unlock freeplay with
|
||||
infinitely more levels, that require a new, randomly generated shape. Alongside the levels, you can unlock upgrades,
|
||||
that make your buildings work faster.
|
||||
unlock a new building or game mechanic (almost) each level, lasts until level 26, which unlocks freeplay with
|
||||
infinitely more levels, that each require a new, randomly generated shape. Alongside the levels, you can unlock
|
||||
upgrades, that make your buildings work faster.
|
||||
|
||||
## Where is the options page?
|
||||
|
||||
@@ -17,29 +17,30 @@ There are also some advanced "datapackage settings" that can be changed by follo
|
||||
|
||||
## What does randomization do to this game?
|
||||
|
||||
Buildings and gameplay mechanics, that you normally unlock by completing a level, and upgrade improvements are put
|
||||
into the item pool of the multiworld. Also, if enabled, the requirements for completing a level or buying an upgrade are
|
||||
randomized.
|
||||
Buildings and gameplay mechanics, which you normally unlock by completing a level, and upgrade improvements are put
|
||||
into the item pool of the multiworld. You can also randomize the requirements for completing a level or buying an
|
||||
upgrade and shuffle the order of building in your toolbars (main and wires layer).
|
||||
|
||||
## What is the goal of shapez in Archipelago?
|
||||
|
||||
As the game has no actual goal where the game ends, there are (currently) 4 different goals you can choose from in the
|
||||
player options:
|
||||
As the game has no actual goal that would represent the end of the game, there are (currently) 4 different goals you
|
||||
can choose from in the player options:
|
||||
1. Vanilla: Complete level 26 (the end of the tutorial).
|
||||
2. MAM: Complete a player-specified level after level 26. It's recommended to build a Make-Anything-Machine (MAM).
|
||||
3. Even Fasterer: Upgrade everything to a player-specified tier after tier 8.
|
||||
3. Even Fasterer: Upgrade everything to a player-specified tier after tier VIII (8).
|
||||
4. Efficiency III: Deliver 256 blueprint shapes per second to the hub.
|
||||
|
||||
## Which items can be in another player's world?
|
||||
|
||||
- Unlock different buildings
|
||||
- Unlock blueprints
|
||||
- Big upgrade improvements (adds 1 to the multiplier)
|
||||
- Small upgrade improvements (adds .1 to the multiplier)
|
||||
- Other unusual upgrade improvements (optional)
|
||||
- Buildings
|
||||
- Unlocking blueprints
|
||||
- Upgrade improvements
|
||||
- Big improvements, adding 1 to the multiplier
|
||||
- Small improvements, adding 0.1 to the multiplier
|
||||
- Optional: Other, rather unusual and even bad, improvements
|
||||
- Different shapes bundles
|
||||
- Inventory draining traps
|
||||
- Different traps afflicting random buildings and game mechanics
|
||||
- Different traps affecting random buildings and game mechanics
|
||||
|
||||
## What is considered a location check?
|
||||
|
||||
@@ -61,5 +62,4 @@ Here's a cheat sheet:
|
||||
## Can I use other mods alongside the AP client?
|
||||
|
||||
At the moment, compatibility with other mods is not supported, but not forbidden. Gameplay altering mods will most
|
||||
likely crash the game or disable loading the afflicted mods, while QoL mods might work without problems. Try at your own
|
||||
risk.
|
||||
likely break the game in some way, while small QoL mods might work without problems. Try at your own risk.
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
|
||||
- Archipelago von der [Archipelago-Release-Seite](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
* (Für den Text-Client)
|
||||
* (Alternativ kannst du auch die eingebaute Konsole (nur lesbar) nutzen, indem du beim Starten des Spiels den
|
||||
`-dev`-Parameter verwendest)
|
||||
- Universal Tracker (schau im `#future-game-design`-Thread für UT auf dem Discord-Server nach der aktuellen Anleitung)
|
||||
* (Alternativ kannst du auch die eingebaute Konsole nutzen, indem du das Spiel mit dem `-dev`-Parameter
|
||||
startest und jede Nachricht als `AP.sendAPMessage("<Nachricht>"")` schreibst)
|
||||
- Universal Tracker (schau im Kanal von UT auf dem Discord-Server nach der aktuellen Anleitung und für weitere
|
||||
Informationen)
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
|
||||
- Archipelago from the [Archipelago Releases Page](https://github.com/ArchipelagoMW/Archipelago/releases)
|
||||
* (Only for the TextClient)
|
||||
* (If you want, you can use the built-in console as a read-only text client by launching the game
|
||||
with the `-dev` parameter)
|
||||
- Universal Tracker (check UT's `#future-game-design` thread in the discord server for instructions)
|
||||
* (You can alternatively use the built-in console by launching the game with the `-dev` parameter and typing
|
||||
`AP.sendAPMessage("<message>"")`)
|
||||
- Universal Tracker (check UT's channel in the discord server for more information and instructions)
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Dict, Callable, Any, List
|
||||
from typing import Callable, Any
|
||||
|
||||
from BaseClasses import Item, ItemClassification as IClass
|
||||
from .options import ShapezOptions
|
||||
@@ -37,7 +37,7 @@ def always_trap(options: ShapezOptions) -> IClass:
|
||||
# would be unreasonably complicated and time-consuming.
|
||||
# Some buildings are not needed to complete the game, but are "logically needed" for the "MAM" achievement.
|
||||
|
||||
buildings_processing: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
buildings_processing: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.cutter: always_progression,
|
||||
ITEMS.cutter_quad: always_progression,
|
||||
ITEMS.rotator: always_progression,
|
||||
@@ -50,7 +50,7 @@ buildings_processing: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.color_mixer: always_progression,
|
||||
}
|
||||
|
||||
buildings_routing: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
buildings_routing: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.balancer: always_progression,
|
||||
ITEMS.comp_merger: always_progression,
|
||||
ITEMS.comp_splitter: always_progression,
|
||||
@@ -58,12 +58,12 @@ buildings_routing: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.tunnel_tier_ii: is_mam_achievement_included,
|
||||
}
|
||||
|
||||
buildings_other: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
buildings_other: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trash: always_progression,
|
||||
ITEMS.extractor_chain: always_useful
|
||||
}
|
||||
|
||||
buildings_top_row: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
buildings_top_row: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.belt_reader: is_mam_achievement_included,
|
||||
ITEMS.storage: is_achievements_included,
|
||||
ITEMS.switch: always_progression,
|
||||
@@ -71,18 +71,18 @@ buildings_top_row: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.display: always_useful
|
||||
}
|
||||
|
||||
buildings_wires: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
buildings_wires: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.wires: always_progression,
|
||||
ITEMS.const_signal: always_progression,
|
||||
ITEMS.logic_gates: is_mam_achievement_included,
|
||||
ITEMS.virtual_proc: is_mam_achievement_included
|
||||
}
|
||||
|
||||
gameplay_unlocks: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
gameplay_unlocks: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.blueprints: is_achievements_included
|
||||
}
|
||||
|
||||
upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
upgrades: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.upgrade_big_belt: always_progression,
|
||||
ITEMS.upgrade_big_miner: always_useful,
|
||||
ITEMS.upgrade_big_proc: always_useful,
|
||||
@@ -93,7 +93,7 @@ upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.upgrade_small_paint: always_filler
|
||||
}
|
||||
|
||||
whacky_upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
whacky_upgrades: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.upgrade_gigantic_belt: always_progression,
|
||||
ITEMS.upgrade_gigantic_miner: always_useful,
|
||||
ITEMS.upgrade_gigantic_proc: always_useful,
|
||||
@@ -106,7 +106,7 @@ whacky_upgrades: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.upgrade_small_random: always_filler,
|
||||
}
|
||||
|
||||
whacky_upgrade_traps: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
whacky_upgrade_traps: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trap_upgrade_belt: always_trap,
|
||||
ITEMS.trap_upgrade_miner: always_trap,
|
||||
ITEMS.trap_upgrade_proc: always_trap,
|
||||
@@ -117,13 +117,13 @@ whacky_upgrade_traps: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trap_upgrade_demonic_paint: always_trap,
|
||||
}
|
||||
|
||||
bundles: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
bundles: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.bundle_blueprint: always_filler,
|
||||
ITEMS.bundle_level: always_filler,
|
||||
ITEMS.bundle_upgrade: always_filler
|
||||
}
|
||||
|
||||
standard_traps: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
standard_traps: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trap_locked: always_trap,
|
||||
ITEMS.trap_throttled: always_trap,
|
||||
ITEMS.trap_malfunction: always_trap,
|
||||
@@ -131,22 +131,22 @@ standard_traps: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trap_clear_belts: always_trap,
|
||||
}
|
||||
|
||||
random_draining_trap: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
random_draining_trap: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trap_draining_inv: always_trap
|
||||
}
|
||||
|
||||
split_draining_traps: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
split_draining_traps: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.trap_draining_blueprint: always_trap,
|
||||
ITEMS.trap_draining_level: always_trap,
|
||||
ITEMS.trap_draining_upgrade: always_trap
|
||||
}
|
||||
|
||||
belt_and_extractor: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
belt_and_extractor: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
ITEMS.belt: always_progression,
|
||||
ITEMS.extractor: always_progression
|
||||
}
|
||||
|
||||
item_table: Dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
item_table: dict[str, Callable[[ShapezOptions], IClass]] = {
|
||||
**buildings_processing,
|
||||
**buildings_routing,
|
||||
**buildings_other,
|
||||
@@ -205,10 +205,10 @@ def trap(random: float, split_draining: bool, whacky_allowed: bool) -> str:
|
||||
return random_choice_nested(random, pool)
|
||||
|
||||
|
||||
def random_choice_nested(random: float, nested: List[Any]) -> Any:
|
||||
def random_choice_nested(random: float, nested: list[Any]) -> Any:
|
||||
"""Helper function for getting a random element from a nested list."""
|
||||
current: Any = nested
|
||||
while isinstance(current, List):
|
||||
while isinstance(current, list):
|
||||
index_float = random*len(current)
|
||||
current = current[int(index_float)]
|
||||
random = index_float-int(index_float)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from random import Random
|
||||
from typing import List, Tuple, Dict, Optional, Callable
|
||||
from typing import Callable
|
||||
|
||||
from BaseClasses import Location, LocationProgressType, Region
|
||||
from .data.strings import CATEGORY, LOCATIONS, REGIONS, OPTIONS, GOALS, OTHER, SHAPESANITY
|
||||
@@ -7,7 +7,7 @@ from .options import max_shapesanity, max_levels_and_upgrades
|
||||
|
||||
categories = [CATEGORY.belt, CATEGORY.miner, CATEGORY.processors, CATEGORY.painting]
|
||||
|
||||
translate: List[Tuple[int, str]] = [
|
||||
translate: list[tuple[int, str]] = [
|
||||
(1000, "M"),
|
||||
(900, "CM"),
|
||||
(500, "D"),
|
||||
@@ -148,17 +148,17 @@ location_description = { # TODO change keys to global strings
|
||||
"windmill.",
|
||||
}
|
||||
|
||||
shapesanity_simple: Dict[str, str] = {}
|
||||
shapesanity_1_4: Dict[str, str] = {}
|
||||
shapesanity_two_sided: Dict[str, str] = {}
|
||||
shapesanity_three_parts: Dict[str, str] = {}
|
||||
shapesanity_four_parts: Dict[str, str] = {}
|
||||
shapesanity_simple: dict[str, str] = {}
|
||||
shapesanity_1_4: dict[str, str] = {}
|
||||
shapesanity_two_sided: dict[str, str] = {}
|
||||
shapesanity_three_parts: dict[str, str] = {}
|
||||
shapesanity_four_parts: dict[str, str] = {}
|
||||
|
||||
level_locations: List[str] = ([LOCATIONS.level(1, 1), LOCATIONS.level(20, 1), LOCATIONS.level(20, 2)]
|
||||
level_locations: list[str] = ([LOCATIONS.level(1, 1), LOCATIONS.level(20, 1), LOCATIONS.level(20, 2)]
|
||||
+ [LOCATIONS.level(x) for x in range(1, max_levels_and_upgrades)])
|
||||
upgrade_locations: List[str] = [LOCATIONS.upgrade(cat, roman(x))
|
||||
upgrade_locations: list[str] = [LOCATIONS.upgrade(cat, roman(x))
|
||||
for cat in categories for x in range(2, max_levels_and_upgrades+1)]
|
||||
achievement_locations: List[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATIONS.cutter, LOCATIONS.rotater,
|
||||
achievement_locations: list[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATIONS.cutter, LOCATIONS.rotater,
|
||||
LOCATIONS.wait_they_stack, LOCATIONS.wires, LOCATIONS.storage, LOCATIONS.freedom,
|
||||
LOCATIONS.the_logo, LOCATIONS.to_the_moon, LOCATIONS.its_piling_up,
|
||||
LOCATIONS.use_it_later, LOCATIONS.efficiency_1, LOCATIONS.preparing_to_launch,
|
||||
@@ -172,7 +172,7 @@ achievement_locations: List[str] = [LOCATIONS.my_eyes, LOCATIONS.painter, LOCATI
|
||||
LOCATIONS.mam, LOCATIONS.perfectionist, LOCATIONS.next_dimension, LOCATIONS.oops,
|
||||
LOCATIONS.copy_pasta, LOCATIONS.ive_seen_that_before, LOCATIONS.memories,
|
||||
LOCATIONS.i_need_trains, LOCATIONS.a_bit_early, LOCATIONS.gps]
|
||||
shapesanity_locations: List[str] = [LOCATIONS.shapesanity(x) for x in range(1, max_shapesanity+1)]
|
||||
shapesanity_locations: list[str] = [LOCATIONS.shapesanity(x) for x in range(1, max_shapesanity+1)]
|
||||
|
||||
|
||||
def init_shapesanity_pool() -> None:
|
||||
@@ -186,12 +186,12 @@ def init_shapesanity_pool() -> None:
|
||||
|
||||
|
||||
def addlevels(maxlevel: int, logictype: str,
|
||||
random_logic_phase_length: List[int]) -> Dict[str, Tuple[str, LocationProgressType]]:
|
||||
random_logic_phase_length: list[int]) -> dict[str, tuple[str, LocationProgressType]]:
|
||||
"""Returns a dictionary with all level locations based on player options (maxlevel INCLUDED).
|
||||
If shape requirements are not randomized, the logic type is expected to be vanilla."""
|
||||
|
||||
# Level 1 is always directly accessible
|
||||
locations: Dict[str, Tuple[str, LocationProgressType]] \
|
||||
locations: dict[str, tuple[str, LocationProgressType]] \
|
||||
= {LOCATIONS.level(1): (REGIONS.main, LocationProgressType.PRIORITY),
|
||||
LOCATIONS.level(1, 1): (REGIONS.main, LocationProgressType.PRIORITY)}
|
||||
level_regions = [REGIONS.main, REGIONS.levels_1, REGIONS.levels_2, REGIONS.levels_3,
|
||||
@@ -282,11 +282,11 @@ def addlevels(maxlevel: int, logictype: str,
|
||||
|
||||
|
||||
def addupgrades(finaltier: int, logictype: str,
|
||||
category_random_logic_amounts: Dict[str, int]) -> Dict[str, Tuple[str, LocationProgressType]]:
|
||||
category_random_logic_amounts: dict[str, int]) -> dict[str, tuple[str, LocationProgressType]]:
|
||||
"""Returns a dictionary with all upgrade locations based on player options (finaltier INCLUDED).
|
||||
If shape requirements are not randomized, give logic type 0."""
|
||||
|
||||
locations: Dict[str, Tuple[str, LocationProgressType]] = {}
|
||||
locations: dict[str, tuple[str, LocationProgressType]] = {}
|
||||
upgrade_regions = [REGIONS.main, REGIONS.upgrades_1, REGIONS.upgrades_2, REGIONS.upgrades_3,
|
||||
REGIONS.upgrades_4, REGIONS.upgrades_5]
|
||||
|
||||
@@ -366,13 +366,13 @@ def addupgrades(finaltier: int, logictype: str,
|
||||
|
||||
|
||||
def addachievements(excludesoftlock: bool, excludelong: bool, excludeprogressive: bool,
|
||||
maxlevel: int, upgradelogictype: str, category_random_logic_amounts: Dict[str, int],
|
||||
goal: str, presentlocations: Dict[str, Tuple[str, LocationProgressType]],
|
||||
maxlevel: int, upgradelogictype: str, category_random_logic_amounts: dict[str, int],
|
||||
goal: str, presentlocations: dict[str, tuple[str, LocationProgressType]],
|
||||
add_alias: Callable[[str, str], None], has_upgrade_traps: bool
|
||||
) -> Dict[str, Tuple[str, LocationProgressType]]:
|
||||
) -> dict[str, tuple[str, LocationProgressType]]:
|
||||
"""Returns a dictionary with all achievement locations based on player options."""
|
||||
|
||||
locations: Dict[str, Tuple[str, LocationProgressType]] = dict()
|
||||
locations: dict[str, tuple[str, LocationProgressType]] = dict()
|
||||
upgrade_regions = [REGIONS.main, REGIONS.upgrades_1, REGIONS.upgrades_2, REGIONS.upgrades_3,
|
||||
REGIONS.upgrades_4, REGIONS.upgrades_5]
|
||||
|
||||
@@ -472,10 +472,10 @@ def addachievements(excludesoftlock: bool, excludelong: bool, excludeprogressive
|
||||
|
||||
|
||||
def addshapesanity(amount: int, random: Random, append_shapesanity: Callable[[str], None],
|
||||
add_alias: Callable[[str, str], None]) -> Dict[str, Tuple[str, LocationProgressType]]:
|
||||
add_alias: Callable[[str, str], None]) -> dict[str, tuple[str, LocationProgressType]]:
|
||||
"""Returns a dictionary with a given number of random shapesanity locations."""
|
||||
|
||||
included_shapes: Dict[str, Tuple[str, LocationProgressType]] = {}
|
||||
included_shapes: dict[str, tuple[str, LocationProgressType]] = {}
|
||||
|
||||
def f(name: str, region: str, alias: str, progress: LocationProgressType = LocationProgressType.DEFAULT) -> None:
|
||||
included_shapes[name] = (region, progress)
|
||||
@@ -518,11 +518,11 @@ def addshapesanity(amount: int, random: Random, append_shapesanity: Callable[[st
|
||||
return included_shapes
|
||||
|
||||
|
||||
def addshapesanity_ut(shapesanity_names: List[str], add_alias: Callable[[str, str], None]
|
||||
) -> Dict[str, Tuple[str, LocationProgressType]]:
|
||||
def addshapesanity_ut(shapesanity_names: list[str], add_alias: Callable[[str, str], None]
|
||||
) -> dict[str, tuple[str, LocationProgressType]]:
|
||||
"""Returns the same information as addshapesanity but will add specific values based on a UT rebuild."""
|
||||
|
||||
included_shapes: Dict[str, Tuple[str, LocationProgressType]] = {}
|
||||
included_shapes: dict[str, tuple[str, LocationProgressType]] = {}
|
||||
|
||||
for name in shapesanity_names:
|
||||
for options in [shapesanity_simple, shapesanity_1_4, shapesanity_two_sided, shapesanity_three_parts,
|
||||
@@ -540,7 +540,7 @@ def addshapesanity_ut(shapesanity_names: List[str], add_alias: Callable[[str, st
|
||||
class ShapezLocation(Location):
|
||||
game = OTHER.game_name
|
||||
|
||||
def __init__(self, player: int, name: str, address: Optional[int], region: Region,
|
||||
def __init__(self, player: int, name: str, address: int | None, region: Region,
|
||||
progress_type: LocationProgressType):
|
||||
super(ShapezLocation, self).__init__(player, name, address, region)
|
||||
self.progress_type = progress_type
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from typing import Dict, Tuple, List
|
||||
|
||||
from BaseClasses import Region, MultiWorld, LocationProgressType, ItemClassification, CollectionState
|
||||
from .items import ShapezItem
|
||||
from .locations import ShapezLocation
|
||||
@@ -102,7 +100,7 @@ def has_x_belt_multiplier(state: CollectionState, player: int, needed: float) ->
|
||||
return multiplier >= needed
|
||||
|
||||
|
||||
def has_logic_list_building(state: CollectionState, player: int, buildings: List[str], index: int,
|
||||
def has_logic_list_building(state: CollectionState, player: int, buildings: list[str], index: int,
|
||||
includeuseful: bool) -> bool:
|
||||
|
||||
# Includes balancer, tunnel, and trash in logic in order to make them appear in earlier spheres
|
||||
@@ -126,11 +124,11 @@ def has_logic_list_building(state: CollectionState, player: int, buildings: List
|
||||
|
||||
|
||||
def create_shapez_regions(player: int, multiworld: MultiWorld, floating: bool,
|
||||
included_locations: Dict[str, Tuple[str, LocationProgressType]],
|
||||
location_name_to_id: Dict[str, int], level_logic_buildings: List[str],
|
||||
upgrade_logic_buildings: List[str], early_useful: str, goal: str) -> List[Region]:
|
||||
included_locations: dict[str, tuple[str, LocationProgressType]],
|
||||
location_name_to_id: dict[str, int], level_logic_buildings: list[str],
|
||||
upgrade_logic_buildings: list[str], early_useful: str, goal: str) -> list[Region]:
|
||||
"""Creates and returns a list of all regions with entrances and all locations placed correctly."""
|
||||
regions: Dict[str, Region] = {name: Region(name, player, multiworld) for name in all_regions}
|
||||
regions: dict[str, Region] = {name: Region(name, player, multiworld) for name in all_regions}
|
||||
|
||||
# Creates ShapezLocations for every included location and puts them into the correct region
|
||||
for name, data in included_locations.items():
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from unittest import TestCase
|
||||
|
||||
from test.bases import WorldTestBase
|
||||
from .. import options_presets, ShapezWorld
|
||||
from .. import ShapezWorld
|
||||
from ..data.strings import GOALS, OTHER, ITEMS, LOCATIONS, CATEGORY, OPTIONS, SHAPESANITY
|
||||
from ..options import max_levels_and_upgrades, max_shapesanity
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@ class SMBool:
|
||||
def __copy__(self):
|
||||
return SMBool(self.bool, self.difficulty, self._knows, self._items)
|
||||
|
||||
def __deepcopy__(self, memodict):
|
||||
# `bool` and `difficulty` are a `bool` and `int`, so do not need to be copied.
|
||||
# The `_knows` list is never mutated, so does not need to be copied.
|
||||
# The `_items` list is a `list[str | list[str]]` (copied to a flat `list[str]` when accessed through the `items`
|
||||
# property) that is mutated by code in helpers.py, so needs to be copied. Because there could be lists within
|
||||
# the list, it is copied using the `flatten()` helper function.
|
||||
return SMBool(self.bool, self.difficulty, self._knows, flatten(self._items))
|
||||
|
||||
def json(self):
|
||||
# as we have slots instead of dict
|
||||
return {'bool': self.bool, 'difficulty': self.difficulty, 'knows': self.knows, 'items': self.items}
|
||||
|
||||
@@ -8,6 +8,7 @@ from ..utils.doorsmanager import DoorsManager
|
||||
from ..utils.objectives import Objectives
|
||||
from ..utils.parameters import Knows, isKnows
|
||||
import logging
|
||||
from copy import deepcopy
|
||||
import sys
|
||||
|
||||
class SMBoolManager(object):
|
||||
@@ -34,6 +35,46 @@ class SMBoolManager(object):
|
||||
self.createFacadeFunctions()
|
||||
self.createKnowsFunctions(player)
|
||||
self.resetItems()
|
||||
self.itemsPositions = {}
|
||||
|
||||
def __deepcopy__(self, memodict):
|
||||
# Use __new__ to avoid calling __init__ like copy.deepcopy without __deepcopy__ implemented.
|
||||
new = object.__new__(type(self))
|
||||
|
||||
# Copy everything over in the same order as __init__, ensuring that mutable attributes are deeply copied.
|
||||
|
||||
# SMBool instances contain mutable lists, so must be deep-copied.
|
||||
new._items = {i: deepcopy(v, memodict) for i, v in self._items.items()}
|
||||
# `_counts` is a dict[str, int], so the dict can be copied because its keys and values are immutable.
|
||||
new._counts = self._counts.copy()
|
||||
# `player` is an int.
|
||||
new.player = self.player
|
||||
# `maxDiff` is an int.
|
||||
new.maxDiff = self.maxDiff
|
||||
# `onlyBossLeft` is a bool.
|
||||
new.onlyBossLeft = self.onlyBossLeft
|
||||
# The HelpersGraph keeps reference to the instance, so a new HelpersGraph is required.
|
||||
new.helpers = Logic.HelpersGraph(new)
|
||||
# DoorsManager is stateless, so the same instance can be used.
|
||||
new.doorsManager = self.doorsManager
|
||||
# Objectives are cached by self.player, so will be the same instance for the copy.
|
||||
new.objectives = self.objectives
|
||||
# Copy the facade functions from new.helpers into new.__dict__.
|
||||
new.createFacadeFunctions()
|
||||
# Copying the existing 'knows' functions from `self` to `new` is faster than re-creating all the lambdas with
|
||||
# `new.createKnowsFunctions(player)`.
|
||||
for key in Knows.__dict__.keys():
|
||||
if isKnows(key):
|
||||
attribute_name = "knows"+key
|
||||
knows_func = getattr(self, attribute_name)
|
||||
setattr(new, attribute_name, knows_func)
|
||||
# There is no need to call `new.resetItems()` because `_items` and `_counts` have been copied over.
|
||||
# new.resetItems()
|
||||
# itemsPositions is a `dict[str, tuple[int, int]]`, so the dict can be copied because the keys and values are
|
||||
# immutable.
|
||||
new.itemsPositions = self.itemsPositions.copy()
|
||||
|
||||
return new
|
||||
|
||||
def computeItemsPositions(self):
|
||||
# compute index in cache key for each items
|
||||
@@ -245,6 +286,9 @@ class SMBoolManagerPlando(SMBoolManager):
|
||||
def __init__(self):
|
||||
super(SMBoolManagerPlando, self).__init__()
|
||||
|
||||
def __deepcopy__(self, memodict):
|
||||
return super().__deepcopy__(memodict)
|
||||
|
||||
def addItem(self, item):
|
||||
# a new item is available
|
||||
already = self.haveItem(item)
|
||||
|
||||
@@ -97,7 +97,6 @@ class SMZ3World(World):
|
||||
ItemType.TwentyRupees,
|
||||
ItemType.FiftyRupees,
|
||||
ItemType.ThreeHundredRupees,
|
||||
ItemType.ETank,
|
||||
ItemType.Missile,
|
||||
ItemType.Super,
|
||||
ItemType.PowerBomb
|
||||
@@ -231,7 +230,6 @@ class SMZ3World(World):
|
||||
|
||||
niceItems = TotalSMZ3Item.Item.CreateNicePool(self.smz3World)
|
||||
junkItems = TotalSMZ3Item.Item.CreateJunkPool(self.smz3World)
|
||||
self.junkItemsNames = [item.Type.name for item in junkItems]
|
||||
|
||||
if (self.smz3World.Config.Keysanity):
|
||||
progressionItems = self.progression + self.dungeon + self.keyCardsItems + self.SmMapsItems
|
||||
|
||||
@@ -41,7 +41,7 @@ class SubnauticaWorld(World):
|
||||
location_name_to_id = all_locations
|
||||
options_dataclass = options.SubnauticaOptions
|
||||
options: options.SubnauticaOptions
|
||||
required_client_version = (0, 5, 0)
|
||||
required_client_version = (0, 6, 2)
|
||||
origin_region_name = "Planet 4546B"
|
||||
creatures_to_scan: List[str]
|
||||
|
||||
@@ -155,6 +155,7 @@ class SubnauticaWorld(World):
|
||||
"creatures_to_scan": self.creatures_to_scan,
|
||||
"death_link": self.options.death_link.value,
|
||||
"free_samples": self.options.free_samples.value,
|
||||
"empty_tanks": self.options.empty_tanks.value,
|
||||
}
|
||||
|
||||
return slot_data
|
||||
|
||||
@@ -129,6 +129,10 @@ class FillerItemsDistribution(ItemDict):
|
||||
return list(self.value.keys()), list(accumulate(self.value.values()))
|
||||
|
||||
|
||||
class EmptyTanks(DefaultOnToggle):
|
||||
"""Oxygen Tanks stored in inventory are empty if enabled."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SubnauticaOptions(PerGameCommonOptions):
|
||||
swim_rule: SwimRule
|
||||
@@ -140,3 +144,4 @@ class SubnauticaOptions(PerGameCommonOptions):
|
||||
death_link: SubnauticaDeathLink
|
||||
start_inventory_from_pool: StartInventoryPool
|
||||
filler_items_distribution: FillerItemsDistribution
|
||||
empty_tanks: EmptyTanks
|
||||
|
||||
@@ -255,8 +255,10 @@ def pair_portals(world: "TunicWorld", regions: Dict[str, Region]) -> Dict[Portal
|
||||
else:
|
||||
dead_ends.append(portal)
|
||||
dead_end_direction_tracker[portal.direction] += 1
|
||||
if portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop:
|
||||
if (portal.region == "Zig Skip Exit" and entrance_layout == EntranceLayout.option_fixed_shop
|
||||
and not decoupled):
|
||||
# direction isn't meaningful here since zig skip cannot be in direction pairs mode
|
||||
# don't add it in decoupled
|
||||
two_plus.append(portal)
|
||||
|
||||
# now we generate the shops and add them to the dead ends list
|
||||
|
||||
@@ -800,6 +800,7 @@ class TWWOptions(PerGameCommonOptions):
|
||||
"swift_sail",
|
||||
"skip_rematch_bosses",
|
||||
"remove_music",
|
||||
"death_link",
|
||||
)
|
||||
|
||||
def get_output_dict(self) -> dict[str, Any]:
|
||||
|
||||
@@ -257,7 +257,7 @@ class WitnessWorld(World):
|
||||
needed_size = 2
|
||||
needed_size += self.options.puzzle_randomization == "sigma_expert"
|
||||
needed_size += self.options.shuffle_symbols
|
||||
needed_size += self.options.shuffle_doors > 0
|
||||
needed_size += self.options.shuffle_doors != "off"
|
||||
|
||||
# Then, add checks in order until the required amount of sphere 1 checks is met.
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def get_priority_hint_items(world: "WitnessWorld") -> List[str]:
|
||||
"Shadows Laser",
|
||||
]
|
||||
|
||||
if world.options.shuffle_doors >= 2:
|
||||
if world.options.shuffle_doors >= "doors":
|
||||
priority.add("Desert Laser")
|
||||
priority.update(world.random.sample(lasers, 5))
|
||||
|
||||
|
||||
@@ -435,7 +435,7 @@ class WitnessPlayerLogic:
|
||||
postgame_adjustments = []
|
||||
|
||||
# Make some quick references to some options
|
||||
remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no region accessibility implications.
|
||||
remote_doors = world.options.shuffle_doors >= "doors" # "Panels" mode has no region accessibility implications.
|
||||
early_caves = world.options.early_caves
|
||||
victory = world.options.victory_condition
|
||||
mnt_lasers = world.options.mountain_lasers
|
||||
@@ -592,7 +592,7 @@ class WitnessPlayerLogic:
|
||||
|
||||
# Make condensed references to some options
|
||||
|
||||
remote_doors = world.options.shuffle_doors >= 2 # "Panels" mode has no overarching region access implications.
|
||||
remote_doors = world.options.shuffle_doors >= "doors" # "Panels" mode has no region access implications.
|
||||
lasers = world.options.shuffle_lasers
|
||||
victory = world.options.victory_condition
|
||||
mnt_lasers = world.options.mountain_lasers
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union
|
||||
|
||||
from BaseClasses import CollectionState, Entrance, Item, Location, Region
|
||||
|
||||
from test.bases import WorldTestBase
|
||||
from test.general import gen_steps, setup_multiworld
|
||||
from test.multiworld.test_multiworlds import MultiworldTestBase
|
||||
|
||||
from .. import WitnessWorld
|
||||
from ..data.utils import cast_not_none
|
||||
|
||||
|
||||
class WitnessTestBase(WorldTestBase):
|
||||
game = "The Witness"
|
||||
player: ClassVar[int] = 1
|
||||
|
||||
world: WitnessWorld
|
||||
|
||||
def can_beat_game_with_items(self, items: Iterable[Item]) -> bool:
|
||||
"""
|
||||
Check that the items listed are enough to beat the game.
|
||||
"""
|
||||
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
state.collect(item)
|
||||
return state.multiworld.can_beat_game(state)
|
||||
|
||||
def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None:
|
||||
"""
|
||||
WorldTestBase.assertAccessDependency, but modified & simplified to work with event items
|
||||
"""
|
||||
event_items = [item for item in self.multiworld.get_items() if item.name == item_name]
|
||||
self.assertTrue(event_items, f"Event item {item_name} does not exist.")
|
||||
|
||||
event_locations = [cast_not_none(event_item.location) for event_item in event_items]
|
||||
|
||||
# Checking for an access dependency on an event item requires a bit of extra work,
|
||||
# as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it.
|
||||
# So, we temporarily set the access rules of the event locations to be impossible.
|
||||
original_rules = {event_location.name: event_location.access_rule for event_location in event_locations}
|
||||
for event_location in event_locations:
|
||||
event_location.access_rule = lambda _: False
|
||||
|
||||
# We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30)
|
||||
test_state = self.multiworld.get_all_state(False)
|
||||
|
||||
self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}")
|
||||
|
||||
test_state.collect(event_items[0])
|
||||
|
||||
self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}")
|
||||
|
||||
# Restore original access rules.
|
||||
for event_location in event_locations:
|
||||
event_location.access_rule = original_rules[event_location.name]
|
||||
|
||||
def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, also make sure that this (non-event) location COULD exist.
|
||||
"""
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
try:
|
||||
self.world.get_location(location_name)
|
||||
except KeyError:
|
||||
self.fail(f"Location {location_name} does not exist.")
|
||||
|
||||
def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, be explicit about whether the location could exist in the first place.
|
||||
"""
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
self.assertRaises(
|
||||
KeyError,
|
||||
lambda _: self.world.get_location(location_name),
|
||||
f"Location {location_name} exists, but is not supposed to.",
|
||||
)
|
||||
|
||||
def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None:
|
||||
"""
|
||||
Assert that the specified mapping of items is enough to beat the game,
|
||||
and that having one less of any item would result in the game being unbeatable.
|
||||
"""
|
||||
# Find the actual items
|
||||
found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts]
|
||||
actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts}
|
||||
for item in found_items:
|
||||
if len(actual_items[item.name]) < required_item_counts[item.name]:
|
||||
actual_items[item.name].append(item)
|
||||
|
||||
# Assert that enough items exist in the item pool to satisfy the specified required counts
|
||||
for item_name, item_objects in actual_items.items():
|
||||
self.assertEqual(
|
||||
len(item_objects),
|
||||
required_item_counts[item_name],
|
||||
f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, "
|
||||
f"only found {len(item_objects)}",
|
||||
)
|
||||
|
||||
# assert that multiworld is beatable with the items specified
|
||||
self.assertTrue(
|
||||
self.can_beat_game_with_items(item for items in actual_items.values() for item in items),
|
||||
f"Could not beat game with items: {required_item_counts}",
|
||||
)
|
||||
|
||||
# assert that one less copy of any item would result in the multiworld being unbeatable
|
||||
for item_name, item_objects in actual_items.items():
|
||||
with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"):
|
||||
removed_item = item_objects.pop()
|
||||
self.assertFalse(
|
||||
self.can_beat_game_with_items(item for items in actual_items.values() for item in items),
|
||||
f"Game was beatable despite having {len(item_objects)} copies of {item_name} "
|
||||
f"instead of the specified {required_item_counts[item_name]}",
|
||||
)
|
||||
item_objects.append(removed_item)
|
||||
|
||||
|
||||
class WitnessMultiworldTestBase(MultiworldTestBase):
|
||||
options_per_world: List[Dict[str, Any]]
|
||||
common_options: Dict[str, Any] = {}
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""
|
||||
Set up a multiworld with multiple players, each using different options.
|
||||
"""
|
||||
|
||||
self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ())
|
||||
|
||||
for world, options in zip(self.multiworld.worlds.values(), self.options_per_world):
|
||||
for option_name, option_value in {**self.common_options, **options}.items():
|
||||
option = getattr(world.options, option_name)
|
||||
self.assertIsNotNone(option)
|
||||
|
||||
option.value = option.from_any(option_value).value
|
||||
|
||||
self.assertSteps(gen_steps)
|
||||
|
||||
def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]:
|
||||
"""
|
||||
Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool.
|
||||
"""
|
||||
|
||||
items = self.get_items_by_name(item_names, player)
|
||||
for item in items:
|
||||
self.multiworld.state.collect(item)
|
||||
return items
|
||||
|
||||
def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]:
|
||||
"""
|
||||
Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool.
|
||||
"""
|
||||
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player]
|
||||
|
||||
def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, also make sure that this (non-event) location COULD exist.
|
||||
"""
|
||||
|
||||
world = self.multiworld.worlds[player]
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
try:
|
||||
world.get_location(location_name)
|
||||
except KeyError:
|
||||
self.fail(f"Location {location_name} does not exist.")
|
||||
|
||||
def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, be explicit about whether the location could exist in the first place.
|
||||
"""
|
||||
|
||||
world = self.multiworld.worlds[player]
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
self.assertRaises(
|
||||
KeyError,
|
||||
lambda _: world.get_location(location_name),
|
||||
f"Location {location_name} exists, but is not supposed to.",
|
||||
)
|
||||
|
||||
196
worlds/witness/test/bases.py
Normal file
196
worlds/witness/test/bases.py
Normal file
@@ -0,0 +1,196 @@
|
||||
from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Union
|
||||
|
||||
from BaseClasses import CollectionState, Entrance, Item, Location, Region
|
||||
|
||||
from test.bases import WorldTestBase
|
||||
from test.general import gen_steps, setup_multiworld
|
||||
from test.multiworld.test_multiworlds import MultiworldTestBase
|
||||
|
||||
from .. import WitnessWorld
|
||||
from ..data.utils import cast_not_none
|
||||
|
||||
|
||||
class WitnessTestBase(WorldTestBase):
|
||||
game = "The Witness"
|
||||
player: ClassVar[int] = 1
|
||||
|
||||
world: WitnessWorld
|
||||
|
||||
def can_beat_game_with_items(self, items: Iterable[Item]) -> bool:
|
||||
"""
|
||||
Check that the items listed are enough to beat the game.
|
||||
"""
|
||||
|
||||
state = CollectionState(self.multiworld)
|
||||
for item in items:
|
||||
state.collect(item)
|
||||
return state.multiworld.can_beat_game(state)
|
||||
|
||||
def assert_dependency_on_event_item(self, spot: Union[Location, Region, Entrance], item_name: str) -> None:
|
||||
"""
|
||||
WorldTestBase.assertAccessDependency, but modified & simplified to work with event items
|
||||
"""
|
||||
event_items = [item for item in self.multiworld.get_items() if item.name == item_name]
|
||||
self.assertTrue(event_items, f"Event item {item_name} does not exist.")
|
||||
|
||||
event_locations = [cast_not_none(event_item.location) for event_item in event_items]
|
||||
|
||||
# Checking for an access dependency on an event item requires a bit of extra work,
|
||||
# as state.remove forces a sweep, which will pick up the event item again right after we tried to remove it.
|
||||
# So, we temporarily set the access rules of the event locations to be impossible.
|
||||
original_rules = {event_location.name: event_location.access_rule for event_location in event_locations}
|
||||
for event_location in event_locations:
|
||||
event_location.access_rule = lambda _: False
|
||||
|
||||
# We can't use self.assertAccessDependency here, it doesn't work for event items. (As of 2024-06-30)
|
||||
test_state = self.multiworld.get_all_state(False)
|
||||
|
||||
self.assertFalse(spot.can_reach(test_state), f"{spot.name} is reachable without {item_name}")
|
||||
|
||||
test_state.collect(event_items[0])
|
||||
|
||||
self.assertTrue(spot.can_reach(test_state), f"{spot.name} is not reachable despite having {item_name}")
|
||||
|
||||
# Restore original access rules.
|
||||
for event_location in event_locations:
|
||||
event_location.access_rule = original_rules[event_location.name]
|
||||
|
||||
def assert_location_exists(self, location_name: str, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, also make sure that this (non-event) location COULD exist.
|
||||
"""
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
try:
|
||||
self.world.get_location(location_name)
|
||||
except KeyError:
|
||||
self.fail(f"Location {location_name} does not exist.")
|
||||
|
||||
def assert_location_does_not_exist(self, location_name: str, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, be explicit about whether the location could exist in the first place.
|
||||
"""
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, self.world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
self.assertRaises(
|
||||
KeyError,
|
||||
lambda _: self.world.get_location(location_name),
|
||||
f"Location {location_name} exists, but is not supposed to.",
|
||||
)
|
||||
|
||||
def assert_can_beat_with_minimally(self, required_item_counts: Mapping[str, int]) -> None:
|
||||
"""
|
||||
Assert that the specified mapping of items is enough to beat the game,
|
||||
and that having one less of any item would result in the game being unbeatable.
|
||||
"""
|
||||
# Find the actual items
|
||||
found_items = [item for item in self.multiworld.get_items() if item.name in required_item_counts]
|
||||
actual_items: Dict[str, List[Item]] = {item_name: [] for item_name in required_item_counts}
|
||||
for item in found_items:
|
||||
if len(actual_items[item.name]) < required_item_counts[item.name]:
|
||||
actual_items[item.name].append(item)
|
||||
|
||||
# Assert that enough items exist in the item pool to satisfy the specified required counts
|
||||
for item_name, item_objects in actual_items.items():
|
||||
self.assertEqual(
|
||||
len(item_objects),
|
||||
required_item_counts[item_name],
|
||||
f"Couldn't find {required_item_counts[item_name]} copies of item {item_name} available in the pool, "
|
||||
f"only found {len(item_objects)}",
|
||||
)
|
||||
|
||||
# assert that multiworld is beatable with the items specified
|
||||
self.assertTrue(
|
||||
self.can_beat_game_with_items(item for items in actual_items.values() for item in items),
|
||||
f"Could not beat game with items: {required_item_counts}",
|
||||
)
|
||||
|
||||
# assert that one less copy of any item would result in the multiworld being unbeatable
|
||||
for item_name, item_objects in actual_items.items():
|
||||
with self.subTest(f"Verify cannot beat game with one less copy of {item_name}"):
|
||||
removed_item = item_objects.pop()
|
||||
self.assertFalse(
|
||||
self.can_beat_game_with_items(item for items in actual_items.values() for item in items),
|
||||
f"Game was beatable despite having {len(item_objects)} copies of {item_name} "
|
||||
f"instead of the specified {required_item_counts[item_name]}",
|
||||
)
|
||||
item_objects.append(removed_item)
|
||||
|
||||
|
||||
class WitnessMultiworldTestBase(MultiworldTestBase):
|
||||
options_per_world: List[Dict[str, Any]]
|
||||
common_options: Dict[str, Any] = {}
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""
|
||||
Set up a multiworld with multiple players, each using different options.
|
||||
"""
|
||||
|
||||
self.multiworld = setup_multiworld([WitnessWorld] * len(self.options_per_world), ())
|
||||
|
||||
for world, options in zip(self.multiworld.worlds.values(), self.options_per_world):
|
||||
for option_name, option_value in {**self.common_options, **options}.items():
|
||||
option = getattr(world.options, option_name)
|
||||
self.assertIsNotNone(option)
|
||||
|
||||
option.value = option.from_any(option_value).value
|
||||
|
||||
self.assertSteps(gen_steps)
|
||||
|
||||
def collect_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]:
|
||||
"""
|
||||
Collect all copies of a specified item name (or list of item names) for a player in the multiworld item pool.
|
||||
"""
|
||||
|
||||
items = self.get_items_by_name(item_names, player)
|
||||
for item in items:
|
||||
self.multiworld.state.collect(item)
|
||||
return items
|
||||
|
||||
def get_items_by_name(self, item_names: Union[str, Iterable[str]], player: int) -> List[Item]:
|
||||
"""
|
||||
Return all copies of a specified item name (or list of item names) for a player in the multiworld item pool.
|
||||
"""
|
||||
|
||||
if isinstance(item_names, str):
|
||||
item_names = (item_names,)
|
||||
return [item for item in self.multiworld.itempool if item.name in item_names and item.player == player]
|
||||
|
||||
def assert_location_exists(self, location_name: str, player: int, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, also make sure that this (non-event) location COULD exist.
|
||||
"""
|
||||
|
||||
world = self.multiworld.worlds[player]
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
try:
|
||||
world.get_location(location_name)
|
||||
except KeyError:
|
||||
self.fail(f"Location {location_name} does not exist.")
|
||||
|
||||
def assert_location_does_not_exist(self, location_name: str, player: int, strict_check: bool = True) -> None:
|
||||
"""
|
||||
Assert that a location exists in this world.
|
||||
If strict_check, be explicit about whether the location could exist in the first place.
|
||||
"""
|
||||
|
||||
world = self.multiworld.worlds[player]
|
||||
|
||||
if strict_check:
|
||||
self.assertIn(location_name, world.location_name_to_id, f"Location {location_name} can never exist")
|
||||
|
||||
self.assertRaises(
|
||||
KeyError,
|
||||
lambda _: world.get_location(location_name),
|
||||
f"Location {location_name} exists, but is not supposed to.",
|
||||
)
|
||||
@@ -1,4 +1,4 @@
|
||||
from ..test import WitnessMultiworldTestBase
|
||||
from ..test.bases import WitnessMultiworldTestBase
|
||||
|
||||
|
||||
class TestElevatorsComeToYouBleed(WitnessMultiworldTestBase):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from ..rules import _has_lasers
|
||||
from ..test import WitnessTestBase
|
||||
from ..test.bases import WitnessTestBase
|
||||
|
||||
|
||||
class TestDisableNonRandomized(WitnessTestBase):
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from typing import cast
|
||||
|
||||
from .. import WitnessWorld
|
||||
from ..test import WitnessMultiworldTestBase, WitnessTestBase
|
||||
from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase
|
||||
|
||||
|
||||
class TestIndividualDoors(WitnessTestBase):
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import cast
|
||||
from BaseClasses import LocationProgressType
|
||||
|
||||
from .. import WitnessWorld
|
||||
from ..test import WitnessMultiworldTestBase
|
||||
from ..test.bases import WitnessMultiworldTestBase
|
||||
|
||||
|
||||
class TestEasterEggShuffle(WitnessMultiworldTestBase):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from ..test import WitnessTestBase
|
||||
from ..test.bases import WitnessTestBase
|
||||
|
||||
|
||||
class TestIndividualEPs(WitnessTestBase):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from ..test import WitnessTestBase
|
||||
from ..test.bases import WitnessTestBase
|
||||
|
||||
|
||||
class TestSymbolsRequiredToWinElevatorNormal(WitnessTestBase):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from BaseClasses import CollectionState
|
||||
|
||||
from worlds.witness.test import WitnessMultiworldTestBase, WitnessTestBase
|
||||
from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase
|
||||
|
||||
|
||||
class TestMaxPanelHuntMinChecks(WitnessTestBase):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from ..options import ElevatorsComeToYou
|
||||
from ..test import WitnessTestBase
|
||||
from ..test.bases import WitnessTestBase
|
||||
|
||||
# These are just some random options combinations, just to catch whether I broke anything obvious
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from ..test import WitnessMultiworldTestBase, WitnessTestBase
|
||||
from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase
|
||||
|
||||
|
||||
class TestSymbols(WitnessTestBase):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from ..test import WitnessTestBase
|
||||
from ..test.bases import WitnessTestBase
|
||||
|
||||
|
||||
class TestWeirdTraversalRequirements(WitnessTestBase):
|
||||
|
||||
@@ -56,7 +56,7 @@ class Yugioh06Web(WebWorld):
|
||||
"A guide to setting up Yu-Gi-Oh! - Ultimate Masters Edition - World Championship Tournament 2006 "
|
||||
"for Archipelago on your computer.",
|
||||
"English",
|
||||
"docs/setup_en.md",
|
||||
"setup_en.md",
|
||||
"setup/en",
|
||||
["Rensen"],
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user