diff --git a/AHITClient.py b/AHITClient.py index 390337108d..5ea3aff85c 100644 --- a/AHITClient.py +++ b/AHITClient.py @@ -3,7 +3,7 @@ import Utils import websockets import functools from copy import deepcopy -from typing import List, Any, Iterable +from typing import List, Any, Iterable, Dict from NetUtils import decode, encode, JSONtoTextParser, JSONMessagePart, NetworkItem from MultiServer import Endpoint from CommonClient import CommonContext, gui_enabled, ClientCommandProcessor, logger, \ @@ -70,12 +70,18 @@ class AHITContext(CommonContext): await self.endpoint.socket.send(msgs) return True + async def disconnect(self, allow_autoreconnect: bool = False): + await super().disconnect(allow_autoreconnect) + async def disconnect_proxy(self): if self.endpoint and not self.endpoint.socket.closed: await self.endpoint.socket.close() if self.proxy_task is not None: await self.proxy_task + def is_connected(self) -> bool: + return self.server and self.server.socket.open + def is_proxy_connected(self) -> bool: return self.endpoint and self.endpoint.socket.open @@ -91,6 +97,10 @@ class AHITContext(CommonContext): logger.info(text) def update_items(self): + # just to be safe - we might still have an inventory from a different room + if not self.is_connected(): + return + self.server_msgs.append(encode([{"cmd": "ReceivedItems", "index": 0, "items": self.full_inventory}])) def on_package(self, cmd: str, args: dict): @@ -118,6 +128,10 @@ class AHITContext(CommonContext): if cmd != "PrintJSON": self.server_msgs.append(encode([args])) + # def on_deathlink(self, data: Dict[str, Any]): + # self.server_msgs.append(encode([data])) + # super().on_deathlink(data) + def run_gui(self): from kvui import GameManager @@ -147,13 +161,17 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None): break if ctx.seed_name: - seed = msg.get("seed", "") - if seed != "" and seed != ctx.seed_name: + seed_name = msg.get("seed_name", "") + if seed_name != "" and seed_name != ctx.seed_name: logger.info("Aborting proxy connection: seed mismatch from save file") + logger.info(f"Expected: {ctx.seed_name}, got: {seed_name}") + text = encode([{"cmd": "PrintJSON", + "data": [{"text": "Connection aborted - save file to seed mismatch"}]}]) + await ctx.send_msgs_proxy(text) await ctx.disconnect_proxy() break - if ctx.connected_msg: + if ctx.connected_msg and ctx.is_connected(): await ctx.send_msgs_proxy(ctx.connected_msg) ctx.update_items() continue @@ -174,7 +192,7 @@ async def proxy(websocket, path: str = "/", ctx: AHITContext = None): async def on_client_connected(ctx: AHITContext): - if ctx.room_info: + if ctx.room_info and ctx.is_connected(): await ctx.send_msgs_proxy(ctx.room_info) else: ctx.awaiting_info = True @@ -186,7 +204,8 @@ async def main(): ctx = AHITContext(args.connect, args.password) logger.info("Starting A Hat in Time proxy server") - ctx.proxy = websockets.serve(functools.partial(proxy, ctx=ctx), host="localhost", port=11311) + ctx.proxy = websockets.serve(functools.partial(proxy, ctx=ctx), + host="localhost", port=11311, ping_timeout=999999, ping_interval=999999) ctx.proxy_task = asyncio.create_task(proxy_loop(ctx), name="ProxyLoop") if gui_enabled: diff --git a/data/yatta.ico b/data/yatta.ico new file mode 100644 index 0000000000..f87a0980f4 Binary files /dev/null and b/data/yatta.ico differ diff --git a/data/yatta.png b/data/yatta.png new file mode 100644 index 0000000000..4f230c86c7 Binary files /dev/null and b/data/yatta.png differ diff --git a/setup-ahitclient.py b/setup-ahitclient.py new file mode 100644 index 0000000000..18fd6a1887 --- /dev/null +++ b/setup-ahitclient.py @@ -0,0 +1,642 @@ +import base64 +import datetime +import os +import platform +import shutil +import sys +import sysconfig +import typing +import warnings +import zipfile +import urllib.request +import io +import json +import threading +import subprocess + +from collections.abc import Iterable +from hashlib import sha3_512 +from pathlib import Path + + +# This is a bit jank. We need cx-Freeze to be able to run anything from this script, so install it +try: + requirement = 'cx-Freeze>=6.15.2' + import pkg_resources + try: + pkg_resources.require(requirement) + install_cx_freeze = False + except pkg_resources.ResolutionError: + install_cx_freeze = True +except ImportError: + install_cx_freeze = True + pkg_resources = None # type: ignore [assignment] + +if install_cx_freeze: + # check if pip is available + try: + import pip # noqa: F401 + except ImportError: + raise RuntimeError("pip not available. Please install pip.") + # install and import cx_freeze + if '--yes' not in sys.argv and '-y' not in sys.argv: + input(f'Requirement {requirement} is not satisfied, press enter to install it') + subprocess.call([sys.executable, '-m', 'pip', 'install', requirement, '--upgrade']) + import pkg_resources + +import cx_Freeze + +# .build only exists if cx-Freeze is the right version, so we have to update/install that first before this line +import setuptools.command.build + +if __name__ == "__main__": + # need to run this early to import from Utils and Launcher + # TODO: move stuff to not require this + import ModuleUpdate + ModuleUpdate.update(yes="--yes" in sys.argv or "-y" in sys.argv) + ModuleUpdate.update_ran = False # restore for later + +from worlds.LauncherComponents import components, icon_paths +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 = { + "A Link to the Past", + "Adventure", + "ArchipIDLE", + "Archipelago", + "ChecksFinder", + "Clique", + "DLCQuest", + "Final Fantasy", + "Hylics 2", + "Kingdom Hearts 2", + "Lufia II Ancient Cave", + "Meritous", + "Ocarina of Time", + "Overcooked! 2", + "Raft", + "Secret of Evermore", + "Slay the Spire", + "Starcraft 2 Wings of Liberty", + "Sudoku", + "Super Mario 64", + "VVVVVV", + "Wargroove", + "Zillion", +} + +# LogicMixin is broken before 3.10 import revamp +if sys.version_info < (3,10): + non_apworlds.add("Hollow Knight") + +def download_SNI(): + print("Updating SNI") + machine_to_go = { + "x86_64": "amd64", + "aarch64": "arm64", + "armv7l": "arm" + } + platform_name = platform.system().lower() + machine_name = platform.machine().lower() + # force amd64 on macos until we have universal2 sni, otherwise resolve to GOARCH + machine_name = "amd64" if platform_name == "darwin" else machine_to_go.get(machine_name, machine_name) + with urllib.request.urlopen("https://api.github.com/repos/alttpo/sni/releases/latest") as request: + data = json.load(request) + files = data["assets"] + + source_url = None + + for file in files: + download_url: str = file["browser_download_url"] + machine_match = download_url.rsplit("-", 1)[1].split(".", 1)[0] == machine_name + if platform_name in download_url and machine_match: + # prefer "many" builds + if "many" in download_url: + source_url = download_url + break + source_url = download_url + + if source_url and source_url.endswith(".zip"): + with urllib.request.urlopen(source_url) as download: + with zipfile.ZipFile(io.BytesIO(download.read()), "r") as zf: + for member in zf.infolist(): + zf.extract(member, path="SNI") + print(f"Downloaded SNI from {source_url}") + + elif source_url and (source_url.endswith(".tar.xz") or source_url.endswith(".tar.gz")): + import tarfile + mode = "r:xz" if source_url.endswith(".tar.xz") else "r:gz" + with urllib.request.urlopen(source_url) as download: + sni_dir = None + with tarfile.open(fileobj=io.BytesIO(download.read()), mode=mode) as tf: + for member in tf.getmembers(): + if member.name.startswith("/") or "../" in member.name: + raise ValueError(f"Unexpected file '{member.name}' in {source_url}") + elif member.isdir() and not sni_dir: + sni_dir = member.name + elif member.isfile() and not sni_dir or not member.name.startswith(sni_dir): + raise ValueError(f"Expected folder before '{member.name}' in {source_url}") + elif member.isfile() and sni_dir: + tf.extract(member) + # sadly SNI is in its own folder on non-windows, so we need to rename + shutil.rmtree("SNI", True) + os.rename(sni_dir, "SNI") + print(f"Downloaded SNI from {source_url}") + + elif source_url: + print(f"Don't know how to extract SNI from {source_url}") + + else: + print(f"No SNI found for system spec {platform_name} {machine_name}") + + +signtool: typing.Optional[str] +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 /tr http://timestamp.digicert.com/ ' +else: + signtool = None + + +build_platform = sysconfig.get_platform() +arch_folder = "exe.{platform}-{version}".format(platform=build_platform, + version=sysconfig.get_python_version()) +buildfolder = Path("build", arch_folder) +build_arch = build_platform.split('-')[-1] if '-' in build_platform else platform.machine() + + +# see Launcher.py on how to add scripts to setup.py +def resolve_icon(icon_name: str): + base_path = icon_paths[icon_name] + if is_windows: + path, extension = os.path.splitext(base_path) + ico_file = path + ".ico" + assert os.path.exists(ico_file), f"ico counterpart of {base_path} should exist." + return ico_file + else: + return base_path + + +exes = [ + cx_Freeze.Executable( + script=f"{c.script_name}.py", + target_name="ArchipelagoAHITClient.exe", + #target_name=c.frozen_name + (".exe" if is_windows else ""), + icon=resolve_icon(c.icon), + base="Win32GUI" if is_windows and not c.cli else None + ) for c in components if c.script_name and c.frozen_name and "AHITClient" in c.script_name +] + +#if is_windows: +if False: + # create a duplicate Launcher for Windows, which has a working stdout/stderr, for debugging and --help + c = next(component for component in components if component.script_name == "Launcher") + exes.append(cx_Freeze.Executable( + script=f"{c.script_name}.py", + target_name=f"{c.frozen_name}(DEBUG).exe", + icon=resolve_icon(c.icon), + )) + +extra_data = ["LICENSE", "data", "EnemizerCLI", "SNI"] +extra_libs = ["libssl.so", "libcrypto.so"] if is_linux else [] + + +def remove_sprites_from_folder(folder): + for file in os.listdir(folder): + if file != ".gitignore": + os.remove(folder / file) + + +def _threaded_hash(filepath): + hasher = sha3_512() + hasher.update(open(filepath, "rb").read()) + return base64.b85encode(hasher.digest()).decode() + + +# cx_Freeze's build command runs other commands. Override to accept --yes and store that. +class BuildCommand(setuptools.command.build.build): + user_options = [ + ('yes', 'y', 'Answer "yes" to all questions.'), + ] + yes: bool + last_yes: bool = False # used by sub commands of build + + def initialize_options(self): + super().initialize_options() + type(self).last_yes = self.yes = False + + def finalize_options(self): + super().finalize_options() + type(self).last_yes = self.yes + + +# Override cx_Freeze's build_exe command for pre and post build steps +class BuildExeCommand(cx_Freeze.command.build_exe.BuildEXE): + user_options = cx_Freeze.command.build_exe.BuildEXE.user_options + [ + ('yes', 'y', 'Answer "yes" to all questions.'), + ('extra-data=', None, 'Additional files to add.'), + ] + yes: bool + extra_data: Iterable # [any] not available in 3.8 + extra_libs: Iterable # work around broken include_files + + buildfolder: Path + libfolder: Path + library: Path + buildtime: datetime.datetime + + def initialize_options(self): + super().initialize_options() + self.yes = BuildCommand.last_yes + self.extra_data = [] + self.extra_libs = [] + + def finalize_options(self): + super().finalize_options() + self.buildfolder = self.build_exe + self.libfolder = Path(self.buildfolder, "lib") + self.library = Path(self.libfolder, "library.zip") + + def installfile(self, path, subpath=None, keep_content: bool = False): + folder = self.buildfolder + if subpath: + folder /= subpath + print('copying', path, '->', folder) + if path.is_dir(): + folder /= path.name + if folder.is_dir() and not keep_content: + shutil.rmtree(folder) + shutil.copytree(path, folder, dirs_exist_ok=True) + elif path.is_file(): + shutil.copy(path, folder) + else: + print('Warning,', path, 'not found') + + def create_manifest(self, create_hashes=False): + # Since the setup is now split into components and the manifest is not, + # it makes most sense to just remove the hashes for now. Not aware of anyone using them. + hashes = {} + manifestpath = os.path.join(self.buildfolder, "manifest.json") + if create_hashes: + from concurrent.futures import ThreadPoolExecutor + pool = ThreadPoolExecutor() + for dirpath, dirnames, filenames in os.walk(self.buildfolder): + for filename in filenames: + path = os.path.join(dirpath, filename) + hashes[os.path.relpath(path, start=self.buildfolder)] = pool.submit(_threaded_hash, path) + + import json + manifest = { + "buildtime": self.buildtime.isoformat(sep=" ", timespec="seconds"), + "hashes": {path: hash.result() for path, hash in hashes.items()}, + "version": version_tuple} + + json.dump(manifest, open(manifestpath, "wt"), indent=4) + print("Created Manifest") + + def run(self): + # start downloading sni asap + sni_thread = threading.Thread(target=download_SNI, name="SNI Downloader") + sni_thread.start() + + # pre-build steps + print(f"Outputting to: {self.buildfolder}") + os.makedirs(self.buildfolder, exist_ok=True) + import ModuleUpdate + ModuleUpdate.requirements_files.add(os.path.join("WebHostLib", "requirements.txt")) + ModuleUpdate.update(yes=self.yes) + + # auto-build cython modules + build_ext = self.distribution.get_command_obj("build_ext") + build_ext.inplace = False + self.run_command("build_ext") + # find remains of previous in-place builds, try to delete and warn otherwise + for path in build_ext.get_outputs(): + parts = os.path.split(path)[-1].split(".") + pattern = parts[0] + ".*." + parts[-1] + for match in Path().glob(pattern): + try: + match.unlink() + print(f"Removed {match}") + except Exception as ex: + warnings.warn(f"Could not delete old build output: {match}\n" + f"{ex}\nPlease close all AP instances and delete manually.") + + # regular cx build + self.buildtime = datetime.datetime.utcnow() + super().run() + + # manually copy built modules to lib folder. cx_Freeze does not know they exist. + for src in build_ext.get_outputs(): + print(f"copying {src} -> {self.libfolder}") + shutil.copy(src, self.libfolder, follow_symlinks=False) + + # need to finish download before copying + sni_thread.join() + + # include_files seems to not be done automatically. implement here + for src, dst in self.include_files: + print(f"copying {src} -> {self.buildfolder / dst}") + shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False) + + # now that include_files is completely broken, run find_libs here + for src, dst in find_libs(*self.extra_libs): + print(f"copying {src} -> {self.buildfolder / dst}") + shutil.copyfile(src, self.buildfolder / dst, follow_symlinks=False) + + # post build steps + if is_windows: # kivy_deps is win32 only, linux picks them up automatically + from kivy_deps import sdl2, glew + for folder in sdl2.dep_bins + glew.dep_bins: + shutil.copytree(folder, self.libfolder, dirs_exist_ok=True) + print(f"copying {folder} -> {self.libfolder}") + + for data in self.extra_data: + self.installfile(Path(data)) + + # kivi data files + import kivy + shutil.copytree(os.path.join(os.path.dirname(kivy.__file__), "data"), + self.buildfolder / "data", + dirs_exist_ok=True) + + os.makedirs(self.buildfolder / "Players" / "Templates", exist_ok=True) + from Options import generate_yaml_templates + from worlds.AutoWorld import AutoWorldRegister + assert not non_apworlds - set(AutoWorldRegister.world_types), \ + f"Unknown world {non_apworlds - set(AutoWorldRegister.world_types)} designated for .apworld" + folders_to_remove: typing.List[str] = [] + generate_yaml_templates(self.buildfolder / "Players" / "Templates", False) + for worldname, worldtype in AutoWorldRegister.world_types.items(): + if worldname not in non_apworlds: + file_name = os.path.split(os.path.dirname(worldtype.__file__))[1] + world_directory = self.libfolder / "worlds" / file_name + # this method creates an apworld that cannot be moved to a different OS or minor python version, + # which should be ok + with zipfile.ZipFile(self.libfolder / "worlds" / (file_name + ".apworld"), "x", zipfile.ZIP_DEFLATED, + compresslevel=9) as zf: + for path in world_directory.rglob("*.*"): + relative_path = os.path.join(*path.parts[path.parts.index("worlds")+1:]) + zf.write(path, relative_path) + folders_to_remove.append(file_name) + shutil.rmtree(world_directory) + shutil.copyfile("meta.yaml", self.buildfolder / "Players" / "Templates" / "meta.yaml") + # TODO: fix LttP options one day + shutil.copyfile("playerSettings.yaml", self.buildfolder / "Players" / "Templates" / "A Link to the Past.yaml") + try: + from maseya import z3pr + except ImportError: + print("Maseya Palette Shuffle not found, skipping data files.") + else: + # maseya Palette Shuffle exists and needs its data files + print("Maseya Palette Shuffle found, including data files...") + file = z3pr.__file__ + self.installfile(Path(os.path.dirname(file)) / "data", keep_content=True) + + if signtool: + for exe in self.distribution.executables: + print(f"Signing {exe.target_name}") + os.system(signtool + os.path.join(self.buildfolder, exe.target_name)) + print("Signing SNI") + os.system(signtool + os.path.join(self.buildfolder, "SNI", "SNI.exe")) + print("Signing OoT Utils") + for exe_path in (("Compress", "Compress.exe"), ("Decompress", "Decompress.exe")): + os.system(signtool + os.path.join(self.buildfolder, "lib", "worlds", "oot", "data", *exe_path)) + + remove_sprites_from_folder(self.buildfolder / "data" / "sprites" / "alttpr") + + 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" + 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" + for world_directory in folders_to_remove) + else: + # make sure extra programs are executable + enemizer_exe = self.buildfolder / 'EnemizerCLI/EnemizerCLI.Core' + sni_exe = self.buildfolder / 'SNI/sni' + extra_exes = (enemizer_exe, sni_exe) + for extra_exe in extra_exes: + if extra_exe.is_file(): + extra_exe.chmod(0o755) + + +class AppImageCommand(setuptools.Command): + description = "build an app image from build output" + user_options = [ + ("build-folder=", None, "Folder to convert to AppImage."), + ("dist-file=", None, "AppImage output file."), + ("app-dir=", None, "Folder to use for packaging."), + ("app-icon=", None, "The icon to use for the AppImage."), + ("app-exec=", None, "The application to run inside the image."), + ("yes", "y", 'Answer "yes" to all questions.'), + ] + build_folder: typing.Optional[Path] + dist_file: typing.Optional[Path] + app_dir: typing.Optional[Path] + app_name: str + app_exec: typing.Optional[Path] + app_icon: typing.Optional[Path] # source file + app_id: str # lower case name, used for icon and .desktop + yes: bool + + def write_desktop(self): + assert self.app_dir, "Invalid app_dir" + desktop_filename = self.app_dir / f"{self.app_id}.desktop" + with open(desktop_filename, 'w', encoding="utf-8") as f: + f.write("\n".join(( + "[Desktop Entry]", + f'Name={self.app_name}', + f'Exec={self.app_exec}', + "Type=Application", + "Categories=Game", + f'Icon={self.app_id}', + '' + ))) + desktop_filename.chmod(0o755) + + def write_launcher(self, default_exe: Path): + assert self.app_dir, "Invalid app_dir" + launcher_filename = self.app_dir / "AppRun" + with open(launcher_filename, 'w', encoding="utf-8") as f: + f.write(f"""#!/bin/sh +exe="{default_exe}" +match="${{1#--executable=}}" +if [ "${{#match}}" -lt "${{#1}}" ]; then + exe="$match" + shift +elif [ "$1" = "-executable" ] || [ "$1" = "--executable" ]; then + exe="$2" + shift; shift +fi +tmp="${{exe#*/}}" +if [ ! "${{#tmp}}" -lt "${{#exe}}" ]; then + exe="{default_exe.parent}/$exe" +fi +export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:$APPDIR/{default_exe.parent}/lib" +$APPDIR/$exe "$@" +""") + launcher_filename.chmod(0o755) + + def install_icon(self, src: Path, name: typing.Optional[str] = None, symlink: typing.Optional[Path] = None): + assert self.app_dir, "Invalid app_dir" + try: + from PIL import Image + except ModuleNotFoundError: + if not self.yes: + input("Requirement PIL is not satisfied, press enter to install it") + subprocess.call([sys.executable, '-m', 'pip', 'install', 'Pillow', '--upgrade']) + from PIL import Image + im = Image.open(src) + res, _ = im.size + + if not name: + name = src.stem + ext = src.suffix + dest_dir = Path(self.app_dir / f'usr/share/icons/hicolor/{res}x{res}/apps') + dest_dir.mkdir(parents=True, exist_ok=True) + dest_file = dest_dir / f'{name}{ext}' + shutil.copy(src, dest_file) + if symlink: + symlink.symlink_to(dest_file.relative_to(symlink.parent)) + + def initialize_options(self): + self.build_folder = None + self.app_dir = None + self.app_name = self.distribution.metadata.name + self.app_icon = self.distribution.executables[0].icon + self.app_exec = Path('opt/{app_name}/{exe}'.format( + app_name=self.distribution.metadata.name, exe=self.distribution.executables[0].target_name + )) + self.dist_file = Path("dist", "{app_name}_{app_version}_{platform}.AppImage".format( + app_name=self.distribution.metadata.name, app_version=self.distribution.metadata.version, + platform=sysconfig.get_platform() + )) + self.yes = False + + def finalize_options(self): + if not self.app_dir: + self.app_dir = self.build_folder.parent / "AppDir" + self.app_id = self.app_name.lower() + + def run(self): + self.dist_file.parent.mkdir(parents=True, exist_ok=True) + if self.app_dir.is_dir(): + shutil.rmtree(self.app_dir) + self.app_dir.mkdir(parents=True) + opt_dir = self.app_dir / "opt" / self.distribution.metadata.name + shutil.copytree(self.build_folder, opt_dir) + root_icon = self.app_dir / f'{self.app_id}{self.app_icon.suffix}' + self.install_icon(self.app_icon, self.app_id, symlink=root_icon) + shutil.copy(root_icon, self.app_dir / '.DirIcon') + self.write_desktop() + self.write_launcher(self.app_exec) + print(f'{self.app_dir} -> {self.dist_file}') + subprocess.call(f'ARCH={build_arch} ./appimagetool -n "{self.app_dir}" "{self.dist_file}"', shell=True) + + +def find_libs(*args: str) -> typing.Sequence[typing.Tuple[str, str]]: + """Try to find system libraries to be included.""" + if not args: + return [] + + arch = build_arch.replace('_', '-') + libc = 'libc6' # we currently don't support musl + + def parse(line): + lib, path = line.strip().split(' => ') + lib, typ = lib.split(' ', 1) + for test_arch in ('x86-64', 'i386', 'aarch64'): + if test_arch in typ: + lib_arch = test_arch + break + else: + lib_arch = '' + for test_libc in ('libc6',): + if test_libc in typ: + lib_libc = test_libc + break + else: + lib_libc = '' + return (lib, lib_arch, lib_libc), path + + if not hasattr(find_libs, "cache"): + ldconfig = shutil.which("ldconfig") + assert ldconfig, "Make sure ldconfig is in PATH" + data = subprocess.run([ldconfig, "-p"], capture_output=True, text=True).stdout.split("\n")[1:] + find_libs.cache = { # type: ignore [attr-defined] + k: v for k, v in (parse(line) for line in data if "=>" in line) + } + + def find_lib(lib, arch, libc): + for k, v in find_libs.cache.items(): + if k == (lib, arch, libc): + return v + for k, v, in find_libs.cache.items(): + if k[0].startswith(lib) and k[1] == arch and k[2] == libc: + return v + return None + + res = [] + for arg in args: + # try exact match, empty libc, empty arch, empty arch and libc + file = find_lib(arg, arch, libc) + file = file or find_lib(arg, arch, '') + file = file or find_lib(arg, '', libc) + file = file or find_lib(arg, '', '') + # resolve symlinks + for n in range(0, 5): + res.append((file, os.path.join('lib', os.path.basename(file)))) + if not os.path.islink(file): + break + dirname = os.path.dirname(file) + file = os.readlink(file) + if not os.path.isabs(file): + file = os.path.join(dirname, file) + return res + + +cx_Freeze.setup( + name="Archipelago", + version=f"{version_tuple.major}.{version_tuple.minor}.{version_tuple.build}", + description="Archipelago", + executables=exes, + ext_modules=cythonize("_speedups.pyx"), + options={ + "build_exe": { + "packages": ["worlds", "kivy", "cymem", "websockets"], + "includes": [], + "excludes": ["numpy", "Cython", "PySide2", "PIL", + "pandas"], + "zip_include_packages": ["*"], + "zip_exclude_packages": ["worlds", "sc2"], + "include_files": [], # broken in cx 6.14.0, we use more special sauce now + "include_msvcr": False, + "replace_paths": ["*."], + "optimize": 1, + "build_exe": buildfolder, + "extra_data": extra_data, + "extra_libs": extra_libs, + "bin_includes": ["libffi.so", "libcrypt.so"] if is_linux else [] + }, + "bdist_appimage": { + "build_folder": buildfolder, + }, + }, + # override commands to get custom stuff in + cmdclass={ + "build": BuildCommand, + "build_exe": BuildExeCommand, + "bdist_appimage": AppImageCommand, + }, +) diff --git a/worlds/ahit/DeathWishRules.py b/worlds/ahit/DeathWishRules.py index 7f6211f417..5a97518499 100644 --- a/worlds/ahit/DeathWishRules.py +++ b/worlds/ahit/DeathWishRules.py @@ -1,6 +1,6 @@ from worlds.AutoWorld import World, CollectionState -from .Rules import can_use_hat, can_use_hookshot, can_hit, zipline_logic, get_difficulty -from .Types import HatType, Difficulty, HatInTimeLocation, HatInTimeItem, LocData, HatDLC +from .Rules import can_use_hat, can_use_hookshot, can_hit, zipline_logic, get_difficulty, has_paintings +from .Types import HatType, Difficulty, HatInTimeLocation, HatInTimeItem, LocData from .DeathWishLocations import dw_prereqs, dw_candles from BaseClasses import Entrance, Location, ItemClassification from worlds.generic.Rules import add_rule, set_rule @@ -169,8 +169,7 @@ def set_dw_rules(world: World): add_rule(loc, lambda state: state.has("Umbrella", world.player)) if data.paintings > 0 and world.multiworld.ShuffleSubconPaintings[world.player].value > 0: - add_rule(loc, lambda state, paintings=data.paintings: state.has("Progressive Painting Unlock", - world.player, paintings)) + add_rule(loc, lambda state, paintings=data.paintings: has_paintings(state, world, paintings)) if data.hit_requirement > 0: if data.hit_requirement == 1: @@ -303,10 +302,17 @@ def set_candle_dw_rules(name: str, world: World): and state.has("Train Rush (Zero Jumps)", world.player) and can_use_hat(state, world, HatType.ICE)) # No Ice Hat/painting required in Expert for Toilet Zero Jumps - if get_difficulty(world) >= Difficulty.EXPERT: + # This painting wall can only be skipped via cherry hover. + if get_difficulty(world) < Difficulty.EXPERT or world.multiworld.NoPaintingSkips[world.player].value == 1: set_rule(world.multiworld.get_location("Toilet of Doom (Zero Jumps)", world.player), - lambda state: can_use_hookshot(state, world) - and can_hit(state, world)) + lambda state: can_use_hookshot(state, world) and can_hit(state, world) + and has_paintings(state, world, 1, False)) + else: + set_rule(world.multiworld.get_location("Toilet of Doom (Zero Jumps)", world.player), + lambda state: can_use_hookshot(state, world) and can_hit(state, world)) + + set_rule(world.multiworld.get_location("Contractual Obligations (Zero Jumps)", world.player), + lambda state: has_paintings(state, world, 1, False)) elif name == "Snatcher's Hit List": add_rule(main_objective, lambda state: state.has("Mafia Goon", world.player)) diff --git a/worlds/ahit/Items.py b/worlds/ahit/Items.py index c9bb76739c..b4636a1d28 100644 --- a/worlds/ahit/Items.py +++ b/worlds/ahit/Items.py @@ -230,8 +230,8 @@ ahit_items = { # DLC1 items "Relic (Cake Stand)": ItemData(2000300018, ItemClassification.progression, HatDLC.dlc1), - "Relic (Cake)": ItemData(2000300019, ItemClassification.progression, HatDLC.dlc1), - "Relic (Cake Slice)": ItemData(2000300020, ItemClassification.progression, HatDLC.dlc1), + "Relic (Chocolate Cake)": ItemData(2000300019, ItemClassification.progression, HatDLC.dlc1), + "Relic (Chocolate Cake Slice)": ItemData(2000300020, ItemClassification.progression, HatDLC.dlc1), "Relic (Shortcake)": ItemData(2000300021, ItemClassification.progression, HatDLC.dlc1), # DLC2 items diff --git a/worlds/ahit/Options.py b/worlds/ahit/Options.py index 7c0567a151..6353269a98 100644 --- a/worlds/ahit/Options.py +++ b/worlds/ahit/Options.py @@ -211,6 +211,12 @@ class ShuffleSubconPaintings(Toggle): default = 0 +class NoPaintingSkips(Toggle): + """If enabled, prevent Subcon fire wall skips from being in logic on higher difficulty settings.""" + display_name = "No Subcon Fire Wall Skips" + default = 0 + + class StartingChapter(Choice): """Determines which chapter you will be guaranteed to be able to enter at the beginning of the game.""" display_name = "Starting Chapter" @@ -606,6 +612,7 @@ ahit_options: typing.Dict[str, type(Option)] = { "ShuffleStorybookPages": ShuffleStorybookPages, "ShuffleActContracts": ShuffleActContracts, "ShuffleSubconPaintings": ShuffleSubconPaintings, + "NoPaintingSkips": NoPaintingSkips, "StartingChapter": StartingChapter, "CTRLogic": CTRLogic, @@ -677,6 +684,7 @@ slot_data_options: typing.Dict[str, type(Option)] = { "ShuffleStorybookPages": ShuffleStorybookPages, "ShuffleActContracts": ShuffleActContracts, "ShuffleSubconPaintings": ShuffleSubconPaintings, + "NoPaintingSkips": NoPaintingSkips, "HatItems": HatItems, "EnableDLC1": EnableDLC1, diff --git a/worlds/ahit/Rules.py b/worlds/ahit/Rules.py index dd8daedb74..5d0377e235 100644 --- a/worlds/ahit/Rules.py +++ b/worlds/ahit/Rules.py @@ -62,25 +62,16 @@ def get_difficulty(world: World) -> Difficulty: return Difficulty(world.multiworld.LogicDifficulty[world.player].value) -def has_paintings(state: CollectionState, world: World, count: int, surf: bool = True) -> bool: +def has_paintings(state: CollectionState, world: World, count: int, allow_skip: bool=True) -> bool: if not painting_logic(world): return True - # Cherry Hover - if get_difficulty(world) >= Difficulty.EXPERT: - return True - - # All paintings can be skipped with No Bonk, very easily, if the player knows - if surf and get_difficulty(world) >= Difficulty.MODERATE and can_surf(state, world): - return True - - paintings: int = state.count("Progressive Painting Unlock", world.player) - if surf and get_difficulty(world) >= Difficulty.MODERATE: - # Green+Yellow paintings can also be skipped easily - if count == 1 or paintings >= 1 and count == 3: + if world.multiworld.NoPaintingSkips[world.player].value == 0 and allow_skip: + # In Moderate there is a very easy trick to skip all the walls, except for the one guarding the boss arena + if get_difficulty(world) >= Difficulty.MODERATE: return True - return paintings >= count + return state.count("Progressive Painting Unlock", world.player) >= count def zipline_logic(world: World) -> bool: @@ -281,10 +272,7 @@ def set_rules(world: World): add_rule(location, lambda state: state.has("Umbrella", world.player)) if data.paintings > 0 and world.multiworld.ShuffleSubconPaintings[world.player].value > 0: - if "Toilet of Doom" not in key: - add_rule(location, lambda state, paintings=data.paintings: has_paintings(state, world, paintings)) - else: - add_rule(location, lambda state, paintings=data.paintings: has_paintings(state, world, paintings, False)) + add_rule(location, lambda state, paintings=data.paintings: has_paintings(state, world, paintings)) if data.hit_requirement > 0: if data.hit_requirement == 1: @@ -417,13 +405,9 @@ def set_moderate_rules(world: World): add_rule(world.multiworld.get_location("Mafia Town - Clock Tower Chest", world.player), lambda state: True) add_rule(world.multiworld.get_location("Mafia Town - Top of Ruined Tower", world.player), lambda state: True) - # Moderate: hitting the bell is not required to enter Subcon Well, however hookshot is still expected to clear - set_rule(world.multiworld.get_location("Subcon Well - Hookshot Badge Chest", world.player), - lambda state: has_paintings(state, world, 1)) - set_rule(world.multiworld.get_location("Subcon Well - Above Chest", world.player), - lambda state: has_paintings(state, world, 1)) - set_rule(world.multiworld.get_location("Subcon Well - Mushroom", world.player), - lambda state: has_paintings(state, world, 1)) + # Moderate: enter and clear The Subcon Well without Hookshot and without hitting the bell + for loc in world.multiworld.get_region("The Subcon Well", world.player).locations: + set_rule(loc, lambda state: has_paintings(state, world, 1)) # Moderate: Vanessa Manor with nothing for loc in world.multiworld.get_region("Queen Vanessa's Manor", world.player).locations: @@ -484,17 +468,17 @@ def set_hard_rules(world: World): lambda state: can_use_hat(state, world, HatType.SPRINT) and state.has("Scooter Badge", world.player), "or") + # No Dweller Mask required set_rule(world.multiworld.get_location("Subcon Forest - Dweller Floating Rocks", world.player), lambda state: has_paintings(state, world, 3)) # Cherry bridge over boss arena gap (painting still expected) set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), - lambda state: has_paintings(state, world, 1, False)) + lambda state: has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player)) # SDJ add_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player), - lambda state: can_sdj(state, world) - and has_paintings(state, world, 2), "or") + lambda state: can_sdj(state, world) and has_paintings(state, world, 2), "or") add_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player), lambda state: has_paintings(state, world, 3) and can_sdj(state, world), "or") @@ -559,21 +543,29 @@ def set_expert_rules(world: World): set_rule(world.multiworld.get_location("Act Completion (Time Rift - The Twilight Bell)", world.player), lambda state: True) - # Expert: enter and clear The Subcon Well with nothing - for loc in world.multiworld.get_region("The Subcon Well", world.player).locations: - set_rule(loc, lambda state: True) - # Expert: Cherry Hovering - connect_regions(world.multiworld.get_region("Your Contract has Expired", world.player), - world.multiworld.get_region("Subcon Forest Area", world.player), - "Subcon Forest Entrance YCHE", world.player) + entrance = connect_regions(world.multiworld.get_region("Your Contract has Expired", world.player), + world.multiworld.get_region("Subcon Forest Area", world.player), + "Subcon Forest Entrance YCHE", world.player) - set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), lambda state: True) - set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player), lambda state: True) - set_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player), lambda state: True) - set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player), lambda state: True) - set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player), lambda state: True) - set_rule(world.multiworld.get_location("Subcon Forest - Tall Tree Hookshot Swing", world.player), lambda state: True) + if world.multiworld.NoPaintingSkips[world.player].value > 0: + add_rule(entrance, lambda state: has_paintings(state, world, 1)) + + set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player), + lambda state: can_use_hookshot(state, world) and can_hit(state, world) + and has_paintings(state, world, 1, True)) + + # Set painting rules only. Skipping paintings is determined in has_paintings + set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), + lambda state: has_paintings(state, world, 1, True)) + set_rule(world.multiworld.get_location("Subcon Forest - Noose Treehouse", world.player), + lambda state: has_paintings(state, world, 2, True)) + set_rule(world.multiworld.get_location("Subcon Forest - Long Tree Climb Chest", world.player), + lambda state: has_paintings(state, world, 2, True)) + set_rule(world.multiworld.get_location("Subcon Forest - Dweller Platforming Tree B", world.player), + lambda state: has_paintings(state, world, 3, True)) + set_rule(world.multiworld.get_location("Subcon Forest - Tall Tree Hookshot Swing", world.player), + lambda state: has_paintings(state, world, 3, True)) # You can cherry hover to Snatcher's post-fight cutscene, which completes the level without having to fight him connect_regions(world.multiworld.get_region("Subcon Forest Area", world.player), @@ -648,15 +640,20 @@ def set_mafia_town_rules(world: World): def set_subcon_rules(world: World): - set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), - lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world) - and (not painting_logic(world) or has_paintings(state, world, 1)) - or state.has("YCHE Access", world.player)) - set_rule(world.multiworld.get_location("Act Completion (Time Rift - Village)", world.player), lambda state: can_use_hat(state, world, HatType.BREWING) or state.has("Umbrella", world.player) or can_use_hat(state, world, HatType.DWELLER)) + # You can't skip over the boss arena wall without cherry hover, so these two need to be set this way + set_rule(world.multiworld.get_location("Subcon Forest - Boss Arena Chest", world.player), + lambda state: state.has("TOD Access", world.player) and can_use_hookshot(state, world) + and has_paintings(state, world, 1, False) or state.has("YCHE Access", world.player)) + + # The painting wall can't be skipped without cherry hover, which is Expert + set_rule(world.multiworld.get_location("Act Completion (Toilet of Doom)", world.player), + lambda state: can_use_hookshot(state, world) and can_hit(state, world) + and has_paintings(state, world, 1, False)) + add_rule(world.multiworld.get_entrance("Subcon Forest - Act 2", world.player), lambda state: state.has("Snatcher's Contract - The Subcon Well", world.player)) @@ -671,7 +668,7 @@ def set_subcon_rules(world: World): if painting_logic(world): add_rule(world.multiworld.get_location("Act Completion (Contractual Obligations)", world.player), - lambda state: state.has("Progressive Painting Unlock", world.player)) + lambda state: has_paintings(state, world, 1, False)) for key in contract_locations: if key == "Snatcher's Contract - The Subcon Well": diff --git a/worlds/ahit/__init__.py b/worlds/ahit/__init__.py index ea8eb1b5b6..88904d8939 100644 --- a/worlds/ahit/__init__.py +++ b/worlds/ahit/__init__.py @@ -9,7 +9,9 @@ from .DeathWishLocations import create_dw_regions, dw_classes, death_wishes from .DeathWishRules import set_dw_rules, create_enemy_events from worlds.AutoWorld import World, WebWorld from typing import List, Dict, TextIO -from worlds.LauncherComponents import Component, components +from worlds.LauncherComponents import Component, components, icon_paths +from multiprocessing import Process +from Utils import local_path hat_craft_order: Dict[int, List[HatType]] = {} hat_yarn_costs: Dict[int, Dict[HatType, int]] = {} @@ -20,7 +22,14 @@ dw_shuffle: Dict[int, List[str]] = {} nyakuza_thug_items: Dict[int, Dict[str, int]] = {} badge_seller_count: Dict[int, int] = {} -components.append(Component("A Hat in Time Client", "AHITClient")) +components.append(Component("A Hat in Time Client", "AHITClient", icon='yatta')) +icon_paths['yatta'] = local_path('data', 'yatta.png') + + +def run_client(): + from AHITClient import main + p = Process(target=main) + p.start() class AWebInTime(WebWorld): @@ -170,7 +179,8 @@ class HatInTimeWorld(World): "Chapter6Cost": chapter_timepiece_costs[self.player][ChapterIndex.CRUISE], "Chapter7Cost": chapter_timepiece_costs[self.player][ChapterIndex.METRO], "BadgeSellerItemCount": badge_seller_count[self.player], - "SeedNumber": str(self.multiworld.seed)} # For shop prices + "SeedNumber": str(self.multiworld.seed), # For shop prices + "SeedName": self.multiworld.seed_name} if self.multiworld.HatItems[self.player].value == 0: slot_data.setdefault("SprintYarnCost", hat_yarn_costs[self.player][HatType.SPRINT])