diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 70c725ef..d7cc3c74 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index a4fe93e7..65c01e3b 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -12,10 +12,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v1 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/CommonClient.py b/CommonClient.py index bde3adb5..c4202491 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -11,7 +11,7 @@ import websockets import Utils if __name__ == "__main__": - Utils.init_logging("TextClient") + Utils.init_logging("TextClient", exception_logger="Client") from MultiServer import CommandProcessor from NetUtils import Endpoint, decode, NetworkItem, encode, JSONtoTextParser, ClientStatus, Permission @@ -39,13 +39,13 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_connect(self, address: str = "") -> bool: """Connect to a MultiWorld Server""" self.ctx.server_address = None - asyncio.create_task(self.ctx.connect(address if address else None)) + asyncio.create_task(self.ctx.connect(address if address else None), name="connecting") return True def _cmd_disconnect(self) -> bool: """Disconnect from a MultiWorld Server""" self.ctx.server_address = None - asyncio.create_task(self.ctx.disconnect()) + asyncio.create_task(self.ctx.disconnect(), name="disconnecting") return True def _cmd_received(self) -> bool: @@ -81,6 +81,16 @@ class ClientCommandProcessor(CommandProcessor): self.output("No missing location checks found.") return True + def _cmd_items(self): + self.output(f"Item Names for {self.ctx.game}") + for item_name in AutoWorldRegister.world_types[self.ctx.game].item_name_to_id: + self.output(item_name) + + def _cmd_locations(self): + self.output(f"Location Names for {self.ctx.game}") + for location_name in AutoWorldRegister.world_types[self.ctx.game].location_name_to_id: + self.output(location_name) + def _cmd_ready(self): self.ctx.ready = not self.ctx.ready if self.ctx.ready: @@ -89,10 +99,10 @@ class ClientCommandProcessor(CommandProcessor): else: state = ClientStatus.CLIENT_CONNECTED self.output("Unreadied.") - asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}])) + asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}]), name="send StatusUpdate") def default(self, raw: str): - asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}])) + asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}]), name="send Say") class CommonContext(): @@ -149,7 +159,7 @@ class CommonContext(): self.set_getters(network_data_package) # execution - self.keep_alive_task = asyncio.create_task(keep_alive(self)) + self.keep_alive_task = asyncio.create_task(keep_alive(self), name="Bouncy") @property def total_locations(self) -> typing.Optional[int]: @@ -230,13 +240,24 @@ class CommonContext(): self.password = await self.console_input() return self.password + async def send_connect(self, **kwargs): + payload = { + "cmd": 'Connect', + 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, + 'tags': self.tags, + 'uuid': Utils.get_unique_identifier(), 'game': self.game + } + if kwargs: + payload.update(kwargs) + await self.send_msgs([payload]) + async def console_input(self): self.input_requests += 1 return await self.input_queue.get() async def connect(self, address=None): await self.disconnect() - self.server_task = asyncio.create_task(server_loop(self, address)) + self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") def on_print(self, args: dict): logger.info(args["text"]) @@ -271,7 +292,7 @@ class CommonContext(): logger.info(f"DeathLink: Received from {data['source']}") async def send_death(self, death_text: str = ""): - logger.info("Sending death to your friends...") + logger.info("DeathLink: Sending death to your friends...") self.last_death_link = time.time() await self.send_msgs([{ "cmd": "Bounce", "tags": ["DeathLink"], @@ -282,6 +303,27 @@ class CommonContext(): } }]) + async def shutdown(self): + self.server_address = None + if self.server and not self.server.socket.closed: + await self.server.socket.close() + if self.server_task: + await self.server_task + + while self.input_requests > 0: + self.input_queue.put_nowait(None) + self.input_requests -= 1 + self.keep_alive_task.cancel() + + async def update_death_link(self, death_link): + old_tags = self.tags.copy() + if death_link: + self.tags.add("DeathLink") + else: + self.tags -= {"DeathLink"} + if old_tags != self.tags and self.server and not self.server.socket.closed: + await self.send_msgs([{"cmd": "ConnectUpdate", "tags": self.tags}]) + async def keep_alive(ctx: CommonContext, seconds_between_checks=100): """some ISPs/network configurations drop TCP connections if no payload is sent (ignore TCP-keep-alive) @@ -340,14 +382,14 @@ async def server_loop(ctx: CommonContext, address=None): await ctx.connection_closed() if ctx.server_address: logger.info(f"... reconnecting in {ctx.current_reconnect_delay}s") - asyncio.create_task(server_autoreconnect(ctx)) + asyncio.create_task(server_autoreconnect(ctx), name="server auto reconnect") ctx.current_reconnect_delay *= 2 async def server_autoreconnect(ctx: CommonContext): await asyncio.sleep(ctx.current_reconnect_delay) if ctx.server_address and ctx.server_task is None: - ctx.server_task = asyncio.create_task(server_loop(ctx)) + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") async def process_server_cmd(ctx: CommonContext, args: dict): @@ -534,6 +576,7 @@ if __name__ == '__main__': class TextContext(CommonContext): tags = {"AP", "IgnoreGame"} + game = "Archipelago" async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -542,11 +585,7 @@ if __name__ == '__main__': logger.info('Enter slot name:') self.auth = await self.console_input() - await self.send_msgs([{"cmd": 'Connect', - 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, - 'tags': self.tags, - 'uuid': Utils.get_unique_identifier(), 'game': self.game - }]) + await self.send_connect() def on_package(self, cmd: str, args: dict): if cmd == "Connected": @@ -555,7 +594,7 @@ if __name__ == '__main__': async def main(args): ctx = TextContext(args.connect, args.password) - ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + ctx.server_task = asyncio.create_task(server_loop(ctx), name="server loop") if gui_enabled: input_task = None from kvui import TextManager @@ -566,16 +605,7 @@ if __name__ == '__main__': ui_task = None await ctx.exit_event.wait() - ctx.server_address = None - if ctx.server and not ctx.server.socket.closed: - await ctx.server.socket.close() - if ctx.server_task: - await ctx.server_task - - while ctx.input_requests > 0: - ctx.input_queue.put_nowait(None) - ctx.input_requests -= 1 - + await ctx.shutdown() if ui_task: await ui_task diff --git a/FactorioClient.py b/FactorioClient.py index 5eb39035..292d926e 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -15,7 +15,7 @@ from queue import Queue import Utils if __name__ == "__main__": - Utils.init_logging("FactorioClient") + Utils.init_logging("FactorioClient", exception_logger="Client") from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger, gui_enabled, \ get_base_parser @@ -65,22 +65,13 @@ class FactorioContext(CommonContext): if password_requested and not self.password: await super(FactorioContext, self).server_auth(password_requested) - if not self.auth: - if self.rcon_client: - get_info(self, self.rcon_client) # retrieve current auth code - else: - raise Exception("Cannot connect to a server with unknown own identity, " - "bridge to Factorio first.") + if self.rcon_client: + await get_info(self, self.rcon_client) # retrieve current auth code + else: + raise Exception("Cannot connect to a server with unknown own identity, " + "bridge to Factorio first.") - await self.send_msgs([{ - "cmd": 'Connect', - 'password': self.password, - 'name': self.auth, - 'version': Utils.version_tuple, - 'tags': self.tags, - 'uuid': Utils.get_unique_identifier(), - 'game': "Factorio" - }]) + await self.send_connect() def on_print(self, args: dict): super(FactorioContext, self).on_print(args) @@ -134,6 +125,8 @@ async def game_watcher(ctx: FactorioContext): research_data = data["research_done"] research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} victory = data["victory"] + if "death_link" in data: # TODO: Remove this if statement around version 0.2.4 or so + await ctx.update_death_link(data["death_link"]) if not ctx.finished_game and victory: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) @@ -148,7 +141,8 @@ async def game_watcher(ctx: FactorioContext): death_link_tick = data.get("death_link_tick", 0) if death_link_tick != ctx.death_link_tick: ctx.death_link_tick = death_link_tick - await ctx.send_death() + if "DeathLink" in ctx.tags: + await ctx.send_death() await asyncio.sleep(0.1) @@ -234,14 +228,13 @@ async def factorio_server_watcher(ctx: FactorioContext): factorio_process.wait(5) -def get_info(ctx, rcon_client): +async def get_info(ctx, rcon_client): info = json.loads(rcon_client.send_command("/ap-rcon-info")) ctx.auth = info["slot_name"] ctx.seed_name = info["seed_name"] # 0.2.0 addition, not present earlier death_link = bool(info.get("death_link", False)) - if death_link: - ctx.tags.add("DeathLink") + await ctx.update_death_link(death_link) async def factorio_spinup_server(ctx: FactorioContext) -> bool: @@ -280,7 +273,7 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) if ctx.mod_version == ctx.__class__.mod_version: raise Exception("No Archipelago mod was loaded. Aborting.") - get_info(ctx, rcon_client) + await get_info(ctx, rcon_client) await asyncio.sleep(0.01) except Exception as e: @@ -322,14 +315,7 @@ async def main(args): await progression_watcher await factorio_server_task - if ctx.server and not ctx.server.socket.closed: - await ctx.server.socket.close() - if ctx.server_task: - await ctx.server_task - - while ctx.input_requests > 0: - ctx.input_queue.put_nowait(None) - ctx.input_requests -= 1 + await ctx.shutdown() if ui_task: await ui_task diff --git a/Generate.py b/Generate.py index c209b35e..9fa4edf7 100644 --- a/Generate.py +++ b/Generate.py @@ -90,7 +90,8 @@ def main(args=None, callback=ERmain): except Exception as e: raise ValueError(f"File {args.meta_file_path} is destroyed. Please fix your yaml.") from e meta_weights = weights_cache[args.meta_file_path] - print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights, 'No description specified')}") + print(f"Meta: {args.meta_file_path} >> {get_choice('meta_description', meta_weights)}") + del(meta_weights["meta_description"]) if args.samesettings: raise Exception("Cannot mix --samesettings with --meta") else: @@ -126,7 +127,7 @@ def main(args=None, callback=ERmain): erargs.outputname = seed_name erargs.outputpath = args.outputpath - Utils.init_logging(f"Generate_{seed}.txt", loglevel=args.log_level) + Utils.init_logging(f"Generate_{seed}", loglevel=args.log_level) erargs.lttp_rom = args.lttp_rom erargs.sm_rom = args.sm_rom @@ -139,17 +140,17 @@ def main(args=None, callback=ERmain): player_path_cache[player] = player_files.get(player, args.weights_file_path) if meta_weights: - for player, path in player_path_cache.items(): - weights_cache[path].setdefault("meta_ignore", []) - for key in meta_weights: - option = get_choice(key, meta_weights) - if option is not None: - for player, path in player_path_cache.items(): - players_meta = weights_cache[path].get("meta_ignore", []) - if key not in players_meta: - weights_cache[path][key] = option - elif type(players_meta) == dict and players_meta[key] and option not in players_meta[key]: - weights_cache[path][key] = option + for category_name, category_dict in meta_weights.items(): + for key in category_dict: + option = get_choice(key, category_dict) + if option is not None: + for player, path in player_path_cache.items(): + if category_name is None: + weights_cache[path][key] = option + elif category_name not in weights_cache[path]: + raise Exception(f"Meta: Category {category_name} is not present in {path}.") + else: + weights_cache[path][category_name][key] = option name_counter = Counter() erargs.player_settings = {} diff --git a/Main.py b/Main.py index 671e56de..3fed538d 100644 --- a/Main.py +++ b/Main.py @@ -197,8 +197,6 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No if lookup_vanilla_location_to_entrance[location.address] != main_entrance.name: er_hint_data[region.player][location.address] = main_entrance.name - - checks_in_area = {player: {area: list() for area in ordered_areas} for player in range(1, world.players + 1)} @@ -215,6 +213,10 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No 'Inverted Ganons Tower': 'Ganons Tower'} \ .get(location.parent_region.dungeon.name, location.parent_region.dungeon.name) checks_in_area[location.player][dungeonname].append(location.address) + elif location.parent_region.type == RegionType.LightWorld: + checks_in_area[location.player]["Light World"].append(location.address) + elif location.parent_region.type == RegionType.DarkWorld: + checks_in_area[location.player]["Dark World"].append(location.address) elif main_entrance.parent_region.type == RegionType.LightWorld: checks_in_area[location.player]["Light World"].append(location.address) elif main_entrance.parent_region.type == RegionType.DarkWorld: diff --git a/MultiServer.py b/MultiServer.py index c1be4d9e..726a21f1 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -469,7 +469,7 @@ def update_aliases(ctx: Context, team: int): asyncio.create_task(ctx.send_encoded_msgs(client, cmd)) -async def server(websocket, path, ctx: Context): +async def server(websocket, path: str = "/", ctx: Context = None): client = Client(websocket, ctx) ctx.endpoints.append(client) @@ -591,10 +591,12 @@ def get_status_string(ctx: Context, team: int): text = "Player Status on your team:" for slot in ctx.locations: connected = len(ctx.clients[team][slot]) + death_link = len([client for client in ctx.clients[team][slot] if "DeathLink" in client.tags]) completion_text = f"({len(ctx.location_checks[team, slot])}/{len(ctx.locations[slot])})" + death_text = f" {death_link} of which are death link" if connected else "" goal_text = " and has finished." if ctx.client_game_state[team, slot] == ClientStatus.CLIENT_GOAL else "." text += f"\n{ctx.get_aliased_name(team, slot)} has {connected} connection{'' if connected == 1 else 's'}" \ - f"{goal_text} {completion_text}" + f"{death_text}{goal_text} {completion_text}" return text @@ -652,27 +654,27 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]: def register_location_checks(ctx: Context, team: int, slot: int, locations: typing.Iterable[int]): new_locations = set(locations) - ctx.location_checks[team, slot] + new_locations.intersection_update(ctx.locations[slot]) # ignore location IDs unknown to this multidata if new_locations: ctx.client_activity_timers[team, slot] = datetime.datetime.now(datetime.timezone.utc) for location in new_locations: - if location in ctx.locations[slot]: - item_id, target_player = ctx.locations[slot][location] - new_item = NetworkItem(item_id, location, slot) - if target_player != slot or slot in ctx.remote_items: - get_received_items(ctx, team, target_player).append(new_item) + item_id, target_player = ctx.locations[slot][location] + new_item = NetworkItem(item_id, location, slot) + if target_player != slot or slot in ctx.remote_items: + get_received_items(ctx, team, target_player).append(new_item) - logging.info('(Team #%d) %s sent %s to %s (%s)' % ( - team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id), - ctx.player_names[(team, target_player)], get_location_name_from_id(location))) - info_text = json_format_send_event(new_item, target_player) - ctx.broadcast_team(team, [info_text]) + logging.info('(Team #%d) %s sent %s to %s (%s)' % ( + team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(item_id), + ctx.player_names[(team, target_player)], get_location_name_from_id(location))) + info_text = json_format_send_event(new_item, target_player) + ctx.broadcast_team(team, [info_text]) ctx.location_checks[team, slot] |= new_locations send_new_items(ctx) ctx.broadcast(ctx.clients[team][slot], [{ "cmd": "RoomUpdate", "hint_points": get_slot_points(ctx, team, slot), - "checked_locations": locations, # duplicated data, but used for coop + "checked_locations": new_locations, # send back new checks only }]) ctx.save() @@ -1242,6 +1244,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): game = ctx.games[slot] if "IgnoreGame" not in args["tags"] and args['game'] != game: errors.add('InvalidGame') + minver = ctx.minimum_client_versions[slot] + if minver > args['version']: + errors.add('IncompatibleVersion') # only exact version match allowed if ctx.compatibility == 0 and args['version'] != version_tuple: @@ -1257,9 +1262,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): client.auth = False # swapping Team/Slot client.team = team client.slot = slot - minver = ctx.minimum_client_versions[slot] - if minver > args['version']: - errors.add('IncompatibleVersion') + ctx.client_ids[client.team, client.slot] = args["uuid"] ctx.clients[team][slot].append(client) client.version = args['version'] @@ -1283,8 +1286,9 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): await ctx.send_msgs(client, reply) elif cmd == "GetDataPackage": - exclusions = set(args.get("exclusions", [])) + exclusions = args.get("exclusions", []) if exclusions: + exclusions = set(exclusions) games = {name: game_data for name, game_data in network_data_package["games"].items() if name not in exclusions} package = network_data_package.copy() @@ -1680,7 +1684,7 @@ async def main(args: argparse.Namespace): ctx.init_save(not args.disable_save) - ctx.server = websockets.serve(functools.partial(server, ctx=ctx), ctx.host, ctx.port, ping_timeout=None, + ctx.server = websockets.serve(functools.partial(server, ctx=ctx), host=ctx.host, port=ctx.port, ping_timeout=None, ping_interval=None) ip = args.host if args.host else Utils.get_public_ipv4() logging.info('Hosting game at %s:%d (%s)' % (ip, ctx.port, diff --git a/Options.py b/Options.py index a87618f9..9fc2da5a 100644 --- a/Options.py +++ b/Options.py @@ -379,6 +379,7 @@ class StartHints(ItemSet): class StartLocationHints(OptionSet): + """Start with these locations and their item prefilled into the !hint command""" displayname = "Start Location Hints" @@ -399,7 +400,7 @@ per_game_common_options = { "start_inventory": StartInventory, "start_hints": StartHints, "start_location_hints": StartLocationHints, - "exclude_locations": OptionSet + "exclude_locations": ExcludeLocations } if __name__ == "__main__": diff --git a/Patch.py b/Patch.py index b136e932..09f41277 100644 --- a/Patch.py +++ b/Patch.py @@ -1,3 +1,5 @@ +# TODO: convert this into a system like AutoWorld + import bsdiff4 import yaml import os @@ -14,16 +16,25 @@ current_patch_version = 3 GAME_ALTTP = "A Link to the Past" GAME_SM = "Super Metroid" -supported_games = {"A Link to the Past", "Super Metroid"} +GAME_SOE = "Secret of Evermore" +supported_games = {"A Link to the Past", "Super Metroid", "Secret of Evermore"} + +preferred_endings = { + GAME_ALTTP: "apbp", + GAME_SM: "apm3", + GAME_SOE: "apsoe" +} def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes: if game == GAME_ALTTP: - from worlds.alttp.Rom import JAP10HASH + from worlds.alttp.Rom import JAP10HASH as HASH elif game == GAME_SM: - from worlds.sm.Rom import JAP10HASH + from worlds.sm.Rom import JAP10HASH as HASH + elif game == GAME_SOE: + from worlds.soe.Patch import USHASH as HASH else: - raise RuntimeError("Selected game for base rom not found.") + raise RuntimeError(f"Selected game {game} for base rom not found.") patch = yaml.dump({"meta": metadata, "patch": patch, @@ -31,21 +42,14 @@ def generate_yaml(patch: bytes, metadata: Optional[dict] = None, game: str = GAM # minimum version of patch system expected for patching to be successful "compatible_version": 3, "version": current_patch_version, - "base_checksum": JAP10HASH}) + "base_checksum": HASH}) return patch.encode(encoding="utf-8-sig") def generate_patch(rom: bytes, metadata: Optional[dict] = None, game: str = GAME_ALTTP) -> bytes: - if game == GAME_ALTTP: - from worlds.alttp.Rom import get_base_rom_bytes - elif game == GAME_SM: - from worlds.sm.Rom import get_base_rom_bytes - else: - raise RuntimeError("Selected game for base rom not found.") - if metadata is None: metadata = {} - patch = bsdiff4.diff(get_base_rom_bytes(), rom) + patch = bsdiff4.diff(get_base_rom_data(game), rom) return generate_yaml(patch, metadata, game) @@ -66,27 +70,30 @@ def create_patch_file(rom_file_to_patch: str, server: str = "", destination: str def create_rom_bytes(patch_file: str, ignore_version: bool = False) -> Tuple[dict, str, bytearray]: data = Utils.parse_yaml(lzma.decompress(load_bytes(patch_file)).decode("utf-8-sig")) game_name = data["game"] - if game_name in supported_games: - if game_name == GAME_ALTTP: - from worlds.alttp.Rom import get_base_rom_bytes - elif game_name == GAME_SM: - from worlds.sm.Rom import get_base_rom_bytes - else: - raise Exception(f"No Patch handler for game {game_name}") - elif game_name == "alttp": # old version for A Link to the Past - from worlds.alttp.Rom import get_base_rom_bytes - else: - raise Exception(f"Cannot handle game {game_name}") - if not ignore_version and data["compatible_version"] > current_patch_version: raise RuntimeError("Patch file is incompatible with this patcher, likely an update is required.") - patched_data = bsdiff4.patch(get_base_rom_bytes(), data["patch"]) + patched_data = bsdiff4.patch(get_base_rom_data(game_name), data["patch"]) rom_hash = patched_data[int(0x7FC0):int(0x7FD5)] data["meta"]["hash"] = "".join(chr(x) for x in rom_hash) target = os.path.splitext(patch_file)[0] + ".sfc" return data["meta"], target, patched_data +def get_base_rom_data(game: str): + if game == GAME_ALTTP: + from worlds.alttp.Rom import get_base_rom_bytes + elif game == "alttp": # old version for A Link to the Past + from worlds.alttp.Rom import get_base_rom_bytes + elif game == GAME_SM: + from worlds.sm.Rom import get_base_rom_bytes + elif game == GAME_SOE: + file_name = Utils.get_options()["soe_options"]["rom"] + get_base_rom_bytes = lambda: bytes(read_rom(open(file_name, "rb"))) + else: + raise RuntimeError("Selected game for base rom not found.") + return get_base_rom_bytes() + + def create_rom_file(patch_file: str) -> Tuple[dict, str]: data, target, patched_data = create_rom_bytes(patch_file) with open(target, "wb") as f: diff --git a/SNIClient.py b/SNIClient.py index 5afda798..f5d9fba2 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sys import threading import time import multiprocessing @@ -14,7 +15,7 @@ from json import loads, dumps from Utils import get_item_name_from_id, init_logging if __name__ == "__main__": - init_logging("SNIClient") + init_logging("SNIClient", exception_logger="Client") import colorama @@ -72,7 +73,7 @@ class LttPCommandProcessor(ClientCommandProcessor): pass self.ctx.snes_reconnect_address = None - asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number)) + asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), name="SNES Connect") return True def _cmd_snes_close(self) -> bool: @@ -84,20 +85,17 @@ class LttPCommandProcessor(ClientCommandProcessor): else: return False - def _cmd_snes_write(self, address, data): - """Write the specified byte (base10) to the SNES' memory address (base16).""" - if self.ctx.snes_state != SNESState.SNES_ATTACHED: - self.output("No attached SNES Device.") - return False - - snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)])) - asyncio.create_task(snes_flush_writes(self.ctx)) - self.output("Data Sent") - return True - - def _cmd_test_death(self): - self.ctx.on_deathlink({"source": "Console", - "time": time.time()}) + # Left here for quick re-addition for debugging. + # def _cmd_snes_write(self, address, data): + # """Write the specified byte (base10) to the SNES' memory address (base16).""" + # if self.ctx.snes_state != SNESState.SNES_ATTACHED: + # self.output("No attached SNES Device.") + # return False + # + # snes_buffered_write(self.ctx, int(address, 16), bytes([int(data)])) + # asyncio.create_task(snes_flush_writes(self.ctx)) + # self.output("Data Sent") + # return True class Context(CommonContext): @@ -145,12 +143,7 @@ class Context(CommonContext): self.awaiting_rom = False self.auth = self.rom auth = base64.b64encode(self.rom).decode() - await self.send_msgs([{"cmd": 'Connect', - 'password': self.password, 'name': auth, 'version': Utils.version_tuple, - 'tags': self.tags, - 'uuid': Utils.get_unique_identifier(), - 'game': self.game - }]) + await self.send_connect(name=auth) def on_deathlink(self, data: dict): if not self.killing_player_task or self.killing_player_task.done(): @@ -896,10 +889,10 @@ async def game_watcher(ctx: Context): if not ctx.rom: ctx.finished_game = False - gameName = await snes_read(ctx, SM_ROMNAME_START, 2) - if gameName is None: + game_name = await snes_read(ctx, SM_ROMNAME_START, 2) + if game_name is None: continue - elif gameName == b"SM": + elif game_name == b"SM": ctx.game = GAME_SM else: ctx.game = GAME_ALTTP @@ -912,14 +905,7 @@ async def game_watcher(ctx: Context): death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR if ctx.game == GAME_ALTTP else SM_DEATH_LINK_ACTIVE_ADDR, 1) if death_link: - death_link = bool(death_link[0] & 0b1) - old_tags = ctx.tags.copy() - if death_link: - ctx.tags.add("DeathLink") - else: - ctx.tags -= {"DeathLink"} - if old_tags != ctx.tags and ctx.server and not ctx.server.socket.closed: - await ctx.send_msgs([{"cmd": "ConnectUpdate", "tags": ctx.tags}]) + await ctx.update_death_link(bool(death_link[0] & 0b1)) if not ctx.prev_rom or ctx.prev_rom != ctx.rom: ctx.locations_checked = set() ctx.locations_scouted = set() @@ -1083,14 +1069,24 @@ async def main(): meta, romfile = Patch.create_rom_file(args.diff_file) args.connect = meta["server"] logging.info(f"Wrote rom file to {romfile}") - adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled) - if adjusted: - try: - shutil.move(adjustedromfile, romfile) - adjustedromfile = romfile - except Exception as e: - logging.exception(e) - asyncio.create_task(run_game(adjustedromfile if adjusted else romfile)) + if args.diff_file.endswith(".apsoe"): + import webbrowser + webbrowser.open("http://www.evermizer.com/apclient/") + logging.info("Starting Evermizer Client in your Browser...") + import time + time.sleep(3) + sys.exit() + elif args.diff_file.endswith((".apbp", "apz3")): + adjustedromfile, adjusted = Utils.get_adjuster_settings(romfile, gui_enabled) + if adjusted: + try: + shutil.move(adjustedromfile, romfile) + adjustedromfile = romfile + except Exception as e: + logging.exception(e) + asyncio.create_task(run_game(adjustedromfile if adjusted else romfile)) + else: + asyncio.create_task(run_game(romfile)) ctx = Context(args.snes, args.connect, args.password) if ctx.server_task is None: @@ -1105,28 +1101,19 @@ async def main(): input_task = asyncio.create_task(console_loop(ctx), name="Input") ui_task = None - snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address)) + snes_connect_task = asyncio.create_task(snes_connect(ctx, ctx.snes_address), name="SNES Connect") watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher") await ctx.exit_event.wait() - if snes_connect_task: - snes_connect_task.cancel() + ctx.server_address = None ctx.snes_reconnect_address = None - - await watcher_task - - if ctx.server and not ctx.server.socket.closed: - await ctx.server.socket.close() - if ctx.server_task: - await ctx.server_task - if ctx.snes_socket is not None and not ctx.snes_socket.closed: await ctx.snes_socket.close() - - while ctx.input_requests > 0: - ctx.input_queue.put_nowait(None) - ctx.input_requests -= 1 + if snes_connect_task: + snes_connect_task.cancel() + await watcher_task + await ctx.shutdown() if ui_task: await ui_task diff --git a/Utils.py b/Utils.py index c5a0a48c..84e4378f 100644 --- a/Utils.py +++ b/Utils.py @@ -122,16 +122,25 @@ parse_yaml = safe_load unsafe_parse_yaml = functools.partial(load, Loader=Loader) +def get_cert_none_ssl_context(): + import ssl + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx + + @cache_argsless def get_public_ipv4() -> str: import socket import urllib.request ip = socket.gethostbyname(socket.gethostname()) + ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen('https://checkip.amazonaws.com/').read().decode('utf8').strip() + ip = urllib.request.urlopen('https://checkip.amazonaws.com/', context=ctx).read().decode('utf8').strip() except Exception as e: try: - ip = urllib.request.urlopen('https://v4.ident.me').read().decode('utf8').strip() + ip = urllib.request.urlopen('https://v4.ident.me', context=ctx).read().decode('utf8').strip() except: logging.exception(e) pass # we could be offline, in a local game, so no point in erroring out @@ -143,8 +152,9 @@ def get_public_ipv6() -> str: import socket import urllib.request ip = socket.gethostbyname(socket.gethostname()) + ctx = get_cert_none_ssl_context() try: - ip = urllib.request.urlopen('https://v6.ident.me').read().decode('utf8').strip() + ip = urllib.request.urlopen('https://v6.ident.me', context=ctx).read().decode('utf8').strip() except Exception as e: logging.exception(e) pass # we could be offline, in a local game, or ipv6 may not be available @@ -166,6 +176,9 @@ def get_default_options() -> dict: "sni": "SNI", "rom_start": True, }, + "soe_options": { + "rom_file": "Secret of Evermore (USA).sfc", + }, "lttp_options": { "rom_file": "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc", "sni": "SNI", @@ -414,7 +427,7 @@ loglevel_mapping = {'error': logging.ERROR, 'info': logging.INFO, 'warning': log def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, write_mode: str = "w", - log_format: str = "[%(name)s]: %(message)s"): + log_format: str = "[%(name)s]: %(message)s", exception_logger: str = ""): loglevel: int = loglevel_mapping.get(loglevel, loglevel) log_folder = local_path("logs") os.makedirs(log_folder, exist_ok=True) @@ -433,3 +446,19 @@ def init_logging(name: str, loglevel: typing.Union[str, int] = logging.INFO, wri root_logger.addHandler( logging.StreamHandler(sys.stdout) ) + + # Relay unhandled exceptions to logger. + if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified + orig_hook = sys.excepthook + + def handle_exception(exc_type, exc_value, exc_traceback): + if issubclass(exc_type, KeyboardInterrupt): + sys.__excepthook__(exc_type, exc_value, exc_traceback) + return + logging.getLogger(exception_logger).exception("Uncaught exception", + exc_info=(exc_type, exc_value, exc_traceback)) + return orig_hook(exc_type, exc_value, exc_traceback) + + handle_exception._wrapped = True + + sys.excepthook = handle_exception diff --git a/WebHostLib/__init__.py b/WebHostLib/__init__.py index cfce8b05..b9a99569 100644 --- a/WebHostLib/__init__.py +++ b/WebHostLib/__init__.py @@ -141,7 +141,7 @@ def new_room(seed: UUID): abort(404) room = Room(seed=seed, owner=session["_id"], tracker=uuid4()) commit() - return redirect(url_for("hostRoom", room=room.id)) + return redirect(url_for("host_room", room=room.id)) def _read_log(path: str): @@ -159,7 +159,7 @@ def display_log(room: UUID): @app.route('/room/', methods=['GET', 'POST']) -def hostRoom(room: UUID): +def host_room(room: UUID): room = Room.get(id=room) if room is None: return abort(404) @@ -175,20 +175,17 @@ def hostRoom(room: UUID): return render_template("hostRoom.html", room=room) -@app.route('/hosted/', methods=['GET', 'POST']) -def hostRoomRedirect(room: UUID): - return redirect(url_for("hostRoom", room=room)) - - @app.route('/favicon.ico') def favicon(): return send_from_directory(os.path.join(app.root_path, 'static/static'), 'favicon.ico', mimetype='image/vnd.microsoft.icon') + @app.route('/discord') def discord(): return redirect("https://discord.gg/archipelago") + from WebHostLib.customserver import run_server_process from . import tracker, upload, landing, check, generate, downloads, api # to trigger app routing picking up on it diff --git a/WebHostLib/api/generate.py b/WebHostLib/api/generate.py index b2caf4d7..2868a079 100644 --- a/WebHostLib/api/generate.py +++ b/WebHostLib/api/generate.py @@ -9,6 +9,7 @@ from pony.orm import commit from WebHostLib import app, Generation, STATE_QUEUED, Seed, STATE_ERROR from WebHostLib.check import get_yaml_data, roll_options +from WebHostLib.generate import get_meta @api_endpoints.route('/generate', methods=['POST']) @@ -35,9 +36,6 @@ def generate_api(): if "race" in json_data: race = bool(0 if json_data["race"] in {"false"} else int(json_data["race"])) - hint_cost = int(meta_options_source.get("hint_cost", 10)) - forfeit_mode = meta_options_source.get("forfeit_mode", "goal") - if not options: return {"text": "No options found. Expected file attachment or json weights." }, 400 @@ -45,7 +43,8 @@ def generate_api(): if len(options) > app.config["MAX_ROLL"]: return {"text": "Max size of multiworld exceeded", "detail": app.config["MAX_ROLL"]}, 409 - + meta = get_meta(meta_options_source) + meta["race"] = race results, gen_options = roll_options(options) if any(type(result) == str for result in results.values()): return {"text": str(results), @@ -54,7 +53,7 @@ def generate_api(): gen = Generation( options=pickle.dumps({name: vars(options) for name, options in gen_options.items()}), # convert to json compatible - meta=json.dumps({"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode}), state=STATE_QUEUED, + meta=json.dumps(meta), state=STATE_QUEUED, owner=session["_id"]) commit() return {"text": f"Generation of seed {gen.id} started successfully.", diff --git a/WebHostLib/downloads.py b/WebHostLib/downloads.py index e7856a12..ce623c1e 100644 --- a/WebHostLib/downloads.py +++ b/WebHostLib/downloads.py @@ -1,10 +1,11 @@ from flask import send_file, Response, render_template from pony.orm import select -from Patch import update_patch_data +from Patch import update_patch_data, preferred_endings from WebHostLib import app, Slot, Room, Seed, cache import zipfile + @app.route("/dl_patch//") def download_patch(room_id, patch_id): patch = Slot.get(id=patch_id) @@ -19,7 +20,8 @@ def download_patch(room_id, patch_id): patch_data = update_patch_data(patch.data, server=f"{app.config['PATCH_TARGET']}:{last_port}") patch_data = io.BytesIO(patch_data) - fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}.apbp" + fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](room_id)}." \ + f"{preferred_endings[patch.game]}" return send_file(patch_data, as_attachment=True, attachment_filename=fname) @@ -28,23 +30,6 @@ def download_spoiler(seed_id): return Response(Seed.get(id=seed_id).spoiler, mimetype="text/plain") -@app.route("/dl_raw_patch//") -def download_raw_patch(seed_id, player_id: int): - seed = Seed.get(id=seed_id) - patch = select(patch for patch in seed.slots if - patch.player_id == player_id).first() - - if not patch: - return "Patch not found" - else: - import io - - patch_data = update_patch_data(patch.data, server="") - patch_data = io.BytesIO(patch_data) - - fname = f"P{patch.player_id}_{patch.player_name}_{app.jinja_env.filters['suuid'](seed_id)}.apbp" - return send_file(patch_data, as_attachment=True, attachment_filename=fname) - @app.route("/slot_file//") def download_slot_file(room_id, player_id: int): room = Room.get(id=room_id) diff --git a/WebHostLib/generate.py b/WebHostLib/generate.py index ac16d7c9..c6e2d7d8 100644 --- a/WebHostLib/generate.py +++ b/WebHostLib/generate.py @@ -20,6 +20,16 @@ from .check import get_yaml_data, roll_options from .upload import upload_zip_to_db +def get_meta(options_source: dict) -> dict: + meta = { + "hint_cost": int(options_source.get("hint_cost", 10)), + "forfeit_mode": options_source.get("forfeit_mode", "goal"), + "remaining_mode": options_source.get("forfeit_mode", "disabled"), + "collect_mode": options_source.get("collect_mode", "disabled"), + } + return meta + + @app.route('/generate', methods=['GET', 'POST']) @app.route('/generate/', methods=['GET', 'POST']) def generate(race=False): @@ -35,9 +45,9 @@ def generate(race=False): else: results, gen_options = roll_options(options) # get form data -> server settings - hint_cost = int(request.form.get("hint_cost", 10)) - forfeit_mode = request.form.get("forfeit_mode", "goal") - meta = {"race": race, "hint_cost": hint_cost, "forfeit_mode": forfeit_mode} + meta = get_meta(request.form) + meta["race"] = race + if race: meta["item_cheat"] = False meta["remaining"] = False diff --git a/WebHostLib/models.py b/WebHostLib/models.py index e03d7666..2c85e5a7 100644 --- a/WebHostLib/models.py +++ b/WebHostLib/models.py @@ -40,7 +40,7 @@ class Seed(db.Entity): creation_time = Required(datetime, default=lambda: datetime.utcnow()) slots = Set(Slot) spoiler = Optional(LongStr, lazy=True) - meta = Required(str, default=lambda: "{\"race\": false}") # additional meta information/tags + meta = Required(LongStr, default=lambda: "{\"race\": false}") # additional meta information/tags class Command(db.Entity): @@ -53,5 +53,5 @@ class Generation(db.Entity): id = PrimaryKey(UUID, default=uuid4) owner = Required(UUID) options = Required(buffer, lazy=True) - meta = Required(str, default=lambda: "{\"race\": false}") + meta = Required(LongStr, default=lambda: "{\"race\": false}") state = Required(int, default=0, index=True) diff --git a/WebHostLib/options.py b/WebHostLib/options.py index 46386569..c8a7b9a6 100644 --- a/WebHostLib/options.py +++ b/WebHostLib/options.py @@ -49,7 +49,7 @@ def create(): game_options = {} for option_name, option in world.options.items(): if option.options: - this_option = { + game_options[option_name] = this_option = { "type": "select", "displayName": option.displayname if hasattr(option, "displayname") else option_name, "description": option.__doc__ if option.__doc__ else "Please document me!", @@ -66,7 +66,10 @@ def create(): if sub_option_id == option.default: this_option["defaultValue"] = sub_option_name - game_options[option_name] = this_option + this_option["options"].append({ + "name": "Random", + "value": "random", + }) elif hasattr(option, "range_start") and hasattr(option, "range_end"): game_options[option_name] = { diff --git a/WebHostLib/static/assets/tutorial/archipelago/advanced_settings_en.md b/WebHostLib/static/assets/tutorial/archipelago/advanced_settings_en.md new file mode 100644 index 00000000..0432efb8 --- /dev/null +++ b/WebHostLib/static/assets/tutorial/archipelago/advanced_settings_en.md @@ -0,0 +1,160 @@ +# Advanced Game Options Guide + + +The Archipelago system generates games using player configuration files as input. Generally these are going to be +YAML files and each player will have one of these containing their custom settings for the randomized game they want to play. +On the website when you customize your settings from one of the game player settings pages which you can reach from the +[supported games page](/games). Clicking on the export settings button at the bottom will provide you with a pre-filled out +YAML with your options. The player settings page also has an option to download a fully filled out yaml containing every +option with every available setting for the available options. + +## YAML Formatting +YAML files are a format of human-readable markup config files. The basic syntax +of a yaml file will have `root` and then different levels of `nested` text that the generator "reads" in order to determine +your settings. To nest text, the correct syntax is **two spaces over** from its root option. A YAML file can be edited +with whatever text editor you choose to use though I personally recommend that you use [Sublime Text](https://www.sublimetext.com/). +This program out of the box supports the correct formatting for the YAML file, so you will be able to tab and get proper +highlighting for any potential errors made while editing the file. If using any other text editor such as Notepad or +Notepad++ whenever you move to nest an option that it is done with two spaces and not tabs. + +Typical YAML format will look as follows: +```yaml +root_option: + nested_option_one: + option_one_setting_one: 1 + option_one_setting_two: 0 + nested_option_two: + option_two_setting_one: 14 + option_two_setting_two: 43 +``` + +In Archipelago YAML options are always written out in full lowercase with underscores separating any words. The numbers +following the colons here are weights. The generator will read the weight of every option the roll that option that many +times, the next option as many times as its numbered and so forth. For the above example `nested_option_one` will have +`option_one_setting_one` 1 time and `option_one_setting_two` 0 times so `option_one_setting_one` is guaranteed to occur. +For `nested_option_two`, `option_two_setting_one` will be rolled 14 times and `option_two_setting_two` will be rolled 43 +times against each other. This means `option_two_setting_two` will be more likely to occur but it isn't guaranteed adding +more randomness and "mystery" to your settings. Every configurable setting supports weights. + +### Root Options +Currently there are only a few options that are root options. Everything else should be nested within one of these root +options or in some cases nested within other nested options. The only options that should exist in root are `description`, +`name`, `game`, `requires`, `accessibility`, `progression_balancing`, `triggers`, and the name of the games you want +settings for. +* `description` is ignored by the generator and is simply a good way for you to organize if you have multiple files using +this to detail the intention of the file. +* `name` is the player name you would like to use and is used for your slot data to connect with most games. This can also +be filled with multiple names each having a weight to it. +* `game` is where either your chosen game goes or if you would like can be filled with multiple games each with different +weights. +* `requires` details different requirements from the generator for the YAML to work as you expect it to. Generally this +is good for detailing the version of Archipelago this YAML was prepared for as if it is rolled on an older version may be +missing settings and as such will not work as expected. If any plando is used in the file then requiring it here to ensure +it will be used is good practice. +* `accessibility` determines the level of access to the game the generation will expect you to have in order to reach your +completion goal. This supports `items`, `locations`, and `none` and is set to `locations` by default. + * `items` will guarantee you can acquire all items in your world but may not be able to access all locations. This mostly +comes into play if there is any entrance shuffle in the seed as locations without items in them can be placed in areas +that make them unreachable. + * `none` will only guarantee that the seed is beatable. You will be guaranteed able to finish the seed logically but +may not be able to access all locations or acquire all items. A good example of this is having a big key in the big chest +in a dungeon in ALTTP making it impossible to get and finish the dungeon. +* `progression_balancing` is a system the Archipelago generator uses to try and reduce "BK mode" as much as possible. This +primarily involves moving necessary progression items into earlier logic spheres to make the games more accessible so that +players almost always have something to do. This can be turned `on` or `off` and is `on` by default. +* `triggers` is one of the more advanced options that allows you to create conditional adjustments. You can read more +about this [here](/tutorial/archipelago/triggers/en). + +### Game Options + +One of your root settings will be the name of the game you would like to populate with settings in the format +`GameName`. since it is possible to give a weight to any option it is possible to have one file that can generate a seed +for you where you don't know which game you'll play. For these cases you'll want to fill the game options for every game +that can be rolled by these settings. If a game can be rolled it **must** have a settings section even if it is empty. + +#### Universal Game Options + +Some options in Archipelago can be used by every game but must still be placed within the relevant game's section. +Currently, these options are `start_inventory`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints`, +`exclude_locations`, and various [plando options](tutorial/archipelago/plando/en). +* `start_inventory` will give any items defined here to you at the beginning of your game. The format for this must be +the name as it appears in the game files and the amount you would like to start with. For example `Rupees(5): 6` which +will give you 30 rupees. +* `start_hints` gives you free server hints for the defined item/s at the beginning of the game allowing you to hint for +the location without using any hint points. +* `local_items` will force any items you want to be in your world instead of being in another world. +* `non_local_items` is the inverse of `local_items` forcing any items you want to be in another world and won't be located +in your own. +* `start_location_hints` allows you to define a location which you can then hint for to find out what item is located in +it to see how important the location is. +* `exclude_locations` lets you define any locations that you don't want to do and during generation will force a "junk" +item which isn't necessary for progression to go in these locations. + +### Example + +```yaml + +description: An example using various advanced options +name: Example Player +game: A Link to the Past +requires: + version: 0.2.0 +accessibility: none +progression_balancing: on +A Link to the Past: + smallkey_shuffle: + original_dungeon: 1 + any_world: 1 + start_inventory: + Pegasus Boots: 1 + Bombs (3): 2 + start_hints: + - Hammer + local_items: + - Bombos + - Ether + - Quake + non_local_items: + - Moon Pearl + start_location_hints: + - Spike Cave + exclude_locations: + - Cave 45 +triggers: + - option_category: A Link to the Past + option_name: smallkey_shuffle + option_result: any_world + options: + A Link to the Past: + bigkey_shuffle: any_world + map_shuffle: any_world + compass_shuffle: any_world +``` + +#### This is a fully functional yaml file that will do all the following things: +* `description` gives us a general overview so if we pull up this file later we can understand the intent. +* `name` is `Example Player` and this will be used in the server console when sending and receiving items. +* `game` is set to `A Link to the Past` meaning that is what game we will play with this file. +* `requires` is set to require release version 0.2.0 or higher. +* `accesibility` is set to `none` which will set this seed to beatable only meaning some locations and items may be +completely inaccessible but the seed will still be completable. +* `progression_balancing` is set on meaning we will likely receive important items earlier increasing the chance of having +things to do. +* `A Link to the Past` defines a location for us to nest all the game options we would like to use for our game `A Link to the Past`. +* `smallkey_shuffle` is an option for A Link to the Past which determines how dungeon small keys are shuffled. In this example +we have a 1/2 chance for them to either be placed in their original dungeon and a 1/2 chance for them to be placed anywhere +amongst the multiworld. +* `start_inventory` defines an area for us to determine what items we would like to start the seed with. For this example +we have: + * `Pegasus Boots: 1` which gives us 1 copy of the Pegasus Boots + * `Bombs (3)` gives us 2 packs of 3 bombs or 6 total bombs +* `start_hints` gives us a starting hint for the hammer available at the beginning of the multiworld which we can use with no cost. +* `local_items` forces the `Bombos`, `Ether`, and `Quake` medallions to all be placed within our own world, meaning we +have to find it ourselves. +* `non_local_items` forces the `Moon Pearl` to be placed in someone else's world, meaning we won't be able to find it. +* `start_location_hints` gives us a starting hint for the `Spike Cave` location available at the beginning of the multiworld +that can be used for no cost. +* `exclude_locations` forces a not important item to be placed on the `Cave 45` location. +* `triggers` allows us to define a trigger such that if our `smallkey_shuffle` option happens to roll the `any_world` +result it will also ensure that `bigkey_shuffle`, `map_shuffle`, and `compass_shuffle` are also forced to the `any_world` +result. \ No newline at end of file diff --git a/WebHostLib/static/assets/tutorial/archipelago/setup_en.md b/WebHostLib/static/assets/tutorial/archipelago/setup_en.md index 4ce96731..ec8cc273 100644 --- a/WebHostLib/static/assets/tutorial/archipelago/setup_en.md +++ b/WebHostLib/static/assets/tutorial/archipelago/setup_en.md @@ -12,6 +12,17 @@ game/games you plan to play are available here go ahead and install these as wel supported by Archipelago but not listed in the installation check the relevant tutorial. ## Generating a game + +### Creating a YAML +In a multiworld there must be one YAML per world. Any number of players can play on each world using either the game's +native coop system or using archipelago's coop support. Each world will hold one slot in the multiworld and will have a +slot name and, if the relevant game requires it, files to associate it with that multiworld. If multiple people plan to +play in one world cooperatively then they will only need one YAML for their coop world, but if each player is planning on +playing their own game then they will each need a YAML. These YAML files can be generated by going to the relevant game's +player settings page, entering the name they want to use for the game, setting the options to what they would like to +play with and then clicking on the export settings button. This will then download a YAML file that will contain all of +these options and this can then be given to whoever is going to generate the game. + ### Gather all player YAMLS All players that wish to play in the generated multiworld must have a YAML file which contains the settings that they wish to play with. A YAML is a file which contains human readable markup. In other words, this is a settings file kind of like an INI file or a TOML file. @@ -51,6 +62,7 @@ The generator will put a zip folder into your `Archipelago\output` folder with t This contains the patch files and relevant mods for the players as well as the serverdata for the host. ## Hosting a multiworld + ### Uploading the seed to the website The easiest and most recommended method is to generate the game on the website which will allow you to create a private room with all the necessary files you can share, as well as hosting the game and supporting item trackers for various games. diff --git a/WebHostLib/static/assets/tutorial/factorio/setup_en.md b/WebHostLib/static/assets/tutorial/factorio/setup_en.md index cf148d1a..0586278f 100644 --- a/WebHostLib/static/assets/tutorial/factorio/setup_en.md +++ b/WebHostLib/static/assets/tutorial/factorio/setup_en.md @@ -107,6 +107,9 @@ Archipelago if you chose to include it during the installation process. 10. Enter `localhost` into the server address box 11. Click "Connect" +For additional client features, issue the `/help` command in the Archipelago Client. Once connected to the AP +server, you can also issue the `!help` command to learn about additional commands like `!hint`. + ## Allowing Other People to Join Your Game 1. Ensure your Archipelago Client is running. 2. Ensure port `34197` is forwarded to the computer running the Archipelago Client. diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index 50e1964f..2762aa7a 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -4,7 +4,7 @@ "tutorials": [ { "name": "Multiworld Setup Tutorial", - "description": "A Guide to setting up the Archipelago software to generate multiworld games on your computer.", + "description": "A guide to setting up the Archipelago software to generate and host multiworld games on your computer and using the website.", "files": [ { "language": "English", @@ -16,9 +16,23 @@ } ] }, + { + "name": "Using Advanced Settings", + "description": "A guide to reading yaml files and editing them to fully customize your game.", + "files": [ + { + "language": "English", + "filename": "archipelago/advanced_settings_en.md", + "link": "archipelago/advanced_settings/en", + "authors": [ + "alwaysintreble" + ] + } + ] + }, { "name": "Archipelago Triggers Guide", - "description": "A Guide to setting up and using triggers in your game settings.", + "description": "A guide to setting up and using triggers in your game settings.", "files": [ { "language": "English", diff --git a/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md b/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md index 1f7a0cfc..737de947 100644 --- a/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md +++ b/WebHostLib/static/assets/tutorial/zelda3/multiworld_en.md @@ -1,10 +1,10 @@ # A Link to the Past Randomizer Setup Guide ## Required Software -- [Z3Client](https://github.com/ArchipelagoMW/Z3Client/releases) or the LttPClient included with +- [Z3Client](https://github.com/ArchipelagoMW/Z3Client/releases) or the SNIClient included with [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) - - If installing Archipelago, make sure to check the box for LttPClient during install, or SNI will not be included -- [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and LttPClient) + - If installing Archipelago, make sure to check the box for SNIClient -> A Link to the Past Patch Setup during install, or SNI will not be included +- [SNI](https://github.com/alttpo/sni/releases) (Included in both Z3Client and SNIClient) - Hardware or software capable of loading and playing SNES ROM files - An emulator capable of connecting to SNI ([snes9x Multitroid](https://drive.google.com/drive/folders/1_ej-pwWtCAHYXIrvs5Hro16A1s9Hi3Jz), @@ -76,7 +76,7 @@ Firewall. 4. In the new window, click **Browse...** 5. Select the connector lua file included with your client - Z3Client users should download `sniConnector.lua` from the client download page - - LttPClient users should look in their Archipelago folder for `/sni/Connector.lua` + - SNIClient users should look in their Archipelago folder for `/sni/Connector.lua` ##### BizHawk 1. Ensure you have the BSNES core loaded. You may do this by clicking on the Tools menu in BizHawk and following @@ -88,7 +88,7 @@ Firewall. 4. Click the button to open a new Lua script. 5. Select the `sniConnector.lua` file you downloaded above - Z3Client users should download `sniConnector.lua` from the client download page - - LttPClient users should look in their Archipelago folder for `/sni/Connector.lua` + - SNIClient users should look in their Archipelago folder for `/sni/Connector.lua` #### With hardware This guide assumes you have downloaded the correct firmware for your device. If you have not diff --git a/WebHostLib/static/assets/tutorial/zelda5/setup_en.md b/WebHostLib/static/assets/tutorial/zelda5/setup_en.md index 747d2e21..eaf1621d 100644 --- a/WebHostLib/static/assets/tutorial/zelda5/setup_en.md +++ b/WebHostLib/static/assets/tutorial/zelda5/setup_en.md @@ -28,7 +28,7 @@ can all have different options. ### Where do I get a YAML file? -A basic OOT yaml will look like this. (There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download (Archipelago)[https://github.com/ArchipelagoMW/Archipelago/releases] and look for the sample file in the "Players" folder)) +A basic OOT yaml will look like this. There are lots of cosmetic options that have been removed for the sake of this tutorial, if you want to see a complete list, download [Archipelago](https://github.com/ArchipelagoMW/Archipelago/releases) and look for the sample file in the "Players" folder. ```yaml description: Default Ocarina of Time Template # Used to describe your yaml. Useful if you have multiple files # Your name in-game. Spaces will be replaced with underscores and there is a 16 character limit diff --git a/WebHostLib/templates/generate.html b/WebHostLib/templates/generate.html index 72592167..9004bc48 100644 --- a/WebHostLib/templates/generate.html +++ b/WebHostLib/templates/generate.html @@ -34,7 +34,14 @@ - + + + + + + + + + + + diff --git a/WebHostLib/templates/hostRoom.html b/WebHostLib/templates/hostRoom.html index 24b7bbf1..9e392af9 100644 --- a/WebHostLib/templates/hostRoom.html +++ b/WebHostLib/templates/hostRoom.html @@ -20,7 +20,7 @@ later, you can simply refresh this page and the server will be started again.
{% if room.last_port %} - You can connect to this room by using '/connect archipelago.gg:{{ room.last_port }}' + You can connect to this room by using '/connect {{ config['PATCH_TARGET'] }}:{{ room.last_port }}' in the client.
{% endif %} {{ macros.list_patches_room(room) }} {% if room.owner == session["_id"] %} diff --git a/WebHostLib/templates/viewSeed.html b/WebHostLib/templates/viewSeed.html index 36271cad..62763629 100644 --- a/WebHostLib/templates/viewSeed.html +++ b/WebHostLib/templates/viewSeed.html @@ -28,34 +28,16 @@ {% endif %} - {% if seed.multidata %} - - - - - {% else %} - - + - {% endif %}
+ + (?) + +
+ + (?) + + + +
+ + (?) + + + +
(?) Download
Rooms:  - {% call macros.list_rooms(rooms) %} -
  • - Create New Room -
  • - {% endcall %} -
    Files:  -
      - {% for slot in seed.slots %} - +
    Rooms:  + {% call macros.list_rooms(rooms) %}
  • - Player {{ slot.player_name }} + Create New Room
  • - - - {% endfor %} - + {% endcall %}
    diff --git a/WebHostLib/upload.py b/WebHostLib/upload.py index 64537295..eef912c2 100644 --- a/WebHostLib/upload.py +++ b/WebHostLib/upload.py @@ -10,10 +10,7 @@ from pony.orm import flush, select from WebHostLib import app, Seed, Room, Slot from Utils import parse_yaml - -accepted_zip_contents = {"patches": ".apbp", - "spoiler": ".txt", - "multidata": ".archipelago"} +from Patch import preferred_endings banned_zip_contents = (".sfc",) @@ -29,15 +26,17 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s if file.filename.endswith(banned_zip_contents): return "Uploaded data contained a rom file, which is likely to contain copyrighted material. " \ "Your file was deleted." - elif file.filename.endswith(".apbp"): + elif file.filename.endswith(tuple(preferred_endings.values())): data = zfile.open(file, "r").read() yaml_data = parse_yaml(lzma.decompress(data).decode("utf-8-sig")) if yaml_data["version"] < 2: - return "Old format cannot be uploaded (outdated .apbp)", 500 + return "Old format cannot be uploaded (outdated .apbp)" metadata = yaml_data["meta"] - slots.add(Slot(data=data, player_name=metadata["player_name"], + + slots.add(Slot(data=data, + player_name=metadata["player_name"], player_id=metadata["player_id"], - game="A Link to the Past")) + game=yaml_data["game"])) elif file.filename.endswith(".apmc"): data = zfile.open(file, "r").read() @@ -66,8 +65,8 @@ def upload_zip_to_db(zfile: zipfile.ZipFile, owner=None, meta={"race": False}, s MultiServer.Context._decompress(multidata) except: flash("Could not load multidata. File may be corrupted or incompatible.") - else: - multidata = zfile.open(file).read() + multidata = None + if multidata: flush() # commit slots seed = Seed(multidata=multidata, spoiler=spoiler, slots=slots, owner=owner, meta=json.dumps(meta), diff --git a/data/client.kv b/data/client.kv index 5b429e78..200ba024 100644 --- a/data/client.kv +++ b/data/client.kv @@ -1,9 +1,9 @@ tab_width: 200 -: +: canvas.before: Color: - rgba: 0.2, 0.2, 0.2, 1 + rgba: (.0, 0.9, .1, .3) if self.selected else (0.2, 0.2, 0.2, 1) Rectangle: size: self.size pos: self.pos @@ -13,10 +13,10 @@ font_size: dp(20) markup: True : - viewclass: 'Row' + viewclass: 'SelectableLabel' scroll_y: 0 effect_cls: "ScrollEffect" - RecycleBoxLayout: + SelectableRecycleBoxLayout: default_size: None, dp(20) default_size_hint: 1, None size_hint_y: None diff --git a/docs/network protocol.md b/docs/network protocol.md index 42669af4..4673b5b7 100644 --- a/docs/network protocol.md +++ b/docs/network protocol.md @@ -140,7 +140,7 @@ The arguments for RoomUpdate are identical to [RoomInfo](#RoomInfo) barring: | ---- | ---- | ----- | | hint_points | int | New argument. The client's current hint points. | | players | list\[NetworkPlayer\] | Changed argument. Always sends all players, whether connected or not. | -| checked_locations | May be a partial update, containing new locations that were checked. | +| checked_locations | May be a partial update, containing new locations that were checked, especially from a coop partner in the same slot. | | missing_locations | Should never be sent as an update, if needed is the inverse of checked_locations. | All arguments for this packet are optional, only changes are sent. diff --git a/docs/webhost configuration sample.yaml b/docs/webhost configuration sample.yaml new file mode 100644 index 00000000..f007805b --- /dev/null +++ b/docs/webhost configuration sample.yaml @@ -0,0 +1,52 @@ +# This is a sample configuration for the Web host. +# If you wish to change any of these, rename this file to config.yaml +# Default values are shown here. Uncomment and change the values as desired. + +# TODO +#SELFHOST: true + +# Maximum concurrent world gens +#GENERATORS: 8 + +# TODO +#SELFLAUNCH: true + +# TODO +#DEBUG: false + +# Web hosting port +#PORT: 80 + +# Place where uploads go. +#UPLOAD_FOLDER: uploads + +# Maximum upload size. Default is 64 megabyte (64 * 1024 * 1024) +#MAX_CONTENT_LENGTH: 67108864 + +# Secret key used to determine important things like cookie authentication of room/seed page ownership. +# If you wish to deploy, uncomment the following line and set it to something not easily guessable. +# SECRET_KEY: "Your secret key here" + +# TODO +#JOB_THRESHOLD: 2 + +# waitress uses one thread for I/O, these are for processing of view that get sent +#WAITRESS_THREADS: 10 + +# Database provider details: +#PONY: +# provider: "sqlite" +# filename: "ap.db3" # This MUST be the ABSOLUTE PATH to the file. +# create_db: true + +# Maximum number of players that are allowed to be rolled on the server. After this limit, one should roll locally and upload the results. +#MAX_ROLL: 20 + +# TODO +#CACHE_TYPE: "simple" + +# TODO +#JSON_AS_ASCII: false + +# Patch target. This is the address encoded into the patch that will be used for client auto-connect. +#PATCH_TARGET: archipelago.gg \ No newline at end of file diff --git a/inno_setup_310.iss b/inno_setup_310.iss index c9dc0432..9bde5ef2 100644 --- a/inno_setup_310.iss +++ b/inno_setup_310.iss @@ -48,21 +48,21 @@ Name: "playing"; Description: "Installation for playing purposes" Name: "custom"; Description: "Custom installation"; Flags: iscustom [Components] -Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed -Name: "generator"; Description: "Generator"; Types: full hosting -Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728 -Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728 -Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 -Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296 -Name: "server"; Description: "Server"; Types: full hosting -Name: "client"; Description: "Clients"; Types: full playing -Name: "client/sni"; Description: "SNI Client"; Types: full playing -Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing -Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing -Name: "client/factorio"; Description: "Factorio"; Types: full playing +Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed +Name: "generator"; Description: "Generator"; Types: full hosting +Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning +Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning +Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 +Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning +Name: "server"; Description: "Server"; Types: full hosting +Name: "client"; Description: "Clients"; Types: full playing +Name: "client/sni"; Description: "SNI Client"; Types: full playing +Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning +Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning +Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 -Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing -Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing +Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing +Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing [Dirs] NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; @@ -136,7 +136,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{ap Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server - [Code] const SHCONTCH_NOPROGRESSBOX = 4; @@ -221,6 +220,19 @@ var OoTROMFilePage: TInputFileWizardPage; var MinecraftDownloadPage: TDownloadWizardPage; +function GetSNESMD5OfFile(const rom: string): string; +var data: AnsiString; +begin + if LoadStringFromFile(rom, data) then + begin + if Length(data) mod 1024 = 512 then + begin + data := copy(data, 513, Length(data)-512); + end; + Result := GetMD5OfString(data); + end; +end; + function CheckRom(name: string; hash: string): string; var rom: string; begin @@ -229,8 +241,8 @@ begin if Length(rom) > 0 then begin log('existing ROM found'); - log(IntToStr(CompareStr(GetMD5OfFile(rom), hash))); - if CompareStr(GetMD5OfFile(rom), hash) = 0 then + log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash))); + if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then begin log('existing ROM verified'); Result := rom; @@ -317,7 +329,16 @@ begin MinecraftDownloadPage.Hide; end; Result := True; - end else + end + else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then + Result := not (LttPROMFilePage.Values[0] = '') + else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then + Result := not (SMROMFilePage.Values[0] = '') + else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then + Result := not (SoEROMFilePage.Values[0] = '') + else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then + Result := not (OoTROMFilePage.Values[0] = '') + else Result := True; end; @@ -327,7 +348,7 @@ begin Result := lttprom else if Assigned(LttPRomFilePage) then begin - R := CompareStr(GetMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') + R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') if R <> 0 then MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); @@ -343,7 +364,7 @@ begin Result := smrom else if Assigned(SMRomFilePage) then begin - R := CompareStr(GetMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') + R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') if R <> 0 then MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); @@ -359,8 +380,7 @@ begin Result := soerom else if Assigned(SoERomFilePage) then begin - R := CompareStr(GetMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') - log(GetMD5OfFile(SoEROMFilePage.Values[0])) + R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') if R <> 0 then MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); @@ -374,7 +394,7 @@ function GetOoTROMPath(Param: string): string; begin if Length(ootrom) > 0 then Result := ootrom - else if Assigned(OoTROMFilePage) then + else if (Assigned(OoTROMFilePage)) then begin R := CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '5bd1fe107bf8106b2ab6650abecd54d6') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '6697768a7a7df2dd27a692a2638ea90b') * CompareStr(GetMD5OfFile(OoTROMFilePage.Values[0]), '05f0f3ebacbc8df9243b6148ffe4792f'); if R <> 0 then @@ -417,4 +437,4 @@ begin Result := not (WizardIsComponentSelected('generator/soe')); if (assigned(OoTROMFilePage)) and (PageID = OoTROMFilePage.ID) then Result := not (WizardIsComponentSelected('generator/oot')); -end; \ No newline at end of file +end; diff --git a/inno_setup_38.iss b/inno_setup_38.iss index 9d391b88..f4e751af 100644 --- a/inno_setup_38.iss +++ b/inno_setup_38.iss @@ -48,21 +48,21 @@ Name: "playing"; Description: "Installation for playing purposes" Name: "custom"; Description: "Custom installation"; Flags: iscustom [Components] -Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed -Name: "generator"; Description: "Generator"; Types: full hosting -Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728 -Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728 -Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 -Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296 -Name: "server"; Description: "Server"; Types: full hosting -Name: "client"; Description: "Clients"; Types: full playing -Name: "client/sni"; Description: "SNI Client"; Types: full playing -Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing -Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing -Name: "client/factorio"; Description: "Factorio"; Types: full playing +Name: "core"; Description: "Core Files"; Types: full hosting playing custom; Flags: fixed +Name: "generator"; Description: "Generator"; Types: full hosting +Name: "generator/sm"; Description: "Super Metroid ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning +Name: "generator/soe"; Description: "Secret of Evermore ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 3145728; Flags: disablenouninstallwarning +Name: "generator/lttp"; Description: "A Link to the Past ROM Setup and Enemizer"; Types: full hosting; ExtraDiskSpaceRequired: 5191680 +Name: "generator/oot"; Description: "Ocarina of Time ROM Setup"; Types: full hosting; ExtraDiskSpaceRequired: 100663296; Flags: disablenouninstallwarning +Name: "server"; Description: "Server"; Types: full hosting +Name: "client"; Description: "Clients"; Types: full playing +Name: "client/sni"; Description: "SNI Client"; Types: full playing +Name: "client/sni/lttp"; Description: "SNI Client - A Link to the Past Patch Setup"; Types: full playing; Flags: disablenouninstallwarning +Name: "client/sni/sm"; Description: "SNI Client - Super Metroid Patch Setup"; Types: full playing; Flags: disablenouninstallwarning +Name: "client/factorio"; Description: "Factorio"; Types: full playing Name: "client/minecraft"; Description: "Minecraft"; Types: full playing; ExtraDiskSpaceRequired: 226894278 -Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing -Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing +Name: "client/oot"; Description: "Ocarina of Time Adjuster"; Types: full playing +Name: "client/text"; Description: "Text, to !command and chat"; Types: full playing [Dirs] NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; @@ -136,7 +136,6 @@ Root: HKCR; Subkey: "{#MyAppName}multidata\DefaultIcon"; ValueData: "{ap Root: HKCR; Subkey: "{#MyAppName}multidata\shell\open\command"; ValueData: """{app}\ArchipelagoServer.exe"" ""%1"""; ValueType: string; ValueName: ""; Components: server - [Code] const SHCONTCH_NOPROGRESSBOX = 4; @@ -221,6 +220,19 @@ var OoTROMFilePage: TInputFileWizardPage; var MinecraftDownloadPage: TDownloadWizardPage; +function GetSNESMD5OfFile(const rom: string): string; +var data: AnsiString; +begin + if LoadStringFromFile(rom, data) then + begin + if Length(data) mod 1024 = 512 then + begin + data := copy(data, 513, Length(data)-512); + end; + Result := GetMD5OfString(data); + end; +end; + function CheckRom(name: string; hash: string): string; var rom: string; begin @@ -229,8 +241,8 @@ begin if Length(rom) > 0 then begin log('existing ROM found'); - log(IntToStr(CompareStr(GetMD5OfFile(rom), hash))); - if CompareStr(GetMD5OfFile(rom), hash) = 0 then + log(IntToStr(CompareStr(GetSNESMD5OfFile(rom), hash))); + if CompareStr(GetSNESMD5OfFile(rom), hash) = 0 then begin log('existing ROM verified'); Result := rom; @@ -317,7 +329,16 @@ begin MinecraftDownloadPage.Hide; end; Result := True; - end else + end + else if (assigned(LttPROMFilePage)) and (CurPageID = LttPROMFilePage.ID) then + Result := not (LttPROMFilePage.Values[0] = '') + else if (assigned(SMROMFilePage)) and (CurPageID = SMROMFilePage.ID) then + Result := not (SMROMFilePage.Values[0] = '') + else if (assigned(SoEROMFilePage)) and (CurPageID = SoEROMFilePage.ID) then + Result := not (SoEROMFilePage.Values[0] = '') + else if (assigned(OoTROMFilePage)) and (CurPageID = OoTROMFilePage.ID) then + Result := not (OoTROMFilePage.Values[0] = '') + else Result := True; end; @@ -327,7 +348,7 @@ begin Result := lttprom else if Assigned(LttPRomFilePage) then begin - R := CompareStr(GetMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') + R := CompareStr(GetSNESMD5OfFile(LttPROMFilePage.Values[0]), '03a63945398191337e896e5771f77173') if R <> 0 then MsgBox('ALttP ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); @@ -343,7 +364,7 @@ begin Result := smrom else if Assigned(SMRomFilePage) then begin - R := CompareStr(GetMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') + R := CompareStr(GetSNESMD5OfFile(SMROMFilePage.Values[0]), '21f3e98df4780ee1c667b84e57d88675') if R <> 0 then MsgBox('Super Metroid ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); @@ -359,8 +380,7 @@ begin Result := soerom else if Assigned(SoERomFilePage) then begin - R := CompareStr(GetMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') - log(GetMD5OfFile(SoEROMFilePage.Values[0])) + R := CompareStr(GetSNESMD5OfFile(SoEROMFilePage.Values[0]), '6e9c94511d04fac6e0a1e582c170be3a') if R <> 0 then MsgBox('Secret of Evermore ROM validation failed. Very likely wrong file.', mbInformation, MB_OK); diff --git a/kvui.py b/kvui.py index 1a0071c9..84ff1c1a 100644 --- a/kvui.py +++ b/kvui.py @@ -2,7 +2,6 @@ import os import logging import typing import asyncio -import sys os.environ["KIVY_NO_CONSOLELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1" @@ -11,6 +10,8 @@ os.environ["KIVY_LOG_ENABLE"] = "0" from kivy.app import App from kivy.core.window import Window +from kivy.core.clipboard import Clipboard +from kivy.core.text.markup import MarkupLabel from kivy.base import ExceptionHandler, ExceptionManager, Config, Clock from kivy.factory import Factory from kivy.properties import BooleanProperty, ObjectProperty @@ -25,6 +26,10 @@ from kivy.uix.label import Label from kivy.uix.progressbar import ProgressBar from kivy.utils import escape_markup from kivy.lang import Builder +from kivy.uix.recycleview.views import RecycleDataViewBehavior +from kivy.uix.behaviors import FocusBehavior +from kivy.uix.recycleboxlayout import RecycleBoxLayout +from kivy.uix.recycleview.layout import LayoutSelectionBehavior import Utils from NetUtils import JSONtoTextParser, JSONMessagePart @@ -140,6 +145,46 @@ class ContainerLayout(FloatLayout): pass +class SelectableRecycleBoxLayout(FocusBehavior, LayoutSelectionBehavior, + RecycleBoxLayout): + """ Adds selection and focus behaviour to the view. """ + + +class SelectableLabel(RecycleDataViewBehavior, Label): + """ Add selection support to the Label """ + index = None + selected = BooleanProperty(False) + + def refresh_view_attrs(self, rv, index, data): + """ Catch and handle the view changes """ + self.index = index + return super(SelectableLabel, self).refresh_view_attrs( + rv, index, data) + + def on_touch_down(self, touch): + """ Add selection on touch down """ + if super(SelectableLabel, self).on_touch_down(touch): + return True + if self.collide_point(*touch.pos): + if self.selected: + self.parent.clear_selection() + else: + # Not a fan of the following few lines, but they work. + temp = MarkupLabel(text=self.text).markup + text = "".join(part for part in temp if not part.startswith(("[color", "[/color]"))) + cmdinput = App.get_running_app().textinput + if not cmdinput.text and text.startswith("Didn't find something that closely matches, did you mean "): + name = Utils.get_text_between(text, "Didn't find something that closely matches, did you mean ", + "? (") + cmdinput.text = f"!hint {name}" + Clipboard.copy(text) + return self.parent.select_with_touch(self.index, touch) + + def apply_selection(self, rv, index, is_selected): + """ Respond to the selection of items in the view. """ + self.selected = is_selected + + class GameManager(App): logging_pairs = [ ("Client", "Archipelago"), @@ -164,7 +209,8 @@ class GameManager(App): # top part server_label = ServerLabel() connect_layout.add_widget(server_label) - self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False) + self.server_connect_bar = TextInput(text="archipelago.gg", size_hint_y=None, height=30, multiline=False, + write_tab=False) self.server_connect_bar.bind(on_text_validate=self.connect_button_action) connect_layout.add_widget(self.server_connect_bar) self.server_connect_button = Button(text="Connect", size=(100, 30), size_hint_y=None, size_hint_x=None) @@ -201,33 +247,21 @@ class GameManager(App): info_button = Button(height=30, text="Command:", size_hint_x=None) info_button.bind(on_release=self.command_button_action) bottom_layout.add_widget(info_button) - textinput = TextInput(size_hint_y=None, height=30, multiline=False) - textinput.bind(on_text_validate=self.on_message) - bottom_layout.add_widget(textinput) + self.textinput = TextInput(size_hint_y=None, height=30, multiline=False, write_tab=False) + self.textinput.bind(on_text_validate=self.on_message) + + def text_focus(event): + """Needs to be set via delay, as unfocusing happens after on_message""" + self.textinput.focus = True + + self.textinput.text_focus = text_focus + bottom_layout.add_widget(self.textinput) self.grid.add_widget(bottom_layout) self.commandprocessor("/help") Clock.schedule_interval(self.update_texts, 1 / 30) self.container.add_widget(self.grid) - self.catch_unhandled_exceptions() return self.container - def catch_unhandled_exceptions(self): - """Relay unhandled exceptions to UI logger.""" - if not getattr(sys.excepthook, "_wrapped", False): # skip if already modified - orig_hook = sys.excepthook - - def handle_exception(exc_type, exc_value, exc_traceback): - if issubclass(exc_type, KeyboardInterrupt): - sys.__excepthook__(exc_type, exc_value, exc_traceback) - return - logging.getLogger("Client").exception("Uncaught exception", - exc_info=(exc_type, exc_value, exc_traceback)) - return orig_hook(exc_type, exc_value, exc_traceback) - - handle_exception._wrapped = True - - sys.excepthook = handle_exception - def update_texts(self, dt): if self.ctx.server: self.title = self.base_title + " " + Utils.__version__ + \ @@ -242,7 +276,11 @@ class GameManager(App): self.progressbar.value = 0 def command_button_action(self, button): - logging.getLogger("Client").info("/help for client commands and !help for server commands.") + if self.ctx.server: + logging.getLogger("Client").info("/help for client commands and !help for server commands.") + else: + logging.getLogger("Client").info("/help for client commands and once you are connected, " + "!help for server commands.") def connect_button_action(self, button): if self.ctx.server: @@ -269,6 +307,9 @@ class GameManager(App): self.ctx.input_queue.put_nowait(input_text) elif input_text: self.commandprocessor(input_text) + + Clock.schedule_once(textinput.text_focus) + except Exception as e: logging.getLogger("Client").exception(e) @@ -304,7 +345,7 @@ class TextManager(GameManager): class LogtoUI(logging.Handler): def __init__(self, on_log): - super(LogtoUI, self).__init__(logging.DEBUG) + super(LogtoUI, self).__init__(logging.INFO) self.on_log = on_log def handle(self, record: logging.LogRecord) -> None: diff --git a/meta.yaml b/meta.yaml index 84159c5e..fabb57ef 100644 --- a/meta.yaml +++ b/meta.yaml @@ -4,12 +4,6 @@ # For example, if a meta.yaml fast_ganon result is rolled, every player will have that fast_ganon goal # There is the special case of null, which ignores that part of the meta.yaml, # allowing for a chance for that meta to not take effect -# Players can also have a meta_ignore option to ignore specific options -# Example of ignore that would be in a player's file: -# meta_ignore: -# mode: -# inverted -# This means, if mode is meta-rolled and the result happens to be inverted, then defer to the player's yaml instead. meta_description: Meta-Mystery file with the intention of having similar-length completion times for a hopefully better experience null: progression_balancing: # Progression balancing tries to make sure that the player has *something* towards any players goal in each "sphere" @@ -33,26 +27,6 @@ A Link to the Past: open: 60 inverted: 10 null: 10 # Maintain individual world states - tower_open: - '0': 8 - '1': 7 - '2': 6 - '3': 5 - '4': 4 - '5': 3 - '6': 2 - '7': 1 - random: 10 # A different GT open time should not usually result in a vastly different completion time, unless ganon goal and tower_open > ganon_open - ganon_open: - '0': 3 - '1': 4 - '2': 5 - '3': 6 - '4': 7 - '5': 8 - '6': 9 - '7': 10 - random: 5 # This will mean differing completion times. But leaving it for that surprise effect triforce_pieces_mode: #Determine how to calculate the extra available triforce pieces. extra: 0 # available = triforce_pieces_extra + triforce_pieces_required percentage: 0 # available = (triforce_pieces_percentage /100) * triforce_pieces_required diff --git a/playerSettings.yaml b/playerSettings.yaml index d1e7e430..e41acaf0 100644 --- a/playerSettings.yaml +++ b/playerSettings.yaml @@ -24,14 +24,8 @@ name: YourName{number} # Your name in-game. Spaces will be replaced with undersc #{number} will be replaced with the counter value of the name. #{NUMBER} will be replaced with the counter value of the name if the counter value is greater than 1. game: # Pick a game to play - A Link to the Past: 0 - Factorio: 0 - Minecraft: 0 - Subnautica: 0 - Slay the Spire: 0 - Ocarina of Time: 0 - Super Metroid: 0 - + A Link to the Past: 1 + requires: version: 0.1.7 # Version of Archipelago required for this yaml to work as expected. # Shared Options supported by all games: @@ -59,493 +53,6 @@ progression_balancing: # exclude_locations: # Force certain locations to never contain progression items, and always be filled with junk. # - "Master Sword Pedestal" -Super Metroid: # see https://randommetroidsolver.pythonanywhere.com/randomizer advanced tab for detailed info on each option -# start_inventory: # Begin the file with the listed items/upgrades -# Screw Attack: 1 -# Bomb: 1 -# Speed Booster: 1 -# Grappling Beam: 1 -# Space Jump: 1 -# Hi-Jump Boots: 1 -# Spring Ball: 1 -# Charge Beam: 1 -# Ice Beam: 1 -# Spazer: 1 -# Reserve Tank: 4 -# Missile: 46 -# Super Missile: 20 -# Power Bomb: 20 -# Energy Tank: 14 -# Morph Ball: 1 -# X-Ray Scope: 1 -# Wave Beam: 1 -# Plasma Beam: 1 -# Varia Suit: 1 -# Gravity Suit: 1 - start_inventory_removes_from_pool: - on: 0 - off: 1 - death_link: - on: 0 - off: 1 - preset: # choose one of the preset or specify "custom" to use customPreset option - newbie: 0 - casual: 0 - regular: 1 - veteran: 0 - expert: 0 - master: 0 - samus: 0 - Season_Races: 0 - SMRAT2021: 0 - solution: 0 - custom: 0 # see https://randommetroidsolver.pythonanywhere.com/presets for detailed info on each preset settings - varia_custom: 0 # use an entry from the preset list on https://randommetroidsolver.pythonanywhere.com/presets - varia_custom_preset: # use an entry from the preset list on https://randommetroidsolver.pythonanywhere.com/presets - regular - start_location: - Ceres: 0 - Landing_Site: 1 - Gauntlet_Top: 0 - Green_Brinstar_Elevator: 0 - Big_Pink: 0 - Etecoons_Supers: 0 - Wrecked_Ship_Main: 0 - Firefleas_Top: 0 - Business_Center: 0 - Bubble_Mountain: 0 - Mama_Turtle: 0 - Watering_Hole: 0 - Aqueduct: 0 - Red_Brinstar_Elevator: 0 - Golden_Four: 0 - max_difficulty: - easy: 0 - medium: 0 - hard: 0 - harder: 0 - hardcore: 1 - mania: 0 - infinity: 0 - morph_placement: - early: 1 - normal: 0 - suits_restriction: - on: 1 - off: 0 - strict_minors: - on: 0 - off: 1 - missile_qty: 30 # a range between 10 and 90 that is divided by 10 as a float - super_qty: 20 # a range between 10 and 90 that is divided by 10 as a float - power_bomb_qty: 10 # a range between 10 and 90 that is divided by 10 as a float - minor_qty: 100 # a range between 7 (minimum to beat the game) and 100 - energy_qty: - ultra_sparse: 0 - sparse: 0 - medium: 0 - vanilla: 1 - area_randomization: - on: 0 - light: 0 - off: 1 - area_layout: - on: 0 - off: 1 - doors_colors_rando: - on: 0 - off: 1 - allow_grey_doors: - on: 0 - off: 1 - boss_randomization: - on: 0 - off: 1 - fun_combat: - on: 0 - off: 1 - fun_movement: - on: 0 - off: 1 - fun_suits: - on: 0 - off: 1 - layout_patches: - on: 1 - off: 0 - varia_tweaks: - on: 0 - off: 1 - nerfed_charge: - on: 0 - off: 1 - gravity_behaviour: - Vanilla: 0 - Balanced: 1 - Progressive: 0 - elevators_doors_speed: - on: 1 - off: 0 - spin_jump_restart: - on: 0 - off: 1 - infinite_space_jump: - on: 0 - off: 1 - refill_before_save: - on: 0 - off: 1 - hud: - on: 0 - off: 1 - animals: - on: 0 - off: 1 - no_music: - on: 0 - off: 1 - random_music: - on: 0 - off: 1 - #item_sounds: always forced on due to a conflict in patching - #majors_split: not supported always "Full" - #scav_num_locs: not supported always off - #scav_randomized: not supported always off - #scav_escape: not supported always off - #progression_speed: not supported always random - #progression_difficulty: not supported always random - #hide_items: not supported always off - #minimizer: not supported always off - #minimizer_qty: not supported always off - #minimizer_tourian: not supported always off - #escape_rando: not supported always off - #remove_escape_enemies: not supported always off - #rando_speed: not supported always off - custom_preset: # see https://randommetroidsolver.pythonanywhere.com/presets for detailed info on each preset settings - Knows: # each skill (know) has a pair [can use, perceived difficulty using one of the following values] - # easy = 1 - # medium = 5 - # hard = 10 - # harder = 25 - # hardcore = 50 - # mania = 100 - Mockball: [True, 1] - SimpleShortCharge: [True, 1] - InfiniteBombJump: [True, 5] - GreenGateGlitch: [True, 5] - ShortCharge: [False, 0] - GravityJump: [True, 10] - SpringBallJump: [True, 10] - SpringBallJumpFromWall: [False, 0] - GetAroundWallJump: [True, 10] - DraygonGrappleKill: [True, 5] - DraygonSparkKill: [False, 0] - MicrowaveDraygon: [True, 1] - MicrowavePhantoon: [True, 5] - IceZebSkip: [False, 0] - SpeedZebSkip: [False, 0] - HiJumpMamaTurtle: [False, 0] - GravLessLevel1: [True, 50] - GravLessLevel2: [False, 0] - GravLessLevel3: [False, 0] - CeilingDBoost: [True, 1] - BillyMays: [True, 1] - AlcatrazEscape: [True, 25] - ReverseGateGlitch: [True, 5] - ReverseGateGlitchHiJumpLess: [False, 0] - EarlyKraid: [True, 1] - XrayDboost: [False, 0] - XrayIce: [True, 10] - RedTowerClimb: [True, 25] - RonPopeilScrew: [False, 0] - OldMBWithSpeed: [False, 0] - Moondance: [False, 0] - HiJumpLessGauntletAccess: [True, 50] - HiJumpGauntletAccess: [True, 25] - LowGauntlet: [False, 0] - IceEscape: [False, 0] - WallJumpCathedralExit: [True, 5] - BubbleMountainWallJump: [True, 5] - NovaBoost: [False, 0] - NorfairReserveDBoost: [False, 0] - CrocPBsDBoost: [False, 0] - CrocPBsIce: [False, 0] - IceMissileFromCroc: [False, 0] - FrogSpeedwayWithoutSpeed: [False, 0] - LavaDive: [True, 50] - LavaDiveNoHiJump: [False, 0] - WorstRoomIceCharge: [False, 0] - ScrewAttackExit: [False, 0] - ScrewAttackExitWithoutScrew: [False, 0] - FirefleasWalljump: [True, 25] - ContinuousWallJump: [False, 0] - DiagonalBombJump: [False, 0] - MockballWs: [False, 0] - SpongeBathBombJump: [False, 0] - SpongeBathHiJump: [True, 1] - SpongeBathSpeed: [True, 5] - TediousMountEverest: [False, 0] - DoubleSpringBallJump: [False, 0] - BotwoonToDraygonWithIce: [False, 0] - DraygonRoomGrappleExit: [False, 0] - DraygonRoomCrystalFlash: [False, 0] - PreciousRoomXRayExit: [False, 0] - MochtroidClip: [True, 5] - PuyoClip: [False, 0] - PuyoClipXRay: [False, 0] - SnailClip: [False, 0] - SuitlessPuyoClip: [False, 0] - KillPlasmaPiratesWithSpark: [False, 0] - KillPlasmaPiratesWithCharge: [True, 5] - AccessSpringBallWithHiJump: [True, 1] - AccessSpringBallWithSpringBallBombJumps: [True, 10] - AccessSpringBallWithBombJumps: [False, 0] - AccessSpringBallWithSpringBallJump: [False, 0] - AccessSpringBallWithXRayClimb: [False, 0] - AccessSpringBallWithGravJump: [False, 0] - Controller: - A: Jump - B: Dash - X: Shoot - Y: Item Cancel - L: Angle Down - R: Angle Up - Select: Item Select - Moonwalk: False - Settings: - Ice: "Gimme energy" - MainUpperNorfair: "Gimme energy" - LowerNorfair: "Default" - Kraid: "Default" - Phantoon: "Default" - Draygon: "Default" - Ridley: "Default" - MotherBrain: "Default" - X-Ray: "I don't like spikes" - Gauntlet: "I don't like acid" -Subnautica: {} -Slay the Spire: - character: # Pick What Character you wish to play with. - ironclad: 50 - silent: 50 - defect: 50 - watcher: 50 - ascension: # What Ascension do you wish to play with. - # you can add additional values between minimum and maximum - 0: 50 # minimum value - 20: 0 # maximum value - random: 0 - random-low: 0 - random-high: 0 - heart_run: # Whether or not you will need to collect they 3 keys to unlock the final act - # and beat the heart to finish the game. - false: 50 - true: 0 -Factorio: - tech_tree_layout: - single: 1 - small_diamonds: 1 - medium_diamonds: 1 - large_diamonds: 1 - small_pyramids: 1 - medium_pyramids: 1 - large_pyramids: 1 - small_funnels: 1 - medium_funnels: 1 - large_funnels: 1 - recipe_time: # randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc. - vanilla: 1 - fast: 0 # 25% to 100% of original time - normal: 0 # 50 % to 200% of original time - slow: 0 # 100% to 400% of original time - chaos: 0 # 25% to 400% of original time - recipe_ingredients: - rocket: 1 # only randomize rocket part recipe - science_pack: 1 # also randomize science pack ingredients - max_science_pack: - automation_science_pack: 0 - logistic_science_pack: 0 - military_science_pack: 0 - chemical_science_pack: 0 - production_science_pack: 0 - utility_science_pack: 0 - space_science_pack: 1 - tech_cost: - very_easy : 0 - easy : 0 - kind : 0 - normal : 1 - hard : 0 - very_hard : 0 - insane : 0 - silo: - vanilla: 1 - randomize_recipe: 0 - spawn: 0 # spawn silo near player spawn point - free_samples: - none: 1 - single_craft: 0 - half_stack: 0 - stack: 0 - progressive: - on: 1 - off: 0 - grouped_random: 0 - tech_tree_information: - none: 0 - advancement: 0 # show which items are a logical advancement - full: 1 # show full info on each tech node - imported_blueprints: # can be turned off to prevent access to blueprints created outside the current world - on: 1 - off: 0 - starting_items: - burner-mining-drill: 19 - stone-furnace: 19 - # Note: Total amount of traps cannot exceed 4, if the sum of them is higher it will get automatically capped. - evolution_traps: - # Trap items that when received increase the enemy evolution. - 0: 1 - 1: 0 - 2: 0 - 3: 0 - 4: 0 - random: 0 - random-low: 0 - random-middle: 0 - random-high: 0 - evolution_trap_increase: - # If present, % increase of Evolution with each trap received. - 5: 0 - 10: 1 - 15: 0 - 20: 0 - 100: 0 - random: 0 - random-low: 0 - random-middle: 0 - random-high: 0 - attack_traps: - # Trap items that when received trigger an attack on your base. - 0: 1 - 1: 0 - 2: 0 - 3: 0 - 4: 0 - random: 0 - random-low: 0 - random-middle: 0 - random-high: 0 - world_gen: - # frequency, size, richness, terrain segmentation, starting area and water are all of https://wiki.factorio.com/Types/MapGenSize - # inverse of water scale - terrain_segmentation: 0.5 - water: 1.5 - autoplace_controls: - # set size to 0 to disable - coal: - frequency: 1 - size: 3 - richness: 6 - copper-ore: - frequency: 1 - size: 3 - richness: 6 - crude-oil: - frequency: 1 - size: 3 - richness: 6 - enemy-base: - frequency: 1 - size: 1 - richness: 1 - iron-ore: - frequency: 1 - size: 3 - richness: 6 - stone: - frequency: 1 - size: 3 - richness: 6 - trees: - frequency: 1 - size: 1 - richness: 1 - uranium-ore: - frequency: 1 - size: 3 - richness: 6 - seed: null # turn into positive number to create specific seed - starting_area: 1 - peaceful_mode: 0 - cliff_settings: - name: cliff - cliff_elevation_0: 10 # base elevation, can't be changed in GUI - cliff_elevation_interval: 40 # 40/frequency - richness: 1 # 0: off, >0: continuity - property_expression_names: - "control-setting:moisture:bias": 0 # grass bias -0.5 to +0.5 - "control-setting:moisture:frequency:multiplier": 1 # 1/scale in GUI - "control-setting:aux:bias": 0 # -sand/+red desert bias -0.5 to +0.5 - "control-setting:aux:frequency:multiplier": 1 # 1/scale in GUI - pollution: - enabled: true - diffusion_ratio: 0.02 - ageing: 1 # GUI dissipation factor - enemy_attack_pollution_consumption_modifier: 1 - min_pollution_to_damage_trees: 60 - pollution_restored_per_tree_damage: 10 - enemy_evolution: - enabled: true - time_factor: 40.0e-7 # GUI value * 0.0000001 - destroy_factor: 200.0e-5 # GUI value * 0.00001 - pollution_factor: 9.0e-7 # GUI value * 0.0000001 - enemy_expansion: - enabled: true - max_expansion_distance: 7 - settler_group_min_size: 5 - settler_group_max_size: 20 - min_expansion_cooldown: 14400 # 1 to 60 min in ticks - max_expansion_cooldown: 216000 # 5 to 180 min in ticks -Minecraft: - advancement_goal: 50 # Number of advancements required (87 max) to spawn the Ender Dragon and complete the game. - egg_shards_required: # Number of dragon egg shards to collect (30 max) before the Ender Dragon will spawn. - 0: 1 - 5: 0 - 10: 0 - 20: 0 - egg_shards_available: # Number of egg shards available in the pool (30 max). - 0: 1 - 5: 0 - 10: 0 - 20: 0 - combat_difficulty: # Modifies the level of items logically required for exploring dangerous areas and fighting bosses. - easy: 0 - normal: 1 - hard: 0 - include_hard_advancements: # Junk-fills certain RNG-reliant or tedious advancements. - on: 0 - off: 1 - include_insane_advancements: # Junk-fills extremely difficult advancements; this is only How Did We Get Here? and Adventuring Time. - on: 0 - off: 1 - include_postgame_advancements: # Some advancements require defeating the Ender Dragon; this will junk-fill them so you won't have to finish to send some items. - on: 0 - off: 1 - shuffle_structures: # Enables shuffling of villages, outposts, fortresses, bastions, and end cities. - on: 0 - off: 1 - structure_compasses: # Adds structure compasses to the item pool, which point to the nearest indicated structure. - on: 0 - off: 1 - bee_traps: # Replaces a percentage of junk items with bee traps, which spawn multiple angered bees around every player when received. - 0: 1 - 25: 0 - 50: 0 - 75: 0 - 100: 0 - send_defeated_mobs: # Send killed mobs to other Minecraft worlds which have this option enabled. - on: 0 - off: 1 A Link to the Past: ### Logic Section ### glitches_required: # Determine the logic required to complete the seed @@ -949,657 +456,6 @@ A Link to the Past: vanilla: 0 # Swords are placed in vanilla locations in your own game (Uncle, Pyramid Fairy, Smiths, Pedestal) swordless: 0 # swordless mode -Ocarina of Time: - logic_rules: # Set the logic used for the generator. - glitchless: 50 - glitched: 0 - no_logic: 0 - logic_no_night_tokens_without_suns_song: # Nighttime skulltulas will logically require Sun's Song. - false: 50 - true: 0 - open_forest: # Set the state of Kokiri Forest and the path to Deku Tree. - open: 50 - closed_deku: 0 - closed: 0 - open_kakariko: # Set the state of the Kakariko Village gate. - open: 50 - zelda: 0 - closed: 0 - open_door_of_time: # Open the Door of Time by default, without the Song of Time. - false: 0 - true: 50 - zora_fountain: # Set the state of King Zora, blocking the way to Zora's Fountain. - open: 0 - adult: 0 - closed: 50 - gerudo_fortress: # Set the requirements for access to Gerudo Fortress. - normal: 0 - fast: 50 - open: 0 - bridge: # Set the requirements for the Rainbow Bridge. - open: 0 - vanilla: 0 - stones: 0 - medallions: 50 - dungeons: 0 - tokens: 0 - trials: # Set the number of required trials in Ganon's Castle. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 6: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - starting_age: # Choose which age Link will start as. - child: 50 - adult: 0 - triforce_hunt: # Gather pieces of the Triforce scattered around the world to complete the game. - false: 50 - true: 0 - triforce_goal: # Number of Triforce pieces required to complete the game. - # you can add additional values between minimum and maximum - 1: 0 # minimum value - 50: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - extra_triforce_percentage: # Percentage of additional Triforce pieces in the pool, separate from the item pool setting. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 100: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - bombchus_in_logic: # Bombchus are properly considered in logic. The first found pack will have 20 chus; Kokiri Shop and Bazaar sell refills; bombchus open Bombchu Bowling. - false: 50 - true: 0 - bridge_stones: # Set the number of Spiritual Stones required for the rainbow bridge. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 3: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - bridge_medallions: # Set the number of medallions required for the rainbow bridge. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 6: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - bridge_rewards: # Set the number of dungeon rewards required for the rainbow bridge. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 9: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - bridge_tokens: # Set the number of Gold Skulltula Tokens required for the rainbow bridge. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 100: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - shuffle_mapcompass: # Control where to shuffle dungeon maps and compasses. - remove: 0 - startwith: 50 - vanilla: 0 - dungeon: 0 - overworld: 0 - any_dungeon: 0 - keysanity: 0 - shuffle_smallkeys: # Control where to shuffle dungeon small keys. - remove: 0 - vanilla: 0 - dungeon: 50 - overworld: 0 - any_dungeon: 0 - keysanity: 0 - shuffle_fortresskeys: # Control where to shuffle the Gerudo Fortress small keys. - vanilla: 50 - overworld: 0 - any_dungeon: 0 - keysanity: 0 - shuffle_bosskeys: # Control where to shuffle boss keys, except the Ganon's Castle Boss Key. - remove: 0 - vanilla: 0 - dungeon: 50 - overworld: 0 - any_dungeon: 0 - keysanity: 0 - shuffle_ganon_bosskey: # Control where to shuffle the Ganon's Castle Boss Key. - remove: 50 - vanilla: 0 - dungeon: 0 - overworld: 0 - any_dungeon: 0 - keysanity: 0 - on_lacs: 0 - enhance_map_compass: # Map tells if a dungeon is vanilla or MQ. Compass tells what the dungeon reward is. - false: 50 - true: 0 - lacs_condition: # Set the requirements for the Light Arrow Cutscene in the Temple of Time. - vanilla: 50 - stones: 0 - medallions: 0 - dungeons: 0 - tokens: 0 - lacs_stones: # Set the number of Spiritual Stones required for LACS. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 3: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - lacs_medallions: # Set the number of medallions required for LACS. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 6: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - lacs_rewards: # Set the number of dungeon rewards required for LACS. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 9: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - lacs_tokens: # Set the number of Gold Skulltula Tokens required for LACS. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 100: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - shuffle_song_items: # Set where songs can appear. - song: 50 - dungeon: 0 - any: 0 - shopsanity: # Randomizes shop contents. "fixed_number" randomizes a specific number of items per shop; "random_number" randomizes the value for each shop. - off: 50 - fixed_number: 0 - random_number: 0 - shop_slots: # Number of items per shop to be randomized into the main itempool. Only active if Shopsanity is set to "fixed_number." - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 4: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - tokensanity: # Token rewards from Gold Skulltulas are shuffled into the pool. - off: 50 - dungeons: 0 - overworld: 0 - all: 0 - shuffle_scrubs: # Shuffle the items sold by Business Scrubs, and set the prices. - off: 50 - low: 0 - affordable: 0 - expensive: 0 - shuffle_cows: # Cows give items when Epona's Song is played. - false: 50 - true: 0 - shuffle_kokiri_sword: # Shuffle Kokiri Sword into the item pool. - false: 50 - true: 0 - shuffle_ocarinas: # Shuffle the Fairy Ocarina and Ocarina of Time into the item pool. - false: 50 - true: 0 - shuffle_weird_egg: # Shuffle the Weird Egg from Malon at Hyrule Castle. - false: 50 - true: 0 - shuffle_gerudo_card: # Shuffle the Gerudo Membership Card into the item pool. - false: 50 - true: 0 - shuffle_beans: # Adds a pack of 10 beans to the item pool and changes the bean salesman to sell one item for 60 rupees. - false: 50 - true: 0 - shuffle_medigoron_carpet_salesman: # Shuffle the items sold by Medigoron and the Haunted Wasteland Carpet Salesman. - false: 50 - true: 0 - skip_child_zelda: # Game starts with Zelda's Letter, the item at Zelda's Lullaby, and the relevant events already completed. - false: 50 - true: 0 - no_escape_sequence: # Skips the tower collapse sequence between the Ganondorf and Ganon fights. - false: 0 - true: 50 - no_guard_stealth: # The crawlspace into Hyrule Castle skips straight to Zelda. - false: 0 - true: 50 - no_epona_race: # Epona can always be summoned with Epona's Song. - false: 0 - true: 50 - skip_some_minigame_phases: # Dampe Race and Horseback Archery give both rewards if the second condition is met on the first attempt. - false: 0 - true: 50 - complete_mask_quest: # All masks are immediately available to borrow from the Happy Mask Shop. - false: 50 - true: 0 - useful_cutscenes: # Reenables the Poe cutscene in Forest Temple, Darunia in Fire Temple, and Twinrova introduction. Mostly useful for glitched. - false: 50 - true: 0 - fast_chests: # All chest animations are fast. If disabled, major items have a slow animation. - false: 0 - true: 50 - free_scarecrow: # Pulling out the ocarina near a scarecrow spot spawns Pierre without needing the song. - false: 50 - true: 0 - fast_bunny_hood: # Bunny Hood lets you move 1.5x faster like in Majora's Mask. - false: 50 - true: 0 - chicken_count: # Controls the number of Cuccos for Anju to give an item as child. - # you can add additional values between minimum and maximum - 0: 0 # minimum value - 7: 0 # maximum value - random: 50 - random-low: 0 - random-high: 0 - correct_chest_sizes: # Changes chests containing progression into large chests, and nonprogression into small chests. - false: 50 - true: 0 - hints: # Gossip Stones can give hints about item locations. - none: 0 - mask: 0 - agony: 0 - always: 50 - false: 0 - hint_dist: # Choose the hint distribution to use. Affects the frequency of strong hints, which items are always hinted, etc. - balanced: 50 - ddr: 0 - league: 0 - mw2: 0 - scrubs: 0 - strong: 0 - tournament: 0 - useless: 0 - very_strong: 0 - text_shuffle: # Randomizes text in the game for comedic effect. - none: 50 - except_hints: 0 - complete: 0 - damage_multiplier: # Controls the amount of damage Link takes. - half: 0 - normal: 50 - double: 0 - quadruple: 0 - ohko: 0 - no_collectible_hearts: # Hearts will not drop from enemies or objects. - false: 50 - true: 0 - starting_tod: # Change the starting time of day. - default: 50 - sunrise: 0 - morning: 0 - noon: 0 - afternoon: 0 - sunset: 0 - evening: 0 - midnight: 0 - witching_hour: 0 - start_with_consumables: # Start the game with full Deku Sticks and Deku Nuts. - false: 50 - true: 0 - start_with_rupees: # Start with a full wallet. Wallet upgrades will also fill your wallet. - false: 50 - true: 0 - item_pool_value: # Changes the number of items available in the game. - plentiful: 0 - balanced: 50 - scarce: 0 - minimal: 0 - junk_ice_traps: # Adds ice traps to the item pool. - off: 0 - normal: 50 - extra: 0 - mayhem: 0 - onslaught: 0 - ice_trap_appearance: # Changes the appearance of ice traps as freestanding items. - major_only: 50 - junk_only: 0 - anything: 0 - logic_earliest_adult_trade: # Earliest item that can appear in the adult trade sequence. - pocket_egg: 0 - pocket_cucco: 0 - cojiro: 0 - odd_mushroom: 0 - poachers_saw: 0 - broken_sword: 0 - prescription: 50 - eyeball_frog: 0 - eyedrops: 0 - claim_check: 0 - logic_latest_adult_trade: # Latest item that can appear in the adult trade sequence. - pocket_egg: 0 - pocket_cucco: 0 - cojiro: 0 - odd_mushroom: 0 - poachers_saw: 0 - broken_sword: 0 - prescription: 0 - eyeball_frog: 0 - eyedrops: 0 - claim_check: 50 - default_targeting: # Default targeting option. - hold: 50 - switch: 0 - display_dpad: # Show dpad icon on HUD for quick actions (ocarina, hover boots, iron boots). - false: 0 - true: 50 - correct_model_colors: # Makes in-game models match their HUD element colors. - false: 0 - true: 50 - background_music: # Randomize or disable background music. - normal: 50 - off: 0 - randomized: 0 - fanfares: # Randomize or disable item fanfares. - normal: 50 - off: 0 - randomized: 0 - ocarina_fanfares: # Enable ocarina songs as fanfares. These are longer than usual fanfares. Does nothing without fanfares randomized. - false: 50 - true: 0 - kokiri_color: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - kokiri_green: 50 - goron_red: 0 - zora_blue: 0 - black: 0 - white: 0 - azure_blue: 0 - vivid_cyan: 0 - light_red: 0 - fuchsia: 0 - purple: 0 - majora_purple: 0 - twitch_purple: 0 - purple_heart: 0 - persian_rose: 0 - dirty_yellow: 0 - blush_pink: 0 - hot_pink: 0 - rose_pink: 0 - orange: 0 - gray: 0 - gold: 0 - silver: 0 - beige: 0 - teal: 0 - blood_red: 0 - blood_orange: 0 - royal_blue: 0 - sonic_blue: 0 - nes_green: 0 - dark_green: 0 - lumen: 0 - goron_color: # Choose a color. Uses the same options as "kokiri_color". - random_choice: 0 - completely_random: 0 - goron_red: 50 - zora_color: # Choose a color. Uses the same options as "kokiri_color". - random_choice: 0 - completely_random: 0 - zora_blue: 50 - silver_gauntlets_color: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - silver: 50 - gold: 0 - black: 0 - green: 0 - blue: 0 - bronze: 0 - red: 0 - sky_blue: 0 - pink: 0 - magenta: 0 - orange: 0 - lime: 0 - purple: 0 - golden_gauntlets_color: # Choose a color. Uses the same options as "silver_gauntlets_color". - random_choice: 0 - completely_random: 0 - gold: 50 - mirror_shield_frame_color: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - red: 50 - green: 0 - blue: 0 - yellow: 0 - cyan: 0 - magenta: 0 - orange: 0 - gold: 0 - purple: 0 - pink: 0 - navi_color_default_inner: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - rainbow: 0 - gold: 0 - white: 50 - green: 0 - light_blue: 0 - yellow: 0 - red: 0 - magenta: 0 - black: 0 - tatl: 0 - tael: 0 - fi: 0 - ciela: 0 - epona: 0 - ezlo: 0 - king_of_red_lions: 0 - linebeck: 0 - loftwing: 0 - midna: 0 - phantom_zelda: 0 - navi_color_default_outer: # Choose a color. Uses the same options as "navi_color_default_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - navi_color_enemy_inner: # Choose a color. Uses the same options as "navi_color_default_inner". - random_choice: 0 - completely_random: 0 - yellow: 50 - navi_color_enemy_outer: # Choose a color. Uses the same options as "navi_color_default_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - navi_color_npc_inner: # Choose a color. Uses the same options as "navi_color_default_inner". - random_choice: 0 - completely_random: 0 - light_blue: 50 - navi_color_npc_outer: # Choose a color. Uses the same options as "navi_color_default_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - navi_color_prop_inner: # Choose a color. Uses the same options as "navi_color_default_inner". - random_choice: 0 - completely_random: 0 - green: 50 - navi_color_prop_outer: # Choose a color. Uses the same options as "navi_color_default_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - sword_trail_duration: # Set the duration for sword trails. - # you can add additional values between minimum and maximum - 4: 50 # minimum value - 20: 0 # maximum value - random: 0 - random-low: 0 - random-high: 0 - sword_trail_color_inner: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - rainbow: 0 - white: 50 - red: 0 - green: 0 - blue: 0 - cyan: 0 - magenta: 0 - orange: 0 - gold: 0 - purple: 0 - pink: 0 - sword_trail_color_outer: # Uses the same options as "sword_trail_color_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - bombchu_trail_color_inner: # Uses the same options as "sword_trail_color_inner". - random_choice: 0 - completely_random: 0 - red: 50 - bombchu_trail_color_outer: # Uses the same options as "sword_trail_color_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - boomerang_trail_color_inner: # Uses the same options as "sword_trail_color_inner". - random_choice: 0 - completely_random: 0 - yellow: 50 - boomerang_trail_color_outer: # Uses the same options as "sword_trail_color_inner" plus "match_inner". - random_choice: 0 - completely_random: 0 - match_inner: 50 - heart_color: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - red: 50 - green: 0 - blue: 0 - yellow: 0 - magic_color: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - green: 50 - red: 0 - blue: 0 - purple: 0 - pink: 0 - yellow: 0 - white: 0 - a_button_color: # Choose a color. "random_choice" selects a random option. "completely_random" generates a random hex code. - random_choice: 0 - completely_random: 0 - n64_blue: 50 - n64_green: 0 - n64_red: 0 - gamecube_green: 0 - gamecube_red: 0 - gamecube_grey: 0 - yellow: 0 - black: 0 - white: 0 - magenta: 0 - ruby: 0 - sapphire: 0 - lime: 0 - cyan: 0 - purple: 0 - orange: 0 - b_button_color: # Choose a color. Uses the same options as "a_button_color". - random_choice: 0 - completely_random: 0 - n64_green: 50 - c_button_color: # Choose a color. Uses the same options as "a_button_color". - random_choice: 0 - completely_random: 0 - yellow: 50 - start_button_color: # Choose a color. Uses the same options as "a_button_color". - random_choice: 0 - completely_random: 0 - n64_red: 50 - sfx_navi_overworld: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_navi_enemy: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_low_hp: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_menu_cursor: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_menu_select: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_nightfall: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_horse_neigh: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - none: 0 - sfx_hover_boots: # Choose a sound effect. "random_choice" selects a random option. "random_ear_safe" selects a random safe option. "completely_random" selects any random sound. - default: 50 - completely_random: 0 - random_ear_safe: 0 - random_choice: 0 - sfx_ocarina: # Change the sound of the ocarina. - ocarina: 50 - malon: 0 - whistle: 0 - harp: 0 - grind_organ: 0 - flute: 0 - - # Uncomment this section to enable logical tricks for Ocarina of Time. - # Add logic tricks keyed by "nice" name rather than internal name: "Hidden Grottos without Stone of Agony", not "logic_grottos_without_agony" - # The following is the typical set of racing tricks, though you can add or remove them as desired. - # logic_tricks: - # - Fewer Tunic Requirements - # - Hidden Grottos without Stone of Agony - # - Child Deadhand without Kokiri Sword - # - Man on Roof without Hookshot - # - Dodongo's Cavern Spike Trap Room Jump without Hover Boots - # - Hammer Rusted Switches Through Walls - # - Windmill PoH as Adult with Nothing - # - Crater's Bean PoH with Hover Boots - # - Forest Temple East Courtyard Vines with Hookshot - # - Bottom of the Well without Lens of Truth - # - Ganon's Castle without Lens of Truth - # - Gerudo Training Grounds without Lens of Truth - # - Shadow Temple before Invisible Moving Platform without Lens of Truth - # - Shadow Temple beyond Invisible Moving Platform without Lens of Truth - # - Spirit Temple without Lens of Truth - -# meta_ignore, linked_options and triggers work for any game -meta_ignore: # Nullify options specified in the meta.yaml file. Adding an option here guarantees it will not occur in your seed, even if the .yaml file specifies it - mode: - - inverted # Never play inverted seeds - retro: - - on # Never play retro seeds - swordless: - - on # Never play a swordless seed - linked_options: - name: crosskeys options: # These overwrite earlier options if the percentage chance triggers diff --git a/worlds/__init__.py b/worlds/__init__.py index f193a9f8..4ab5b473 100644 --- a/worlds/__init__.py +++ b/worlds/__init__.py @@ -3,7 +3,8 @@ import os __all__ = {"lookup_any_item_id_to_name", "lookup_any_location_id_to_name", - "network_data_package"} + "network_data_package", + "AutoWorldRegister"} # import all submodules to trigger AutoWorldRegister for file in os.scandir(os.path.dirname(__file__)): diff --git a/worlds/alttp/Text.py b/worlds/alttp/Text.py index 920913d7..2449f2a7 100644 --- a/worlds/alttp/Text.py +++ b/worlds/alttp/Text.py @@ -284,7 +284,7 @@ junk_texts = [ "{C:GREEN}\nThere’s always\nmoney in the\nBanana Stand>", "{C:GREEN}\n \nJust walk away\n >", "{C:GREEN}\neverybody is\nlooking for\nsomething >", - "{C:GREEN}\nSpring Ball\nare behind\nRidley >", + # "{C:GREEN}\nSpring Ball\nare behind\nRidley >", removed as people may assume it's a real hint "{C:GREEN}\nThe gnome asks\nyou to guess\nhis name. >", "{C:GREEN}\nI heard beans\non toast is a\ngreat meal. >", "{C:GREEN}\n> Sweetcorn\non pizza is a\ngreat choice.", diff --git a/worlds/factorio/Mod.py b/worlds/factorio/Mod.py index 484ed626..3a5dc569 100644 --- a/worlds/factorio/Mod.py +++ b/worlds/factorio/Mod.py @@ -47,10 +47,16 @@ recipe_time_scales = { Options.RecipeTime.option_vanilla: None } +recipe_time_ranges = { + Options.RecipeTime.option_new_fast: (0.25, 2), + Options.RecipeTime.option_new_normal: (0.25, 10), + Options.RecipeTime.option_slow: (5, 10) +} + def generate_mod(world, output_directory: str): player = world.player multiworld = world.world - global data_final_template, locale_template, control_template, data_template + global data_final_template, locale_template, control_template, data_template, settings_template with template_load_lock: if not data_final_template: mod_template_folder = os.path.join(os.path.dirname(__file__), "data", "mod_template") @@ -60,6 +66,7 @@ def generate_mod(world, output_directory: str): data_final_template = template_env.get_template("data-final-fixes.lua") locale_template = template_env.get_template(r"locale/en/locale.cfg") control_template = template_env.get_template("control.lua") + settings_template = template_env.get_template("settings.lua") # get data for templates player_names = {x: multiworld.player_name[x] for x in multiworld.player_ids} locations = [] @@ -91,11 +98,12 @@ def generate_mod(world, output_directory: str): "mod_name": mod_name, "allowed_science_packs": multiworld.max_science_pack[player].get_allowed_packs(), "tech_cost_scale": tech_cost_scale, "custom_technologies": multiworld.worlds[player].custom_technologies, "tech_tree_layout_prerequisites": multiworld.tech_tree_layout_prerequisites[player], - "slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name, + "slot_name": multiworld.player_name[player], "seed_name": multiworld.seed_name, "slot_player": player, "starting_items": multiworld.starting_items[player], "recipes": recipes, "random": random, "flop_random": flop_random, "static_nodes": multiworld.worlds[player].static_nodes, - "recipe_time_scale": recipe_time_scales[multiworld.recipe_time[player].value], + "recipe_time_scale": recipe_time_scales.get(multiworld.recipe_time[player].value, None), + "recipe_time_range": recipe_time_ranges.get(multiworld.recipe_time[player].value, None), "free_sample_blacklist": {item : 1 for item in free_sample_blacklist}, "progressive_technology_table": {tech.name : tech.progressive for tech in progressive_technology_table.values()}, @@ -107,10 +115,14 @@ def generate_mod(world, output_directory: str): if getattr(multiworld, "silo")[player].value == Options.Silo.option_randomize_recipe: template_data["free_sample_blacklist"]["rocket-silo"] = 1 + + if getattr(multiworld, "satellite")[player].value == Options.Satellite.option_randomize_recipe: + template_data["free_sample_blacklist"]["satellite"] = 1 control_code = control_template.render(**template_data) data_template_code = data_template.render(**template_data) data_final_fixes_code = data_final_template.render(**template_data) + settings_code = settings_template.render(**template_data) mod_dir = os.path.join(output_directory, mod_name + "_" + Utils.__version__) en_locale_dir = os.path.join(mod_dir, "locale", "en") @@ -122,6 +134,8 @@ def generate_mod(world, output_directory: str): f.write(data_final_fixes_code) with open(os.path.join(mod_dir, "control.lua"), "wt") as f: f.write(control_code) + with open(os.path.join(mod_dir, "settings.lua"), "wt") as f: + f.write(settings_code) locale_content = locale_template.render(**template_data) with open(os.path.join(en_locale_dir, "locale.cfg"), "wt") as f: f.write(locale_content) diff --git a/worlds/factorio/Options.py b/worlds/factorio/Options.py index 0c9e3cd8..abce73c5 100644 --- a/worlds/factorio/Options.py +++ b/worlds/factorio/Options.py @@ -55,6 +55,14 @@ class Silo(Choice): default = 0 +class Satellite(Choice): + """Ingredients to craft satellite.""" + displayname = "Satellite" + option_vanilla = 0 + option_randomize_recipe = 1 + default = 0 + + class FreeSamples(Choice): """Get free items with your technologies.""" displayname = "Free Samples" @@ -91,13 +99,25 @@ class TechTreeInformation(Choice): class RecipeTime(Choice): - """randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc.""" + """Randomize the time it takes for any recipe to craft, this includes smelting, chemical lab, hand crafting etc. + Fast: 0.25X - 1X + Normal: 0.5X - 2X + Slow: 1X - 4X + Chaos: 0.25X - 4X + New category: ignores vanilla recipe time and rolls new one + New Fast: 0.25 - 2 seconds + New Normal: 0.25 - 10 seconds + New Slow: 5 - 10 seconds + """ displayname = "Recipe Time" option_vanilla = 0 option_fast = 1 option_normal = 2 option_slow = 4 option_chaos = 5 + option_new_fast = 6 + option_new_normal = 7 + option_new_slow = 8 class Progressive(Choice): @@ -289,6 +309,7 @@ factorio_options: typing.Dict[str, type(Option)] = { "tech_tree_layout": TechTreeLayout, "tech_cost": TechCost, "silo": Silo, + "satellite": Satellite, "free_samples": FreeSamples, "tech_tree_information": TechTreeInformation, "starting_items": FactorioStartItems, diff --git a/worlds/factorio/Technologies.py b/worlds/factorio/Technologies.py index fbf283d3..89f50b84 100644 --- a/worlds/factorio/Technologies.py +++ b/worlds/factorio/Technologies.py @@ -59,8 +59,8 @@ class Technology(FactorioElement): # maybe make subclass of Location? def build_rule(self, player: int): logging.debug(f"Building rules for {self.name}") - return lambda state, technologies=technologies: all(state.has(f"Automated {ingredient}", player) - for ingredient in self.ingredients) + return lambda state: all(state.has(f"Automated {ingredient}", player) + for ingredient in self.ingredients) def get_prior_technologies(self) -> Set[Technology]: """Get Technologies that have to precede this one to resolve tree connections.""" @@ -300,19 +300,17 @@ for category_name, machine_name in machine_per_category.items(): required_technologies: Dict[str, FrozenSet[Technology]] = Utils.KeyedDefaultDict(lambda ingredient_name: frozenset( recursively_get_unlocking_technologies(ingredient_name, unlock_func=unlock))) -advancement_technologies: Set[str] = set() -for ingredient_name in all_ingredient_names: - technologies = required_technologies[ingredient_name] - advancement_technologies |= {technology.name for technology in technologies} - - -def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe) -> Set[str]: +def get_rocket_requirements(silo_recipe: Recipe, part_recipe: Recipe, satellite_recipe: Recipe) -> Set[str]: techs = set() if silo_recipe: for ingredient in silo_recipe.ingredients: techs |= recursively_get_unlocking_technologies(ingredient) for ingredient in part_recipe.ingredients: techs |= recursively_get_unlocking_technologies(ingredient) + if satellite_recipe: + techs |= satellite_recipe.unlocking_technologies + for ingredient in satellite_recipe.ingredients: + techs |= recursively_get_unlocking_technologies(ingredient) return {tech.name for tech in techs} @@ -335,8 +333,6 @@ rocket_recipes = { {"copper-cable": 10, "iron-plate": 10, "wood": 10} } -advancement_technologies |= {tech.name for tech in required_technologies["rocket-silo"]} - # progressive technologies # auto-progressive progressive_rows: Dict[str, Union[List[str], Tuple[str, ...]]] = {} @@ -430,8 +426,6 @@ for root in sorted_rows: unlocks=any(technology_table[tech].unlocks for tech in progressive)) progressive_tech_table[root] = progressive_technology.factorio_id progressive_technology_table[root] = progressive_technology - if any(tech in advancement_technologies for tech in progressive): - advancement_technologies.add(root) tech_to_progressive_lookup: Dict[str, str] = {} for technology in progressive_technology_table.values(): diff --git a/worlds/factorio/__init__.py b/worlds/factorio/__init__.py index 1834e4ff..e502ddb5 100644 --- a/worlds/factorio/__init__.py +++ b/worlds/factorio/__init__.py @@ -1,15 +1,16 @@ import collections +import typing from ..AutoWorld import World from BaseClasses import Region, Entrance, Location, Item -from .Technologies import base_tech_table, recipe_sources, base_technology_table, advancement_technologies, \ +from .Technologies import base_tech_table, recipe_sources, base_technology_table, \ all_ingredient_names, all_product_sources, required_technologies, get_rocket_requirements, rocket_recipes, \ progressive_technology_table, common_tech_table, tech_to_progressive_lookup, progressive_tech_table, \ get_science_pack_pools, Recipe, recipes, technology_table, tech_table, factorio_base_id, useless_technologies from .Shapes import get_shapes from .Mod import generate_mod -from .Options import factorio_options, Silo, TechTreeInformation +from .Options import factorio_options, MaxSciencePack, Silo, Satellite, TechTreeInformation import logging @@ -32,13 +33,17 @@ class Factorio(World): game: str = "Factorio" static_nodes = {"automation", "logistics", "rocket-silo"} custom_recipes = {} - additional_advancement_technologies = set() + advancement_technologies: typing.Set[str] item_name_to_id = all_items location_name_to_id = base_tech_table data_version = 5 + def __init__(self, world, player: int): + super(Factorio, self).__init__(world, player) + self.advancement_technologies = set() + def generate_basic(self): player = self.player want_progressives = collections.defaultdict(lambda: self.world.progressive[player]. @@ -137,11 +142,13 @@ class Factorio(World): locations=locations: all(state.can_reach(loc) for loc in locations)) silo_recipe = None if self.world.silo[self.player].value == Silo.option_spawn \ - else self.custom_recipes["rocket-silo"] \ - if "rocket-silo" in self.custom_recipes \ + else self.custom_recipes["rocket-silo"] if "rocket-silo" in self.custom_recipes \ else next(iter(all_product_sources.get("rocket-silo"))) part_recipe = self.custom_recipes["rocket-part"] - victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe) + satellite_recipe = None if self.world.max_science_pack[self.player].value != MaxSciencePack.option_space_science_pack \ + else self.custom_recipes["satellite"] if "satellite" in self.custom_recipes \ + else next(iter(all_product_sources.get("satellite"))) + victory_tech_names = get_rocket_requirements(silo_recipe, part_recipe, satellite_recipe) world.get_location("Rocket Launch", player).access_rule = lambda state: all(state.has(technology, player) for technology in victory_tech_names) @@ -189,12 +196,12 @@ class Factorio(World): fallback_pool = [] # fill all but one slot with random ingredients, last with a good match - while remaining_num_ingredients > 0 and len(pool) > 0: + while remaining_num_ingredients > 0 and pool: if remaining_num_ingredients == 1: max_raw = 1.1 * remaining_raw min_raw = 0.9 * remaining_raw max_energy = 1.1 * remaining_energy - min_energy = 1.1 * remaining_energy + min_energy = 0.9 * remaining_energy else: max_raw = remaining_raw * 0.75 min_raw = (remaining_raw - max_raw) / remaining_num_ingredients @@ -226,7 +233,7 @@ class Factorio(World): # fill failed slots with whatever we got pool = fallback_pool - while remaining_num_ingredients > 0 and len(pool) > 0: + while remaining_num_ingredients > 0 and pool: ingredient = pool.pop() if ingredient not in recipes: logging.warning(f"missing recipe for {ingredient}") @@ -264,8 +271,6 @@ class Factorio(World): {valid_pool[x]: 10 for x in range(3)}, original_rocket_part.products, original_rocket_part.energy)} - self.additional_advancement_technologies = {tech.name for tech in - self.custom_recipes["rocket-part"].recursive_unlocking_technologies} if self.world.recipe_ingredients[self.player]: valid_pool = [] @@ -278,31 +283,45 @@ class Factorio(World): for _ in original.ingredients: new_ingredients[valid_pool.pop()] = 1 new_recipe = Recipe(pack, original.category, new_ingredients, original.products, original.energy) - self.additional_advancement_technologies |= {tech.name for tech in - new_recipe.recursive_unlocking_technologies} self.custom_recipes[pack] = new_recipe - if self.world.silo[self.player].value == Silo.option_randomize_recipe: + if self.world.silo[self.player].value == Silo.option_randomize_recipe \ + or self.world.satellite[self.player].value == Satellite.option_randomize_recipe: valid_pool = [] for pack in sorted(self.world.max_science_pack[self.player].get_allowed_packs()): valid_pool += sorted(science_pack_pools[pack]) - new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool, - factor=(self.world.max_science_pack[self.player].value + 1) / 7) - self.additional_advancement_technologies |= {tech.name for tech in - new_recipe.recursive_unlocking_technologies} - self.custom_recipes["rocket-silo"] = new_recipe + + if self.world.silo[self.player].value == Silo.option_randomize_recipe: + new_recipe = self.make_balanced_recipe(recipes["rocket-silo"], valid_pool.copy(), + factor=(self.world.max_science_pack[self.player].value + 1) / 7) + self.custom_recipes["rocket-silo"] = new_recipe + + if self.world.satellite[self.player].value == Satellite.option_randomize_recipe: + new_recipe = self.make_balanced_recipe(recipes["satellite"], valid_pool, + factor=(self.world.max_science_pack[self.player].value + 1) / 7) + self.custom_recipes["satellite"] = new_recipe + + needed_recipes = self.world.max_science_pack[self.player].get_allowed_packs() | {"rocket-part"} + if self.world.silo[self.player] != Silo.option_spawn: + needed_recipes |= {"rocket-silo"} + if self.world.max_science_pack[self.player].value == MaxSciencePack.option_space_science_pack: + needed_recipes |= {"satellite"} + + for recipe in needed_recipes: + recipe = self.custom_recipes.get(recipe, recipes[recipe]) + self.advancement_technologies |= {tech.name for tech in recipe.unlocking_technologies} + self.advancement_technologies |= {tech.name for tech in recipe.recursive_unlocking_technologies} # handle marking progressive techs as advancement prog_add = set() - for tech in self.additional_advancement_technologies: + for tech in self.advancement_technologies: if tech in tech_to_progressive_lookup: prog_add.add(tech_to_progressive_lookup[tech]) - self.additional_advancement_technologies |= prog_add + self.advancement_technologies |= prog_add def create_item(self, name: str) -> Item: if name in tech_table: - return FactorioItem(name, name in advancement_technologies or - name in self.additional_advancement_technologies, + return FactorioItem(name, name in self.advancement_technologies, tech_table[name], self.player) item = FactorioItem(name, False, all_items[name], self.player) diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua index dd1bfe25..d53a0b9c 100644 --- a/worlds/factorio/data/mod_template/control.lua +++ b/worlds/factorio/data/mod_template/control.lua @@ -8,7 +8,14 @@ SLOT_NAME = "{{ slot_name }}" SEED_NAME = "{{ seed_name }}" FREE_SAMPLE_BLACKLIST = {{ dict_to_lua(free_sample_blacklist) }} TRAP_EVO_FACTOR = {{ evolution_trap_increase }} / 100 -DEATH_LINK = {{ death_link | int }} +MAX_SCIENCE_PACK = {{ max_science_pack }} +ARCHIPELAGO_DEATH_LINK_SETTING = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}" + +if settings.global[ARCHIPELAGO_DEATH_LINK_SETTING].value then + DEATH_LINK = 1 +else + DEATH_LINK = 0 +end CURRENTLY_DEATH_LOCK = 0 @@ -76,6 +83,27 @@ function on_force_destroyed(event) global.forcedata[event.force.name] = nil end +function on_runtime_mod_setting_changed(event) + local force + if event.player_index == nil then + force = game.forces.player + else + force = game.players[event.player_index].force + end + + if event.setting == ARCHIPELAGO_DEATH_LINK_SETTING then + if settings.global[ARCHIPELAGO_DEATH_LINK_SETTING].value then + DEATH_LINK = 1 + else + DEATH_LINK = 0 + end + if force ~= nil then + dumpInfo(force) + end + end +end +script.on_event(defines.events.on_runtime_mod_setting_changed, on_runtime_mod_setting_changed) + -- Initialize player data, either from them joining the game or them already being part of the game when the mod was -- added.` function on_player_created(event) @@ -107,8 +135,19 @@ end script.on_event(defines.events.on_player_removed, on_player_removed) function on_rocket_launched(event) - global.forcedata[event.rocket.force.name]['victory'] = 1 - dumpInfo(event.rocket.force) + if event.rocket and event.rocket.valid and global.forcedata[event.rocket.force.name]['victory'] == 0 then + if event.rocket.get_item_count("satellite") > 0 or MAX_SCIENCE_PACK < 6 then + global.forcedata[event.rocket.force.name]['victory'] = 1 + dumpInfo(event.rocket.force) + game.set_game_state + { + game_finished = true, + player_won = true, + can_continue = true, + victorious_force = event.rocket.force + } + end + end end script.on_event(defines.events.on_rocket_launched, on_rocket_launched) @@ -198,6 +237,10 @@ script.on_init(function() e.player_index = index on_player_created(e) end + + if remote.interfaces["silo_script"] then + remote.call("silo_script", "set_no_victory", true) + end end) -- hook into researches done @@ -366,18 +409,19 @@ function spawn_entity(surface, force, name, x, y, radius, randomize, avoid_ores) end -if DEATH_LINK == 1 then - script.on_event(defines.events.on_entity_died, function(event) - if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event - return - end +script.on_event(defines.events.on_entity_died, function(event) + if DEATH_LINK == 0 then + return + end + if CURRENTLY_DEATH_LOCK == 1 then -- don't re-trigger on same event + return + end - local force = event.entity.force - global.forcedata[force.name].death_link_tick = game.tick - dumpInfo(force) - kill_players(force) - end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}}) -end + local force = event.entity.force + global.forcedata[force.name].death_link_tick = game.tick + dumpInfo(force) + kill_players(force) +end, {LuaEntityDiedEventFilter = {["filter"] = "name", ["name"] = "character"}}) -- add / commands @@ -392,7 +436,8 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress local data_collection = { ["research_done"] = research_done, ["victory"] = chain_lookup(global, "forcedata", force.name, "victory"), - ["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick") + ["death_link_tick"] = chain_lookup(global, "forcedata", force.name, "death_link_tick"), + ["death_link"] = DEATH_LINK } for tech_name, tech in pairs(force.technologies) do @@ -423,8 +468,8 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi game.print({"", "Received [technology=" .. tech.name .. "] as it is already checked."}) game.play_sound({path="utility/research_completed"}) tech.researched = true - return end + return elseif progressive_technologies[item_name] ~= nil then if global.index_sync[index] == nil then -- not yet received prog item global.index_sync[index] = item_name @@ -442,9 +487,6 @@ commands.add_command("ap-get-technology", "Grant a technology, used by the Archi elseif force.technologies[item_name] ~= nil then tech = force.technologies[item_name] if tech ~= nil then - if global.index_sync[index] ~= nil and global.index_sync[index] ~= tech then - game.print("Warning: Desync Detected. Duplicate/Missing items may occur.") - end global.index_sync[index] = tech if tech.researched ~= true then game.print({"", "Received [technology=" .. tech.name .. "] from ", source}) diff --git a/worlds/factorio/data/mod_template/data-final-fixes.lua b/worlds/factorio/data/mod_template/data-final-fixes.lua index 4b2d67a2..be55d2dc 100644 --- a/worlds/factorio/data/mod_template/data-final-fixes.lua +++ b/worlds/factorio/data/mod_template/data-final-fixes.lua @@ -100,6 +100,20 @@ function adjust_energy(recipe_name, factor) end end +function set_energy(recipe_name, energy) + local recipe = data.raw.recipe[recipe_name] + + if (recipe.normal ~= nil) then + recipe.normal.energy_required = energy + end + if (recipe.expensive ~= nil) then + recipe.expensive.energy_required = energy + end + if (recipe.expensive == nil and recipe.normal == nil) then + recipe.energy_required = energy + end +end + data.raw["assembling-machine"]["assembling-machine-1"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories) data.raw["assembling-machine"]["assembling-machine-2"].crafting_categories = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-3"].crafting_categories) data.raw["assembling-machine"]["assembling-machine-1"].fluid_boxes = table.deepcopy(data.raw["assembling-machine"]["assembling-machine-2"].fluid_boxes) @@ -144,6 +158,12 @@ data:extend{new_tree_copy} adjust_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_scale) }}) {%- endif %} {%- endfor -%} +{% elif recipe_time_range %} +{%- for recipe_name, recipe in recipes.items() %} +{%- if recipe.category != "mining" %} +set_energy("{{ recipe_name }}", {{ flop_random(*recipe_time_range) }}) +{%- endif %} +{%- endfor -%} {% endif %} {%- if silo==2 %} diff --git a/worlds/factorio/data/mod_template/locale/en/locale.cfg b/worlds/factorio/data/mod_template/locale/en/locale.cfg index 25e9eb23..e970dbfa 100644 --- a/worlds/factorio/data/mod_template/locale/en/locale.cfg +++ b/worlds/factorio/data/mod_template/locale/en/locale.cfg @@ -22,4 +22,10 @@ ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends somet {%- else %} ap-{{ tech_table[original_tech_name] }}-=Researching this technology sends something to someone. For purposes of hints, this location is called "{{ original_tech_name }}". {%- endif -%} -{% endfor %} \ No newline at end of file +{% endfor %} + +[mod-setting-name] +archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Death Link + +[mod-setting-description] +archipelago-death-link-{{ slot_player }}-{{ seed_name }}=Kill other players in the same Archipelago Multiworld that also have Death Link turned on, when you die. \ No newline at end of file diff --git a/worlds/factorio/data/mod_template/settings.lua b/worlds/factorio/data/mod_template/settings.lua new file mode 100644 index 00000000..7703ebe2 --- /dev/null +++ b/worlds/factorio/data/mod_template/settings.lua @@ -0,0 +1,12 @@ +data:extend({ + { + type = "bool-setting", + name = "archipelago-death-link-{{ slot_player }}-{{ seed_name }}", + setting_type = "runtime-global", + {% if death_link %} + default_value = true + {% else %} + default_value = false + {% endif %} + } +}) \ No newline at end of file diff --git a/worlds/oot/__init__.py b/worlds/oot/__init__.py index fe4cd73a..66d08895 100644 --- a/worlds/oot/__init__.py +++ b/worlds/oot/__init__.py @@ -147,7 +147,7 @@ class OOTWorld(World): # Incompatible option handling # ER and glitched logic are not compatible; glitched takes priority if self.logic_rules == 'glitched': - self.shuffle_interior_entrances = False + self.shuffle_interior_entrances = 'off' self.shuffle_grotto_entrances = False self.shuffle_dungeon_entrances = False self.shuffle_overworld_entrances = False diff --git a/worlds/sm/Rom.py b/worlds/sm/Rom.py index 5d7ab709..3e191c2e 100644 --- a/worlds/sm/Rom.py +++ b/worlds/sm/Rom.py @@ -2,7 +2,7 @@ import Utils from Patch import read_rom JAP10HASH = '21f3e98df4780ee1c667b84e57d88675' -ROM_PLAYER_LIMIT = 255 +ROM_PLAYER_LIMIT = 65535 import hashlib import os diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index 808f1bde..005844ea 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -159,6 +159,9 @@ class SMWorld(World): def getWord(self, w): return (w & 0x00FF, (w & 0xFF00) >> 8) + + def getWordArray(self, w): + return [w & 0x00FF, (w & 0xFF00) >> 8] # used for remote location Credits Spoiler of local items class DummyLocation: @@ -232,7 +235,10 @@ class SMWorld(World): multiWorldItems = {} idx = 0 itemId = 0 + self.playerIDMap = {} + playerIDCount = 0 # 0 is for "Archipelago" server for itemLoc in self.world.get_locations(): + romPlayerID = itemLoc.item.player if itemLoc.item.player <= ROM_PLAYER_LIMIT else 0 if itemLoc.player == self.player and locationsDict[itemLoc.name].Id != None: if itemLoc.item.type in ItemManager.Items: itemId = ItemManager.Items[itemLoc.item.type].Id @@ -240,12 +246,21 @@ class SMWorld(World): itemId = ItemManager.Items['ArchipelagoItem'].Id + idx multiWorldItems[0x029EA3 + idx*64] = self.convertToROMItemName(itemLoc.item.name) idx += 1 + + if (romPlayerID > 0 and romPlayerID not in self.playerIDMap.keys()): + playerIDCount += 1 + self.playerIDMap[romPlayerID] = playerIDCount + (w0, w1) = self.getWord(0 if itemLoc.item.player == self.player else 1) (w2, w3) = self.getWord(itemId) - (w4, w5) = self.getWord(itemLoc.item.player if itemLoc.item.player <= ROM_PLAYER_LIMIT else 0) + (w4, w5) = self.getWord(romPlayerID) (w6, w7) = self.getWord(0 if itemLoc.item.advancement else 1) multiWorldLocations[0x1C6000 + locationsDict[itemLoc.name].Id*8] = [w0, w1, w2, w3, w4, w5, w6, w7] + if itemLoc.item.player == self.player: + if (romPlayerID > 0 and romPlayerID not in self.playerIDMap.keys()): + playerIDCount += 1 + self.playerIDMap[romPlayerID] = playerIDCount itemSprites = ["off_world_prog_item.bin", "off_world_item.bin"] idx = 0 @@ -260,21 +275,24 @@ class SMWorld(World): openTourianGreyDoors = {0x07C823 + 5: [0x0C], 0x07C831 + 5: [0x0C]} deathLink = {0x277f04: [int(self.world.death_link[self.player])]} + + playerNames = {} + playerNameIDMap = {} + playerNames[0x1C5000] = "Archipelago".upper().center(16).encode() + playerNameIDMap[0x1C5800] = self.getWordArray(0) + for key,value in self.playerIDMap.items(): + playerNames[0x1C5000 + value * 16] = self.world.player_name[key][:16].upper().center(16).encode() + playerNameIDMap[0x1C5800 + value * 2] = self.getWordArray(key) + patchDict = { 'MultiWorldLocations': multiWorldLocations, 'MultiWorldItems': multiWorldItems, 'offworldSprites': offworldSprites, 'openTourianGreyDoors': openTourianGreyDoors, - 'deathLink': deathLink} + 'deathLink': deathLink, + 'PlayerName': playerNames, + 'PlayerNameIDMap': playerNameIDMap} romPatcher.applyIPSPatchDict(patchDict) - playerNames = {} - playerNames[0x1C5000] = "Archipelago".upper().center(16).encode() - for p in range(1, min(self.world.players, ROM_PLAYER_LIMIT) + 1): - playerNames[0x1C5000 + p * 16] = self.world.player_name[p][:16].upper().center(16).encode() - - - romPatcher.applyIPSPatch('PlayerName', { 'PlayerName': playerNames }) - # set rom name # 21 bytes from Main import __version__ diff --git a/worlds/sm/variaRandomizer/patches/common/ips/basepatch.ips b/worlds/sm/variaRandomizer/patches/common/ips/basepatch.ips index 3877dbaa..1acf93ef 100644 Binary files a/worlds/sm/variaRandomizer/patches/common/ips/basepatch.ips and b/worlds/sm/variaRandomizer/patches/common/ips/basepatch.ips differ diff --git a/worlds/sm/variaRandomizer/patches/patchaccess.py b/worlds/sm/variaRandomizer/patches/patchaccess.py index 857b99e8..bce2d486 100644 --- a/worlds/sm/variaRandomizer/patches/patchaccess.py +++ b/worlds/sm/variaRandomizer/patches/patchaccess.py @@ -2,16 +2,15 @@ import os, importlib from logic.logic import Logic from patches.common.patches import patches, additional_PLMs from utils.parameters import appDir -from Utils import is_frozen class PatchAccess(object): def __init__(self): # load all ips patches self.patchesPath = {} - commonDir = os.path.join(appDir, 'lib' if is_frozen() else '', 'worlds/sm/variaRandomizer/patches/common/ips/') + commonDir = os.path.join(appDir, 'worlds/sm/variaRandomizer/patches/common/ips/') for patch in os.listdir(commonDir): self.patchesPath[patch] = commonDir - logicDir = os.path.join(appDir, 'lib' if is_frozen() else '', 'worlds/sm/variaRandomizer/patches/{}/ips/'.format(Logic.patches)) + logicDir = os.path.join(appDir, 'worlds/sm/variaRandomizer/patches/{}/ips/'.format(Logic.patches)) for patch in os.listdir(logicDir): self.patchesPath[patch] = logicDir diff --git a/worlds/sm/variaRandomizer/randomizer.py b/worlds/sm/variaRandomizer/randomizer.py index 563ed6bf..d87c3e39 100644 --- a/worlds/sm/variaRandomizer/randomizer.py +++ b/worlds/sm/variaRandomizer/randomizer.py @@ -327,7 +327,7 @@ class VariaRandomizer: preset = loadRandoPreset(world, self.player, args) # use the skill preset from the rando preset if preset is not None and preset != 'custom' and preset != 'varia_custom' and args.paramsFileName is None: - args.paramsFileName = '{}/{}/{}.json'.format(appDir, getPresetDir(preset), preset) + args.paramsFileName = os.path.join(appDir, getPresetDir(preset), preset+".json") # if diff preset given, load it if args.paramsFileName is not None: @@ -352,7 +352,7 @@ class VariaRandomizer: sys.exit(-1) else: preset = 'default' - PresetLoader.factory('{}/{}/{}.json'.format(appDir, getPresetDir('casual'), 'casual')).load(self.player) + PresetLoader.factory(os.path.join(appDir, getPresetDir('casual'), 'casual.json')).load(self.player) diff --git a/worlds/sm/variaRandomizer/utils/parameters.py b/worlds/sm/variaRandomizer/utils/parameters.py index 7adec798..0f7b62c6 100644 --- a/worlds/sm/variaRandomizer/utils/parameters.py +++ b/worlds/sm/variaRandomizer/utils/parameters.py @@ -1,6 +1,7 @@ from logic.smbool import SMBool import os import sys +from pathlib import Path # the different difficulties available easy = 1 @@ -60,7 +61,7 @@ def diff4solver(difficulty): return "mania" # allow multiple local repo -appDir = sys.path[0] +appDir = Path(__file__).parents[4] def isKnows(knows): return knows[0:len('__')] != '__' and knows[0] == knows[0].upper() diff --git a/worlds/sm/variaRandomizer/utils/utils.py b/worlds/sm/variaRandomizer/utils/utils.py index d6c71b00..d64cb252 100644 --- a/worlds/sm/variaRandomizer/utils/utils.py +++ b/worlds/sm/variaRandomizer/utils/utils.py @@ -1,19 +1,17 @@ -import os, json, sys, re, random +import os, json, re, random from utils.parameters import Knows, Settings, Controller, isKnows, isSettings, isButton from utils.parameters import easy, medium, hard, harder, hardcore, mania, text2diff from logic.smbool import SMBool -from Utils import is_frozen - def isStdPreset(preset): return preset in ['newbie', 'casual', 'regular', 'veteran', 'expert', 'master', 'samus', 'solution', 'Season_Races', 'SMRAT2021'] -def getPresetDir(preset): +def getPresetDir(preset) -> str: if isStdPreset(preset): - return 'lib/worlds/sm/variaRandomizer/standard_presets' if is_frozen() else 'worlds/sm/variaRandomizer/standard_presets' + return 'worlds/sm/variaRandomizer/standard_presets' else: - return 'lib/worlds/sm/variaRandomizer/community_presets' if is_frozen() else 'worlds/sm/variaRandomizer/community_presets' + return 'worlds/sm/variaRandomizer/community_presets' def removeChars(string, toRemove): return re.sub('[{}]+'.format(toRemove), '', string) diff --git a/worlds/soe/__init__.py b/worlds/soe/__init__.py index 104f2e86..6bf6f803 100644 --- a/worlds/soe/__init__.py +++ b/worlds/soe/__init__.py @@ -5,6 +5,7 @@ from Utils import get_options, output_path import typing import lzma import os +import os.path import threading try: @@ -200,11 +201,18 @@ class SoEWorld(World): line = f'{loc.type},{loc.index}:{item.type},{item.index}\n' f.write(line.encode('utf-8')) - if (pyevermizer.main(rom_file, out_file, placement_file, self.world.seed_name, self.connect_name, self.evermizer_seed, - flags, money, exp)): + if not os.path.exists(rom_file): + raise FileNotFoundError(rom_file) + if (pyevermizer.main(rom_file, out_file, placement_file, self.world.seed_name, self.connect_name, + self.evermizer_seed, flags, money, exp)): raise RuntimeError() with lzma.LZMAFile(patch_file, 'wb') as f: - f.write(generate_patch(rom_file, out_file)) + f.write(generate_patch(rom_file, out_file, + { + # used by WebHost + "player_name": self.world.player_name[self.player], + "player_id": self.player + })) except: raise finally: diff --git a/worlds/timespinner/Locations.py b/worlds/timespinner/Locations.py index 904240d6..b1895b4c 100644 --- a/worlds/timespinner/Locations.py +++ b/worlds/timespinner/Locations.py @@ -106,7 +106,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData('Refugee Camp', 'Refugee camp storage chest 2', 1337088), LocationData('Refugee Camp', 'Refugee camp storage chest 1', 1337089), LocationData('Forest', 'Refugee camp roof', 1337090), - LocationData('Forest', 'Bat jump chest', 1337091, lambda state: state._timespinner_has_doublejump_of_npc(world, player) or state._timespinner_has_forwarddash_doublejump(world, player)), + LocationData('Forest', 'Bat jump chest', 1337091, lambda state: state._timespinner_has_doublejump_of_npc(world, player) or state._timespinner_has_forwarddash_doublejump(world, player) or state._timespinner_has_fastjump_on_npc(world, player)), LocationData('Forest', 'Green platform secret', 1337092, lambda state: state._timespinner_can_break_walls(world, player)), LocationData('Forest', 'Rats guarded chest', 1337093), LocationData('Forest', 'Waterfall chest 1', 1337094, lambda state: state.has('Water Mask', player)), @@ -158,7 +158,7 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData('Castle Keep', 'Out of the way', 1337139), LocationData('Castle Keep', 'Killed Twins', EventId, lambda state: state._timespinner_has_timestop(world, player)), LocationData('Castle Keep', 'Twins', 1337140, lambda state: state._timespinner_has_timestop(world, player)), - LocationData('Castle Keep', 'Royal guard tiny room', 1337141, lambda state: state._timespinner_has_doublejump(world, player)), + LocationData('Castle Keep', 'Royal guard tiny room', 1337141, lambda state: state._timespinner_has_doublejump(world, player) or state._timespinner_has_fastjump_on_npc(world,player)), LocationData('Royal towers (lower)', 'Royal tower floor secret', 1337142, lambda state: state._timespinner_has_doublejump(world, player) and state._timespinner_can_break_walls(world, player)), LocationData('Royal towers', 'Above the gap', 1337143), LocationData('Royal towers', 'Under the ice mage', 1337144), diff --git a/worlds/timespinner/LogicMixin.py b/worlds/timespinner/LogicMixin.py index 8181b309..7a81c25e 100644 --- a/worlds/timespinner/LogicMixin.py +++ b/worlds/timespinner/LogicMixin.py @@ -15,6 +15,9 @@ class TimespinnerLogic(LogicMixin): def _timespinner_has_doublejump_of_npc(self, world: MultiWorld, player: int) -> bool: return self._timespinner_has_upwarddash(world, player) or (self.has('Timespinner Wheel', player) and self._timespinner_has_doublejump(world, player)) + def _timespinner_has_fastjump_on_npc(self, world: MultiWorld, player: int) -> bool: + return self.has_all(['Timespinner Wheel', 'Talaria Attachment'], player) + def _timespinner_has_multiple_small_jumps_of_npc(self, world: MultiWorld, player: int) -> bool: return self.has('Timespinner Wheel', player) or self._timespinner_has_upwarddash(world, player) diff --git a/worlds/timespinner/Regions.py b/worlds/timespinner/Regions.py index 827277d8..7fa1ec27 100644 --- a/worlds/timespinner/Regions.py +++ b/worlds/timespinner/Regions.py @@ -72,7 +72,7 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData connect(world, player, names, 'Varndagroth tower right (upper)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player)) connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (upper)') connect(world, player, names, 'Varndagroth tower right (elevator)', 'Varndagroth tower right (lower)') - connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower left') + connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower left', lambda state: state._timespinner_has_keycard_B(world, player)) connect(world, player, names, 'Varndagroth tower right (lower)', 'Varndagroth tower right (elevator)', lambda state: state.has('Elevator Keycard', player)) connect(world, player, names, 'Varndagroth tower right (lower)', 'Sealed Caves (Sirens)', lambda state: state._timespinner_has_keycard_B(world, player) and state.has('Elevator Keycard', player)) connect(world, player, names, 'Varndagroth tower right (lower)', 'Militairy Fortress', lambda state: state._timespinner_can_kill_all_3_bosses(world, player))