Merge branch 'ArchipelagoMW:main' into Satisfactory_ToBeVerified

This commit is contained in:
Jarno
2025-08-05 20:53:19 +02:00
committed by GitHub
85 changed files with 843 additions and 917 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -800,6 +800,7 @@ class TWWOptions(PerGameCommonOptions):
"swift_sail",
"skip_rematch_bosses",
"remove_music",
"death_link",
)
def get_output_dict(self) -> dict[str, Any]:

View File

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

View File

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

View File

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

View File

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

View 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.",
)

View File

@@ -1,4 +1,4 @@
from ..test import WitnessMultiworldTestBase
from ..test.bases import WitnessMultiworldTestBase
class TestElevatorsComeToYouBleed(WitnessMultiworldTestBase):

View File

@@ -1,5 +1,5 @@
from ..rules import _has_lasers
from ..test import WitnessTestBase
from ..test.bases import WitnessTestBase
class TestDisableNonRandomized(WitnessTestBase):

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
from ..test import WitnessTestBase
from ..test.bases import WitnessTestBase
class TestIndividualEPs(WitnessTestBase):

View File

@@ -1,4 +1,4 @@
from ..test import WitnessTestBase
from ..test.bases import WitnessTestBase
class TestSymbolsRequiredToWinElevatorNormal(WitnessTestBase):

View File

@@ -1,6 +1,6 @@
from BaseClasses import CollectionState
from worlds.witness.test import WitnessMultiworldTestBase, WitnessTestBase
from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase
class TestMaxPanelHuntMinChecks(WitnessTestBase):

View File

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

View File

@@ -1,4 +1,4 @@
from ..test import WitnessMultiworldTestBase, WitnessTestBase
from ..test.bases import WitnessMultiworldTestBase, WitnessTestBase
class TestSymbols(WitnessTestBase):

View File

@@ -1,4 +1,4 @@
from ..test import WitnessTestBase
from ..test.bases import WitnessTestBase
class TestWeirdTraversalRequirements(WitnessTestBase):

View File

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