diff --git a/BaseClasses.py b/BaseClasses.py index ce2fc9e3c5..016c80ec83 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -48,6 +48,7 @@ class MultiWorld(): state: CollectionState accessibility: Dict[int, Options.Accessibility] + early_items: Dict[int, Options.EarlyItems] local_items: Dict[int, Options.LocalItems] non_local_items: Dict[int, Options.NonLocalItems] progression_balancing: Dict[int, Options.ProgressionBalancing] diff --git a/CommonClient.py b/CommonClient.py index b17709eecf..4fc82b5e67 100644 --- a/CommonClient.py +++ b/CommonClient.py @@ -91,12 +91,18 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_items(self): """List all item names for the currently running game.""" + if not self.ctx.game: + self.output("No game set, cannot determine existing items.") + return False 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): """List all location names for the currently running game.""" + if not self.ctx.game: + self.output("No game set, cannot determine existing locations.") + return False 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) @@ -279,6 +285,7 @@ class CommonContext: self.auth = await self.console_input() async def send_connect(self, **kwargs: typing.Any) -> None: + """ send `Connect` packet to log in to server """ payload = { 'cmd': 'Connect', 'password': self.password, 'name': self.auth, 'version': Utils.version_tuple, @@ -294,6 +301,7 @@ class CommonContext: return await self.input_queue.get() async def connect(self, address: typing.Optional[str] = None) -> None: + """ disconnect any previous connection, and open new connection to the server """ await self.disconnect() self.server_task = asyncio.create_task(server_loop(self, address), name="server loop") @@ -304,6 +312,12 @@ class CommonContext: return self.slot in self.slot_info[slot].group_members return False + def is_uninteresting_item_send(self, print_json_packet: dict) -> bool: + """Helper function for filtering out ItemSend prints that do not concern the local player.""" + return print_json_packet.get("type", "") == "ItemSend" \ + and not self.slot_concerns_self(print_json_packet["receiving"]) \ + and not self.slot_concerns_self(print_json_packet["item"].player) + def on_print(self, args: dict): logger.info(args["text"]) diff --git a/FactorioClient.py b/FactorioClient.py index 1efca05d3c..12ec22916b 100644 --- a/FactorioClient.py +++ b/FactorioClient.py @@ -4,9 +4,11 @@ import logging import json import string import copy +import re import subprocess import time import random +import typing import ModuleUpdate ModuleUpdate.update() @@ -46,6 +48,13 @@ class FactorioCommandProcessor(ClientCommandProcessor): """Manually trigger a resync.""" self.ctx.awaiting_bridge = True + def _cmd_toggle_send_filter(self): + """Toggle filtering of item sends that get displayed in-game to only those that involve you.""" + self.ctx.toggle_filter_item_sends() + + def _cmd_toggle_chat(self): + """Toggle sending of chat messages from players on the Factorio server to Archipelago.""" + self.ctx.toggle_bridge_chat_out() class FactorioContext(CommonContext): command_processor = FactorioCommandProcessor @@ -65,6 +74,9 @@ class FactorioContext(CommonContext): self.factorio_json_text_parser = FactorioJSONtoTextParser(self) self.energy_link_increment = 0 self.last_deplete = 0 + self.filter_item_sends: bool = False + self.multiplayer: bool = False # whether multiple different players have connected + self.bridge_chat_out: bool = True async def server_auth(self, password_requested: bool = False): if password_requested and not self.password: @@ -81,12 +93,15 @@ class FactorioContext(CommonContext): def on_print(self, args: dict): super(FactorioContext, self).on_print(args) if self.rcon_client: - self.print_to_game(args['text']) + if not args['text'].startswith(self.player_names[self.slot] + ":"): + self.print_to_game(args['text']) def on_print_json(self, args: dict): if self.rcon_client: - text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) - self.print_to_game(text) + if not self.filter_item_sends or not self.is_uninteresting_item_send(args): + text = self.factorio_json_text_parser(copy.deepcopy(args["data"])) + if not text.startswith(self.player_names[self.slot] + ":"): + self.print_to_game(text) super(FactorioContext, self).on_print_json(args) @property @@ -123,6 +138,45 @@ class FactorioContext(CommonContext): f"{Utils.format_SI_prefix(args['value'])}J remaining.") self.rcon_client.send_command(f"/ap-energylink {gained}") + def on_user_say(self, text: str) -> typing.Optional[str]: + # Mirror chat sent from the UI to the Factorio server. + self.print_to_game(f"{self.player_names[self.slot]}: {text}") + return text + + async def chat_from_factorio(self, user: str, message: str) -> None: + if not self.bridge_chat_out: + return + + # Pass through commands + if message.startswith("!"): + await self.send_msgs([{"cmd": "Say", "text": message}]) + return + + # Omit messages that contain local coordinates + if "[gps=" in message: + return + + prefix = f"({user}) " if self.multiplayer else "" + await self.send_msgs([{"cmd": "Say", "text": f"{prefix}{message}"}]) + + def toggle_filter_item_sends(self) -> None: + self.filter_item_sends = not self.filter_item_sends + if self.filter_item_sends: + announcement = "Item sends are now filtered." + else: + announcement = "Item sends are no longer filtered." + logger.info(announcement) + self.print_to_game(announcement) + + def toggle_bridge_chat_out(self) -> None: + self.bridge_chat_out = not self.bridge_chat_out + if self.bridge_chat_out: + announcement = "Chat is now bridged to Archipelago." + else: + announcement = "Chat is no longer bridged to Archipelago." + logger.info(announcement) + self.print_to_game(announcement) + def run_gui(self): from kvui import GameManager @@ -162,6 +216,7 @@ async def game_watcher(ctx: FactorioContext): research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} victory = data["victory"] await ctx.update_death_link(data["death_link"]) + ctx.multiplayer = data.get("multiplayer", False) if not ctx.finished_game and victory: await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) @@ -262,8 +317,17 @@ async def factorio_server_watcher(ctx: FactorioContext): if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: ctx.awaiting_bridge = True factorio_server_logger.debug(msg) + elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-send-filter", msg): + factorio_server_logger.debug(msg) + ctx.toggle_filter_item_sends() + elif re.match(r"^[0-9.]+ Script @[^ ]+\.lua:\d+: Player command toggle-ap-chat$", msg): + factorio_server_logger.debug(msg) + ctx.toggle_bridge_chat_out() else: factorio_server_logger.info(msg) + match = re.match(r"^\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d \[CHAT\] ([^:]+): (.*)$", msg) + if match: + await ctx.chat_from_factorio(match.group(1), match.group(2)) if ctx.rcon_client: commands = {} while ctx.send_index < len(ctx.items_received): @@ -363,6 +427,8 @@ async def factorio_spinup_server(ctx: FactorioContext) -> bool: async def main(args): ctx = FactorioContext(args.connect, args.password) + ctx.filter_item_sends = initial_filter_item_sends + ctx.bridge_chat_out = initial_bridge_chat_out ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") if gui_enabled: @@ -415,6 +481,12 @@ if __name__ == '__main__': server_settings = args.server_settings if args.server_settings else options["factorio_options"].get("server_settings", None) if server_settings: server_settings = os.path.abspath(server_settings) + if not isinstance(options["factorio_options"]["filter_item_sends"], bool): + logging.warning(f"Warning: Option filter_item_sends should be a bool.") + initial_filter_item_sends = bool(options["factorio_options"]["filter_item_sends"]) + if not isinstance(options["factorio_options"]["bridge_chat_out"], bool): + logging.warning(f"Warning: Option bridge_chat_out should be a bool.") + initial_bridge_chat_out = bool(options["factorio_options"]["bridge_chat_out"]) if not os.path.exists(os.path.dirname(executable)): raise FileNotFoundError(f"Path {os.path.dirname(executable)} does not exist or could not be accessed.") diff --git a/Fill.py b/Fill.py index cb9844b442..105b359134 100644 --- a/Fill.py +++ b/Fill.py @@ -258,6 +258,45 @@ def distribute_items_restrictive(world: MultiWorld) -> None: usefulitempool: typing.List[Item] = [] filleritempool: typing.List[Item] = [] + early_items_count: typing.Dict[typing.Tuple[str, int], int] = {} + for player in world.player_ids: + for item, count in world.early_items[player].value.items(): + early_items_count[(item, player)] = count + if early_items_count: + early_locations: typing.List[Location] = [] + early_priority_locations: typing.List[Location] = [] + for loc in reversed(fill_locations): + if loc.can_reach(world.state): + if loc.progress_type == LocationProgressType.PRIORITY: + early_priority_locations.append(loc) + else: + early_locations.append(loc) + fill_locations.remove(loc) + + early_prog_items: typing.List[Item] = [] + early_rest_items: typing.List[Item] = [] + for item in reversed(itempool): + if (item.name, item.player) in early_items_count: + if item.advancement: + early_prog_items.append(item) + else: + early_rest_items.append(item) + itempool.remove(item) + early_items_count[(item.name, item.player)] -= 1 + if early_items_count[(item.name, item.player)] == 0: + del early_items_count[(item.name, item.player)] + fill_restrictive(world, world.state, early_locations, early_rest_items, lock=True) + early_locations += early_priority_locations + fill_restrictive(world, world.state, early_locations, early_prog_items, lock=True) + unplaced_early_items = early_rest_items + early_prog_items + if unplaced_early_items: + logging.warning(f"Ran out of early locations for early items. Failed to place \ + {len(unplaced_early_items)} items early.") + itempool += unplaced_early_items + + fill_locations += early_locations + early_priority_locations + world.random.shuffle(fill_locations) + for item in itempool: if item.advancement: progitempool.append(item) diff --git a/LttPAdjuster.py b/LttPAdjuster.py index 9fab226c67..a2cc2eeba5 100644 --- a/LttPAdjuster.py +++ b/LttPAdjuster.py @@ -26,7 +26,9 @@ ModuleUpdate.update() from worlds.alttp.Rom import Sprite, LocalRom, apply_rom_settings, get_base_rom_bytes from Utils import output_path, local_path, user_path, open_file, get_cert_none_ssl_context, persistent_store, \ get_adjuster_settings, tkinter_center_window, init_logging -from Patch import GAME_ALTTP + + +GAME_ALTTP = "A Link to the Past" class AdjusterWorld(object): diff --git a/Main.py b/Main.py index 63f5b8a818..38100bd050 100644 --- a/Main.py +++ b/Main.py @@ -80,15 +80,30 @@ def main(args, seed=None, baked_server_options: Optional[Dict[str, object]] = No logger.info("Found World Types:") longest_name = max(len(text) for text in AutoWorld.AutoWorldRegister.world_types) - numlength = 8 + + max_item = 0 + max_location = 0 + for cls in AutoWorld.AutoWorldRegister.world_types.values(): + if cls.item_id_to_name: + max_item = max(max_item, max(cls.item_id_to_name)) + max_location = max(max_location, max(cls.location_id_to_name)) + + item_digits = len(str(max_item)) + location_digits = len(str(max_location)) + item_count = len(str(max(len(cls.item_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) + location_count = len(str(max(len(cls.location_names) for cls in AutoWorld.AutoWorldRegister.world_types.values()))) + del max_item, max_location + for name, cls in AutoWorld.AutoWorldRegister.world_types.items(): if not cls.hidden and len(cls.item_names) > 0: - logger.info(f" {name:{longest_name}}: {len(cls.item_names):3} " - f"Items (IDs: {min(cls.item_id_to_name):{numlength}} - " - f"{max(cls.item_id_to_name):{numlength}}) | " - f"{len(cls.location_names):3} " - f"Locations (IDs: {min(cls.location_id_to_name):{numlength}} - " - f"{max(cls.location_id_to_name):{numlength}})") + logger.info(f" {name:{longest_name}}: {len(cls.item_names):{item_count}} " + f"Items (IDs: {min(cls.item_id_to_name):{item_digits}} - " + f"{max(cls.item_id_to_name):{item_digits}}) | " + f"{len(cls.location_names):{location_count}} " + f"Locations (IDs: {min(cls.location_id_to_name):{location_digits}} - " + f"{max(cls.location_id_to_name):{location_digits}})") + + del item_digits, location_digits, item_count, location_count AutoWorld.call_stage(world, "assert_generate") diff --git a/MultiServer.py b/MultiServer.py index 9f0865d425..1a672afaa6 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -998,7 +998,11 @@ class CommandMeta(type): return super(CommandMeta, cls).__new__(cls, name, bases, attrs) -def mark_raw(function): +_Return = typing.TypeVar("_Return") +# TODO: when python 3.10 is lowest supported, typing.ParamSpec + + +def mark_raw(function: typing.Callable[[typing.Any], _Return]) -> typing.Callable[[typing.Any], _Return]: function.raw_text = True return function @@ -1328,6 +1332,8 @@ class ClientMessageProcessor(CommonCommandProcessor): def get_hints(self, input_text: str, for_location: bool = False) -> bool: points_available = get_client_points(self.ctx, self.client) + cost = self.ctx.get_hint_cost(self.client.slot) + if not input_text: hints = {hint.re_check(self.ctx, self.client.team) for hint in self.ctx.hints[self.client.team, self.client.slot]} @@ -1382,7 +1388,6 @@ class ClientMessageProcessor(CommonCommandProcessor): return False if hints: - cost = self.ctx.get_hint_cost(self.client.slot) new_hints = set(hints) - self.ctx.hints[self.client.team, self.client.slot] old_hints = set(hints) - new_hints if old_hints: @@ -1432,7 +1437,12 @@ class ClientMessageProcessor(CommonCommandProcessor): return True else: - self.output("Nothing found. Item/Location may not exist.") + if points_available >= cost: + self.output("Nothing found. Item/Location may not exist.") + else: + self.output(f"You can't afford the hint. " + f"You have {points_available} points and need at least " + f"{self.ctx.get_hint_cost(self.client.slot)}.") return False @mark_raw diff --git a/Options.py b/Options.py index c2007c1c41..ad87f5ebf8 100644 --- a/Options.py +++ b/Options.py @@ -1,5 +1,6 @@ from __future__ import annotations import abc +from copy import deepcopy import math import numbers import typing @@ -753,7 +754,7 @@ class OptionDict(Option[typing.Dict[str, typing.Any]], VerifyKeys): supports_weighting = False def __init__(self, value: typing.Dict[str, typing.Any]): - self.value = value + self.value = deepcopy(value) @classmethod def from_any(cls, data: typing.Dict[str, typing.Any]) -> OptionDict: @@ -784,7 +785,7 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): supports_weighting = False def __init__(self, value: typing.List[typing.Any]): - self.value = value or [] + self.value = deepcopy(value) super(OptionList, self).__init__() @classmethod @@ -806,11 +807,11 @@ class OptionList(Option[typing.List[typing.Any]], VerifyKeys): class OptionSet(Option[typing.Set[str]], VerifyKeys): - default = frozenset() + default: typing.Union[typing.Set[str], typing.FrozenSet[str]] = frozenset() supports_weighting = False - def __init__(self, value: typing.Union[typing.Set[str, typing.Any], typing.List[str, typing.Any]]): - self.value = set(value) + def __init__(self, value: typing.Iterable[str]): + self.value = set(deepcopy(value)) super(OptionSet, self).__init__() @classmethod @@ -882,6 +883,11 @@ class NonLocalItems(ItemSet): display_name = "Not Local Items" +class EarlyItems(ItemDict): + """Force the specified items to be in locations that are reachable from the start.""" + display_name = "Early Items" + + class StartInventory(ItemDict): """Start with these items.""" verify_item_name = True @@ -980,6 +986,7 @@ per_game_common_options = { **common_options, # can be overwritten per-game "local_items": LocalItems, "non_local_items": NonLocalItems, + "early_items": EarlyItems, "start_inventory": StartInventory, "start_hints": StartHints, "start_location_hints": StartLocationHints, diff --git a/Patch.py b/Patch.py index 4ff0e9602a..113d0658c6 100644 --- a/Patch.py +++ b/Patch.py @@ -11,16 +11,6 @@ if __name__ == "__main__": from worlds.Files import AutoPatchRegister, APDeltaPatch -GAME_ALTTP = "A Link to the Past" -GAME_SM = "Super Metroid" -GAME_SOE = "Secret of Evermore" -GAME_SMZ3 = "SMZ3" -GAME_DKC3 = "Donkey Kong Country 3" - -GAME_SMW = "Super Mario World" - - - class RomMeta(TypedDict): server: str player: Optional[int] diff --git a/SNIClient.py b/SNIClient.py index 188822bce7..03e1ff5783 100644 --- a/SNIClient.py +++ b/SNIClient.py @@ -7,7 +7,6 @@ import multiprocessing import os import subprocess import base64 -import shutil import logging import asyncio import enum @@ -20,24 +19,19 @@ from CommonClient import CommonContext, server_loop, ClientCommandProcessor, gui import Utils +from MultiServer import mark_raw +if typing.TYPE_CHECKING: + from worlds.AutoSNIClient import SNIClient + if __name__ == "__main__": Utils.init_logging("SNIClient", exception_logger="Client") import colorama -import websockets - -from NetUtils import ClientStatus, color -from worlds.alttp import Regions, Shops -from worlds.alttp.Rom import ROM_PLAYER_LIMIT -from worlds.sm.Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT -from worlds.smz3.Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT -from Patch import GAME_ALTTP, GAME_SM, GAME_SMZ3, GAME_DKC3, GAME_SMW - +from websockets.client import connect as websockets_connect, WebSocketClientProtocol +from websockets.exceptions import WebSocketException, ConnectionClosed snes_logger = logging.getLogger("SNES") -from MultiServer import mark_raw - class DeathState(enum.IntEnum): killing_player = 1 @@ -46,9 +40,9 @@ class DeathState(enum.IntEnum): class SNIClientCommandProcessor(ClientCommandProcessor): - ctx: Context + ctx: SNIContext - def _cmd_slow_mode(self, toggle: str = ""): + def _cmd_slow_mode(self, toggle: str = "") -> None: """Toggle slow mode, which limits how fast you send / receive items.""" if toggle: self.ctx.slow_mode = toggle.lower() in {"1", "true", "on"} @@ -63,6 +57,9 @@ class SNIClientCommandProcessor(ClientCommandProcessor): otherwise show available devices; and a SNES device number if more than one SNES is detected. Examples: "/snes", "/snes 1", "/snes localhost:23074 1" """ + return self.connect_to_snes(snes_options) + + def connect_to_snes(self, snes_options: str = "") -> bool: snes_address = self.ctx.snes_address snes_device_number = -1 @@ -79,8 +76,8 @@ class SNIClientCommandProcessor(ClientCommandProcessor): self.ctx.snes_reconnect_address = None if self.ctx.snes_connect_task: self.ctx.snes_connect_task.cancel() - self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), - name="SNES Connect") + self.ctx.snes_connect_task = asyncio.create_task(snes_connect(self.ctx, snes_address, snes_device_number), + name="SNES Connect") return True def _cmd_snes_close(self) -> bool: @@ -113,14 +110,36 @@ class SNIClientCommandProcessor(ClientCommandProcessor): # return True -class Context(CommonContext): - command_processor = SNIClientCommandProcessor - game = "A Link to the Past" +class SNIContext(CommonContext): + command_processor: typing.Type[SNIClientCommandProcessor] = SNIClientCommandProcessor + game = None # set in validate_rom items_handling = None # set in game_watcher - snes_connect_task: typing.Optional[asyncio.Task] = None + snes_connect_task: "typing.Optional[asyncio.Task[None]]" = None - def __init__(self, snes_address, server_address, password): - super(Context, self).__init__(server_address, password) + snes_address: str + snes_socket: typing.Optional[WebSocketClientProtocol] + snes_state: SNESState + snes_attached_device: typing.Optional[typing.Tuple[int, str]] + snes_reconnect_address: typing.Optional[str] + snes_recv_queue: "asyncio.Queue[bytes]" + snes_request_lock: asyncio.Lock + snes_write_buffer: typing.List[typing.Tuple[int, bytes]] + snes_connector_lock: threading.Lock + death_state: DeathState + killing_player_task: "typing.Optional[asyncio.Task[None]]" + allow_collect: bool + slow_mode: bool + + client_handler: typing.Optional[SNIClient] + awaiting_rom: bool + rom: typing.Optional[bytes] + prev_rom: typing.Optional[bytes] + + hud_message_queue: typing.List[str] # TODO: str is a guess, is this right? + death_link_allow_survive: bool + + def __init__(self, snes_address: str, server_address: str, password: str) -> None: + super(SNIContext, self).__init__(server_address, password) # snes stuff self.snes_address = snes_address @@ -137,39 +156,48 @@ class Context(CommonContext): self.allow_collect = False self.slow_mode = False + self.client_handler = None self.awaiting_rom = False self.rom = None self.prev_rom = None - async def connection_closed(self): - await super(Context, self).connection_closed() + async def connection_closed(self) -> None: + await super(SNIContext, self).connection_closed() self.awaiting_rom = False - def event_invalid_slot(self): + def event_invalid_slot(self) -> typing.NoReturn: if self.snes_socket is not None and not self.snes_socket.closed: asyncio.create_task(self.snes_socket.close()) raise Exception("Invalid ROM detected, " "please verify that you have loaded the correct rom and reconnect your snes (/snes)") - async def server_auth(self, password_requested: bool = False): + async def server_auth(self, password_requested: bool = False) -> None: if password_requested and not self.password: - await super(Context, self).server_auth(password_requested) + await super(SNIContext, self).server_auth(password_requested) if self.rom is None: self.awaiting_rom = True snes_logger.info( "No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)") return self.awaiting_rom = False + # TODO: This looks kind of hacky... + # Context.auth is meant to be the "name" parameter in send_connect, + # which has to be a str (bytes is not json serializable). + # But here, Context.auth is being used for something else + # (where it has to be bytes because it is compared with rom elsewhere). + # If we need to save something to compare with rom elsewhere, + # it should probably be in a different variable, + # and let auth be used for what it's meant for. self.auth = self.rom auth = base64.b64encode(self.rom).decode() await self.send_connect(name=auth) - def on_deathlink(self, data: dict): + def on_deathlink(self, data: typing.Dict[str, typing.Any]) -> None: if not self.killing_player_task or self.killing_player_task.done(): self.killing_player_task = asyncio.create_task(deathlink_kill_player(self)) - super(Context, self).on_deathlink(data) + super(SNIContext, self).on_deathlink(data) - async def handle_deathlink_state(self, currently_dead: bool): + async def handle_deathlink_state(self, currently_dead: bool) -> None: # in this state we only care about triggering a death send if self.death_state == DeathState.alive: if currently_dead: @@ -184,25 +212,27 @@ class Context(CommonContext): if not currently_dead: self.death_state = DeathState.alive - async def shutdown(self): - await super(Context, self).shutdown() + async def shutdown(self) -> None: + await super(SNIContext, self).shutdown() if self.snes_connect_task: try: await asyncio.wait_for(self.snes_connect_task, 1) except asyncio.TimeoutError: self.snes_connect_task.cancel() - def on_package(self, cmd: str, args: dict): + def on_package(self, cmd: str, args: typing.Dict[str, typing.Any]) -> None: if cmd in {"Connected", "RoomUpdate"}: if "checked_locations" in args and args["checked_locations"]: new_locations = set(args["checked_locations"]) self.checked_locations |= new_locations self.locations_scouted |= new_locations - # Items belonging to the player should not be marked as checked in game, since the player will likely need that item. - # Once the games handled by SNIClient gets made to be remote items, this will no longer be needed. + # Items belonging to the player should not be marked as checked in game, + # since the player will likely need that item. + # Once the games handled by SNIClient gets made to be remote items, + # this will no longer be needed. asyncio.create_task(self.send_msgs([{"cmd": "LocationScouts", "locations": list(new_locations)}])) - def run_gui(self): + def run_gui(self) -> None: from kvui import GameManager class SNIManager(GameManager): @@ -213,391 +243,23 @@ class Context(CommonContext): base_title = "Archipelago SNI Client" self.ui = SNIManager(self) - self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") + self.ui_task = asyncio.create_task(self.ui.async_run(), name="UI") # type: ignore -async def deathlink_kill_player(ctx: Context): +async def deathlink_kill_player(ctx: SNIContext) -> None: ctx.death_state = DeathState.killing_player while ctx.death_state == DeathState.killing_player and \ ctx.snes_state == SNESState.SNES_ATTACHED: - if ctx.game == GAME_ALTTP: - invincible = await snes_read(ctx, WRAM_START + 0x037B, 1) - last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1) - await asyncio.sleep(0.25) - health = await snes_read(ctx, WRAM_START + 0xF36D, 1) - if not invincible or not last_health or not health: - ctx.death_state = DeathState.dead - ctx.last_death_link = time.time() - continue - if not invincible[0] and last_health[0] == health[0]: - snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0 - snes_buffered_write(ctx, WRAM_START + 0x0373, - bytes([8])) # deal 1 full heart of damage at next opportunity - elif ctx.game == GAME_SM: - snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy) - snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity - if not ctx.death_link_allow_survive: - snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0 - elif ctx.game == GAME_SMW: - from worlds.smw.Client import deathlink_kill_player as smw_deathlink_kill_player - await smw_deathlink_kill_player(ctx) - await snes_flush_writes(ctx) - await asyncio.sleep(1) + if ctx.client_handler is None: + continue + + await ctx.client_handler.deathlink_kill_player(ctx) - if ctx.game == GAME_ALTTP: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - if not gamemode or gamemode[0] in DEATH_MODES: - ctx.death_state = DeathState.dead - elif ctx.game == GAME_SM: - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - health = await snes_read(ctx, WRAM_START + 0x09C2, 2) - if health is not None: - health = health[0] | (health[1] << 8) - if not gamemode or gamemode[0] in SM_DEATH_MODES or ( - ctx.death_link_allow_survive and health is not None and health > 0): - ctx.death_state = DeathState.dead - elif ctx.game == GAME_DKC3: - from worlds.dkc3.Client import deathlink_kill_player as dkc3_deathlink_kill_player - await dkc3_deathlink_kill_player(ctx) ctx.last_death_link = time.time() -SNES_RECONNECT_DELAY = 5 - -# FXPAK Pro protocol memory mapping used by SNI -ROM_START = 0x000000 -WRAM_START = 0xF50000 -WRAM_SIZE = 0x20000 -SRAM_START = 0xE00000 - -ROMNAME_START = SRAM_START + 0x2000 -ROMNAME_SIZE = 0x15 - -INGAME_MODES = {0x07, 0x09, 0x0b} -ENDGAME_MODES = {0x19, 0x1a} -DEATH_MODES = {0x12} - -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - -RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes -RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte -ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes -ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte -SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte -SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte -SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte -SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte -SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes -SHOP_LEN = (len(Shops.shop_table) * 3) + 5 - -DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte - -# SM -SM_ROMNAME_START = ROM_START + 0x007FC0 - -SM_INGAME_MODES = {0x07, 0x09, 0x0b} -SM_ENDGAME_MODES = {0x26, 0x27} -SM_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} - -# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue -SM_RECV_QUEUE_START = SRAM_START + 0x2000 -SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 -SM_SEND_QUEUE_START = SRAM_START + 0x2700 -SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 -SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 - -SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277f04 # 1 byte -SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277f06 # 1 byte - -# SMZ3 -SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 - -SMZ3_INGAME_MODES = {0x07, 0x09, 0x0b} -SMZ3_ENDGAME_MODES = {0x26, 0x27} -SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} - -SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes -SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte -SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte - - -location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) - -location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), - "Blind's Hideout - Left": (0x11d, 0x20), - "Blind's Hideout - Right": (0x11d, 0x40), - "Blind's Hideout - Far Left": (0x11d, 0x80), - "Blind's Hideout - Far Right": (0x11d, 0x100), - 'Secret Passage': (0x55, 0x10), - 'Waterfall Fairy - Left': (0x114, 0x10), - 'Waterfall Fairy - Right': (0x114, 0x20), - "King's Tomb": (0x113, 0x10), - 'Floodgate Chest': (0x10b, 0x10), - "Link's House": (0x104, 0x10), - 'Kakariko Tavern': (0x103, 0x10), - 'Chicken House': (0x108, 0x10), - "Aginah's Cave": (0x10a, 0x10), - "Sahasrahla's Hut - Left": (0x105, 0x10), - "Sahasrahla's Hut - Middle": (0x105, 0x20), - "Sahasrahla's Hut - Right": (0x105, 0x40), - 'Kakariko Well - Top': (0x2f, 0x10), - 'Kakariko Well - Left': (0x2f, 0x20), - 'Kakariko Well - Middle': (0x2f, 0x40), - 'Kakariko Well - Right': (0x2f, 0x80), - 'Kakariko Well - Bottom': (0x2f, 0x100), - 'Lost Woods Hideout': (0xe1, 0x200), - 'Lumberjack Tree': (0xe2, 0x200), - 'Cave 45': (0x11b, 0x400), - 'Graveyard Cave': (0x11b, 0x200), - 'Checkerboard Cave': (0x126, 0x200), - 'Mini Moldorm Cave - Far Left': (0x123, 0x10), - 'Mini Moldorm Cave - Left': (0x123, 0x20), - 'Mini Moldorm Cave - Right': (0x123, 0x40), - 'Mini Moldorm Cave - Far Right': (0x123, 0x80), - 'Mini Moldorm Cave - Generous Guy': (0x123, 0x400), - 'Ice Rod Cave': (0x120, 0x10), - 'Bonk Rock Cave': (0x124, 0x10), - 'Desert Palace - Big Chest': (0x73, 0x10), - 'Desert Palace - Torch': (0x73, 0x400), - 'Desert Palace - Map Chest': (0x74, 0x10), - 'Desert Palace - Compass Chest': (0x85, 0x10), - 'Desert Palace - Big Key Chest': (0x75, 0x10), - 'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400), - 'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400), - 'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400), - 'Desert Palace - Boss': (0x33, 0x800), - 'Eastern Palace - Compass Chest': (0xa8, 0x10), - 'Eastern Palace - Big Chest': (0xa9, 0x10), - 'Eastern Palace - Dark Square Pot Key': (0xba, 0x400), - 'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400), - 'Eastern Palace - Cannonball Chest': (0xb9, 0x10), - 'Eastern Palace - Big Key Chest': (0xb8, 0x10), - 'Eastern Palace - Map Chest': (0xaa, 0x10), - 'Eastern Palace - Boss': (0xc8, 0x800), - 'Hyrule Castle - Boomerang Chest': (0x71, 0x10), - 'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400), - 'Hyrule Castle - Map Chest': (0x72, 0x10), - 'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400), - "Hyrule Castle - Zelda's Chest": (0x80, 0x10), - 'Hyrule Castle - Big Key Drop': (0x80, 0x400), - 'Sewers - Dark Cross': (0x32, 0x10), - 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), - 'Sewers - Secret Room - Left': (0x11, 0x10), - 'Sewers - Secret Room - Middle': (0x11, 0x20), - 'Sewers - Secret Room - Right': (0x11, 0x40), - 'Sanctuary': (0x12, 0x10), - 'Castle Tower - Room 03': (0xe0, 0x10), - 'Castle Tower - Dark Maze': (0xd0, 0x10), - 'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400), - 'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400), - 'Spectacle Rock Cave': (0xea, 0x400), - 'Paradox Cave Lower - Far Left': (0xef, 0x10), - 'Paradox Cave Lower - Left': (0xef, 0x20), - 'Paradox Cave Lower - Right': (0xef, 0x40), - 'Paradox Cave Lower - Far Right': (0xef, 0x80), - 'Paradox Cave Lower - Middle': (0xef, 0x100), - 'Paradox Cave Upper - Left': (0xff, 0x10), - 'Paradox Cave Upper - Right': (0xff, 0x20), - 'Spiral Cave': (0xfe, 0x10), - 'Tower of Hera - Basement Cage': (0x87, 0x400), - 'Tower of Hera - Map Chest': (0x77, 0x10), - 'Tower of Hera - Big Key Chest': (0x87, 0x10), - 'Tower of Hera - Compass Chest': (0x27, 0x20), - 'Tower of Hera - Big Chest': (0x27, 0x10), - 'Tower of Hera - Boss': (0x7, 0x800), - 'Hype Cave - Top': (0x11e, 0x10), - 'Hype Cave - Middle Right': (0x11e, 0x20), - 'Hype Cave - Middle Left': (0x11e, 0x40), - 'Hype Cave - Bottom': (0x11e, 0x80), - 'Hype Cave - Generous Guy': (0x11e, 0x400), - 'Peg Cave': (0x127, 0x400), - 'Pyramid Fairy - Left': (0x116, 0x10), - 'Pyramid Fairy - Right': (0x116, 0x20), - 'Brewery': (0x106, 0x10), - 'C-Shaped House': (0x11c, 0x10), - 'Chest Game': (0x106, 0x400), - 'Mire Shed - Left': (0x10d, 0x10), - 'Mire Shed - Right': (0x10d, 0x20), - 'Superbunny Cave - Top': (0xf8, 0x10), - 'Superbunny Cave - Bottom': (0xf8, 0x20), - 'Spike Cave': (0x117, 0x10), - 'Hookshot Cave - Top Right': (0x3c, 0x10), - 'Hookshot Cave - Top Left': (0x3c, 0x20), - 'Hookshot Cave - Bottom Right': (0x3c, 0x80), - 'Hookshot Cave - Bottom Left': (0x3c, 0x40), - 'Mimic Cave': (0x10c, 0x10), - 'Swamp Palace - Entrance': (0x28, 0x10), - 'Swamp Palace - Map Chest': (0x37, 0x10), - 'Swamp Palace - Pot Row Pot Key': (0x38, 0x400), - 'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400), - 'Swamp Palace - Hookshot Pot Key': (0x36, 0x400), - 'Swamp Palace - Big Chest': (0x36, 0x10), - 'Swamp Palace - Compass Chest': (0x46, 0x10), - 'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400), - 'Swamp Palace - Big Key Chest': (0x35, 0x10), - 'Swamp Palace - West Chest': (0x34, 0x10), - 'Swamp Palace - Flooded Room - Left': (0x76, 0x10), - 'Swamp Palace - Flooded Room - Right': (0x76, 0x20), - 'Swamp Palace - Waterfall Room': (0x66, 0x10), - 'Swamp Palace - Waterway Pot Key': (0x16, 0x400), - 'Swamp Palace - Boss': (0x6, 0x800), - "Thieves' Town - Big Key Chest": (0xdb, 0x20), - "Thieves' Town - Map Chest": (0xdb, 0x10), - "Thieves' Town - Compass Chest": (0xdc, 0x10), - "Thieves' Town - Ambush Chest": (0xcb, 0x10), - "Thieves' Town - Hallway Pot Key": (0xbc, 0x400), - "Thieves' Town - Spike Switch Pot Key": (0xab, 0x400), - "Thieves' Town - Attic": (0x65, 0x10), - "Thieves' Town - Big Chest": (0x44, 0x10), - "Thieves' Town - Blind's Cell": (0x45, 0x10), - "Thieves' Town - Boss": (0xac, 0x800), - 'Skull Woods - Compass Chest': (0x67, 0x10), - 'Skull Woods - Map Chest': (0x58, 0x20), - 'Skull Woods - Big Chest': (0x58, 0x10), - 'Skull Woods - Pot Prison': (0x57, 0x20), - 'Skull Woods - Pinball Room': (0x68, 0x10), - 'Skull Woods - Big Key Chest': (0x57, 0x10), - 'Skull Woods - West Lobby Pot Key': (0x56, 0x400), - 'Skull Woods - Bridge Room': (0x59, 0x10), - 'Skull Woods - Spike Corner Key Drop': (0x39, 0x400), - 'Skull Woods - Boss': (0x29, 0x800), - 'Ice Palace - Jelly Key Drop': (0x0e, 0x400), - 'Ice Palace - Compass Chest': (0x2e, 0x10), - 'Ice Palace - Conveyor Key Drop': (0x3e, 0x400), - 'Ice Palace - Freezor Chest': (0x7e, 0x10), - 'Ice Palace - Big Chest': (0x9e, 0x10), - 'Ice Palace - Iced T Room': (0xae, 0x10), - 'Ice Palace - Many Pots Pot Key': (0x9f, 0x400), - 'Ice Palace - Spike Room': (0x5f, 0x10), - 'Ice Palace - Big Key Chest': (0x1f, 0x10), - 'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400), - 'Ice Palace - Map Chest': (0x3f, 0x10), - 'Ice Palace - Boss': (0xde, 0x800), - 'Misery Mire - Big Chest': (0xc3, 0x10), - 'Misery Mire - Map Chest': (0xc3, 0x20), - 'Misery Mire - Main Lobby': (0xc2, 0x10), - 'Misery Mire - Bridge Chest': (0xa2, 0x10), - 'Misery Mire - Spikes Pot Key': (0xb3, 0x400), - 'Misery Mire - Spike Chest': (0xb3, 0x10), - 'Misery Mire - Fishbone Pot Key': (0xa1, 0x400), - 'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400), - 'Misery Mire - Compass Chest': (0xc1, 0x10), - 'Misery Mire - Big Key Chest': (0xd1, 0x10), - 'Misery Mire - Boss': (0x90, 0x800), - 'Turtle Rock - Compass Chest': (0xd6, 0x10), - 'Turtle Rock - Roller Room - Left': (0xb7, 0x10), - 'Turtle Rock - Roller Room - Right': (0xb7, 0x20), - 'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400), - 'Turtle Rock - Chain Chomps': (0xb6, 0x10), - 'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400), - 'Turtle Rock - Big Key Chest': (0x14, 0x10), - 'Turtle Rock - Big Chest': (0x24, 0x10), - 'Turtle Rock - Crystaroller Room': (0x4, 0x10), - 'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80), - 'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40), - 'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20), - 'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10), - 'Turtle Rock - Boss': (0xa4, 0x800), - 'Palace of Darkness - Shooter Room': (0x9, 0x10), - 'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20), - 'Palace of Darkness - Stalfos Basement': (0xa, 0x10), - 'Palace of Darkness - Big Key Chest': (0x3a, 0x10), - 'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10), - 'Palace of Darkness - Map Chest': (0x2b, 0x10), - 'Palace of Darkness - Compass Chest': (0x1a, 0x20), - 'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10), - 'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20), - 'Palace of Darkness - Dark Maze - Top': (0x19, 0x10), - 'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20), - 'Palace of Darkness - Big Chest': (0x1a, 0x10), - 'Palace of Darkness - Harmless Hellway': (0x1a, 0x40), - 'Palace of Darkness - Boss': (0x5a, 0x800), - 'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400), - "Ganons Tower - Bob's Torch": (0x8c, 0x400), - 'Ganons Tower - Hope Room - Left': (0x8c, 0x20), - 'Ganons Tower - Hope Room - Right': (0x8c, 0x40), - 'Ganons Tower - Tile Room': (0x8d, 0x10), - 'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10), - 'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20), - 'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40), - 'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80), - 'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400), - 'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10), - 'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20), - 'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40), - 'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80), - 'Ganons Tower - Map Chest': (0x8b, 0x10), - 'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400), - 'Ganons Tower - Firesnake Room': (0x7d, 0x10), - 'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10), - 'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20), - 'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40), - 'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80), - "Ganons Tower - Bob's Chest": (0x8c, 0x80), - 'Ganons Tower - Big Chest': (0x8c, 0x10), - 'Ganons Tower - Big Key Room - Left': (0x1c, 0x20), - 'Ganons Tower - Big Key Room - Right': (0x1c, 0x40), - 'Ganons Tower - Big Key Chest': (0x1c, 0x10), - 'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10), - 'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20), - 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), - 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), - 'Ganons Tower - Validation Chest': (0x4d, 0x10)} - -boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss', - 'Desert Palace - Boss', - 'Tower of Hera - Boss', - 'Palace of Darkness - Boss', - 'Swamp Palace - Boss', - 'Skull Woods - Boss', - "Thieves' Town - Boss", - 'Ice Palace - Boss', - 'Misery Mire - Boss', - 'Turtle Rock - Boss', - 'Sahasrahla'}} - -location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()} - -location_table_npc = {'Mushroom': 0x1000, - 'King Zora': 0x2, - 'Sahasrahla': 0x10, - 'Blacksmith': 0x400, - 'Magic Bat': 0x8000, - 'Sick Kid': 0x4, - 'Library': 0x80, - 'Potion Shop': 0x2000, - 'Old Man': 0x1, - 'Ether Tablet': 0x100, - 'Catfish': 0x20, - 'Stumpy': 0x8, - 'Bombos Tablet': 0x200} - -location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()} - -location_table_ow = {'Flute Spot': 0x2a, - 'Sunken Treasure': 0x3b, - "Zora's Ledge": 0x81, - 'Lake Hylia Island': 0x35, - 'Maze Race': 0x28, - 'Desert Ledge': 0x30, - 'Master Sword Pedestal': 0x80, - 'Spectacle Rock': 0x3, - 'Pyramid': 0x5b, - 'Digging Game': 0x68, - 'Bumper Cave Ledge': 0x4a, - 'Floating Island': 0x5} - -location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()} - -location_table_misc = {'Bottle Merchant': (0x3c9, 0x2), - 'Purple Chest': (0x3c9, 0x10), - "Link's Uncle": (0x3c6, 0x1), - 'Hobo': (0x3c9, 0x1)} - -location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} +_global_snes_reconnect_delay = 5 class SNESState(enum.IntEnum): @@ -607,13 +269,13 @@ class SNESState(enum.IntEnum): SNES_ATTACHED = 3 -def launch_sni(): - sni_path = Utils.get_options()["lttp_options"]["sni"] +def launch_sni() -> None: + sni_path = Utils.get_options()["sni_options"]["sni_path"] if not os.path.isdir(sni_path): sni_path = Utils.local_path(sni_path) if os.path.isdir(sni_path): - dir_entry: os.DirEntry + dir_entry: "os.DirEntry[str]" for dir_entry in os.scandir(sni_path): if dir_entry.is_file(): lower_file = dir_entry.name.lower() @@ -641,13 +303,13 @@ def launch_sni(): f"please start it yourself if it is not running") -async def _snes_connect(ctx: Context, address: str): +async def _snes_connect(ctx: SNIContext, address: str) -> WebSocketClientProtocol: address = f"ws://{address}" if "://" not in address else address snes_logger.info("Connecting to SNI at %s ..." % address) - seen_problems = set() - while 1: + seen_problems: typing.Set[str] = set() + while True: try: - snes_socket = await websockets.connect(address, ping_timeout=None, ping_interval=None) + snes_socket = await websockets_connect(address, ping_timeout=None, ping_interval=None) except Exception as e: problem = "%s" % e # only tell the user about new problems, otherwise silently lay in wait for a working connection @@ -664,15 +326,24 @@ async def _snes_connect(ctx: Context, address: str): return snes_socket -async def get_snes_devices(ctx: Context) -> typing.List[str]: +class SNESRequest(typing.TypedDict): + Opcode: str + Space: str + Operands: typing.List[str] + # TODO: When Python 3.11 is the lowest version supported, `Operands` can use `typing.NotRequired` (pep-0655) + # Then the `Operands` key doesn't need to be given for opcodes that don't use it. + + +async def get_snes_devices(ctx: SNIContext) -> typing.List[str]: socket = await _snes_connect(ctx, ctx.snes_address) # establish new connection to poll - DeviceList_Request = { + DeviceList_Request: SNESRequest = { "Opcode": "DeviceList", - "Space": "SNES" + "Space": "SNES", + "Operands": [] } await socket.send(dumps(DeviceList_Request)) - reply: dict = loads(await socket.recv()) + reply: typing.Dict[str, typing.Any] = loads(await socket.recv()) devices: typing.List[str] = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else [] if not devices: @@ -688,7 +359,7 @@ async def get_snes_devices(ctx: Context) -> typing.List[str]: return sorted(devices) -async def verify_snes_app(socket): +async def verify_snes_app(socket: WebSocketClientProtocol) -> None: AppVersion_Request = { "Opcode": "AppVersion", } @@ -699,8 +370,8 @@ async def verify_snes_app(socket): snes_logger.warning(f"Warning: Did not find SNI as the endpoint, instead {app} was found.") -async def snes_connect(ctx: Context, address, deviceIndex=-1): - global SNES_RECONNECT_DELAY +async def snes_connect(ctx: SNIContext, address: str, deviceIndex: int = -1) -> None: + global _global_snes_reconnect_delay if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED: if ctx.rom: snes_logger.error('Already connected to SNES, with rom loaded.') @@ -722,6 +393,7 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): if device_count == 1: device = devices[0] elif ctx.snes_reconnect_address: + assert ctx.snes_attached_device if ctx.snes_attached_device[1] in devices: device = ctx.snes_attached_device[1] else: @@ -746,7 +418,7 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): snes_logger.info("Attaching to " + device) - Attach_Request = { + Attach_Request: SNESRequest = { "Opcode": "Attach", "Space": "SNES", "Operands": [device] @@ -770,35 +442,37 @@ async def snes_connect(ctx: Context, address, deviceIndex=-1): if not ctx.snes_reconnect_address: snes_logger.error("Error connecting to snes (%s)" % e) else: - snes_logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s") + snes_logger.error(f"Error connecting to snes, attempt again in {_global_snes_reconnect_delay}s") asyncio.create_task(snes_autoreconnect(ctx)) - SNES_RECONNECT_DELAY *= 2 + _global_snes_reconnect_delay *= 2 else: - SNES_RECONNECT_DELAY = ctx.starting_reconnect_delay + _global_snes_reconnect_delay = ctx.starting_reconnect_delay snes_logger.info(f"Attached to {device}") -async def snes_disconnect(ctx: Context): +async def snes_disconnect(ctx: SNIContext) -> None: if ctx.snes_socket: if not ctx.snes_socket.closed: await ctx.snes_socket.close() ctx.snes_socket = None -async def snes_autoreconnect(ctx: Context): - await asyncio.sleep(SNES_RECONNECT_DELAY) +async def snes_autoreconnect(ctx: SNIContext) -> None: + await asyncio.sleep(_global_snes_reconnect_delay) if ctx.snes_reconnect_address and ctx.snes_socket is None: await snes_connect(ctx, ctx.snes_reconnect_address) -async def snes_recv_loop(ctx: Context): +async def snes_recv_loop(ctx: SNIContext) -> None: try: + if ctx.snes_socket is None: + raise Exception("invalid context state - snes_socket not connected") async for msg in ctx.snes_socket: - ctx.snes_recv_queue.put_nowait(msg) + ctx.snes_recv_queue.put_nowait(typing.cast(bytes, msg)) snes_logger.warning("Snes disconnected") except Exception as e: - if not isinstance(e, websockets.WebSocketException): + if not isinstance(e, WebSocketException): snes_logger.exception(e) snes_logger.error("Lost connection to the snes, type /snes to reconnect") finally: @@ -813,28 +487,33 @@ async def snes_recv_loop(ctx: Context): ctx.rom = None if ctx.snes_reconnect_address: - snes_logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s") + snes_logger.info(f"...reconnecting in {_global_snes_reconnect_delay}s") asyncio.create_task(snes_autoreconnect(ctx)) -async def snes_read(ctx: Context, address, size): +async def snes_read(ctx: SNIContext, address: int, size: int) -> typing.Optional[bytes]: try: await ctx.snes_request_lock.acquire() - if ctx.snes_state != SNESState.SNES_ATTACHED or ctx.snes_socket is None or not ctx.snes_socket.open or ctx.snes_socket.closed: + if ( + ctx.snes_state != SNESState.SNES_ATTACHED or + ctx.snes_socket is None or + not ctx.snes_socket.open or + ctx.snes_socket.closed + ): return None - GetAddress_Request = { + GetAddress_Request: SNESRequest = { "Opcode": "GetAddress", "Space": "SNES", "Operands": [hex(address)[2:], hex(size)[2:]] } try: await ctx.snes_socket.send(dumps(GetAddress_Request)) - except websockets.ConnectionClosed: + except ConnectionClosed: return None - data = bytes() + data: bytes = bytes() while len(data) < size: try: data += await asyncio.wait_for(ctx.snes_recv_queue.get(), 5) @@ -855,7 +534,7 @@ async def snes_read(ctx: Context, address, size): ctx.snes_request_lock.release() -async def snes_write(ctx: Context, write_list): +async def snes_write(ctx: SNIContext, write_list: typing.List[typing.Tuple[int, bytes]]) -> bool: try: await ctx.snes_request_lock.acquire() @@ -863,16 +542,18 @@ async def snes_write(ctx: Context, write_list): not ctx.snes_socket.open or ctx.snes_socket.closed: return False - PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} + PutAddress_Request: SNESRequest = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} try: for address, data in write_list: PutAddress_Request['Operands'] = [hex(address)[2:], hex(len(data))[2:]] + # REVIEW: above: `if snes_socket is None: return False` + # Does it need to be checked again? if ctx.snes_socket is not None: await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(data) else: snes_logger.warning(f"Could not send data to SNES: {data}") - except websockets.ConnectionClosed: + except ConnectionClosed: return False return True @@ -880,7 +561,7 @@ async def snes_write(ctx: Context, write_list): ctx.snes_request_lock.release() -def snes_buffered_write(ctx: Context, address, data): +def snes_buffered_write(ctx: SNIContext, address: int, data: bytes) -> None: if ctx.snes_write_buffer and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address: # append to existing write command, bundling them ctx.snes_write_buffer[-1] = (ctx.snes_write_buffer[-1][0], ctx.snes_write_buffer[-1][1] + data) @@ -888,7 +569,7 @@ def snes_buffered_write(ctx: Context, address, data): ctx.snes_write_buffer.append((address, data)) -async def snes_flush_writes(ctx: Context): +async def snes_flush_writes(ctx: SNIContext) -> None: if not ctx.snes_write_buffer: return @@ -897,142 +578,7 @@ async def snes_flush_writes(ctx: Context): await snes_write(ctx, writes) -async def track_locations(ctx: Context, roomid, roomdata): - new_locations = [] - - def new_check(location_id): - new_locations.append(location_id) - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info( - f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - - try: - shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN) - shop_data_changed = False - shop_data = list(shop_data) - for cnt, b in enumerate(shop_data): - location = Shops.SHOP_ID_START + cnt - if int(b) and location not in ctx.locations_checked: - new_check(location) - if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \ - and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot: - if not int(b): - shop_data[cnt] += 1 - shop_data_changed = True - if shop_data_changed: - snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data)) - except Exception as e: - snes_logger.info(f"Exception: {e}") - - for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): - try: - if location_id not in ctx.locations_checked and loc_roomid == roomid and \ - (roomdata << 4) & loc_mask != 0: - new_check(location_id) - except Exception as e: - snes_logger.exception(f"Exception: {e}") - - uw_begin = 0x129 - ow_end = uw_end = 0 - uw_unchecked = {} - uw_checked = {} - for location, (roomid, mask) in location_table_uw.items(): - location_id = Regions.lookup_name_to_id[location] - if location_id not in ctx.locations_checked: - uw_unchecked[location_id] = (roomid, mask) - uw_begin = min(uw_begin, roomid) - uw_end = max(uw_end, roomid + 1) - if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ - and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - uw_begin = min(uw_begin, roomid) - uw_end = max(uw_end, roomid + 1) - uw_checked[location_id] = (roomid, mask) - - if uw_begin < uw_end: - uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2) - if uw_data is not None: - for location_id, (roomid, mask) in uw_unchecked.items(): - offset = (roomid - uw_begin) * 2 - roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) - if roomdata & mask != 0: - new_check(location_id) - if uw_checked: - uw_data = list(uw_data) - for location_id, (roomid, mask) in uw_checked.items(): - offset = (roomid - uw_begin) * 2 - roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) - roomdata |= mask - uw_data[offset] = roomdata & 0xFF - uw_data[offset + 1] = roomdata >> 8 - snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data)) - - ow_begin = 0x82 - ow_unchecked = {} - ow_checked = {} - for location_id, screenid in location_table_ow_id.items(): - if location_id not in ctx.locations_checked: - ow_unchecked[location_id] = screenid - ow_begin = min(ow_begin, screenid) - ow_end = max(ow_end, screenid + 1) - if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - ow_checked[location_id] = screenid - - if ow_begin < ow_end: - ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin) - if ow_data is not None: - for location_id, screenid in ow_unchecked.items(): - if ow_data[screenid - ow_begin] & 0x40 != 0: - new_check(location_id) - if ow_checked: - ow_data = list(ow_data) - for location_id, screenid in ow_checked.items(): - ow_data[screenid - ow_begin] |= 0x40 - snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data)) - - if not ctx.locations_checked.issuperset(location_table_npc_id): - npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) - if npc_data is not None: - npc_value_changed = False - npc_value = npc_data[0] | (npc_data[1] << 8) - for location_id, mask in location_table_npc_id.items(): - if npc_value & mask != 0 and location_id not in ctx.locations_checked: - new_check(location_id) - if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ - and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ - and ctx.locations_info[location_id].player != ctx.slot: - npc_value |= mask - npc_value_changed = True - if npc_value_changed: - npc_data = bytes([npc_value & 0xFF, npc_value >> 8]) - snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data) - - if not ctx.locations_checked.issuperset(location_table_misc_id): - misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) - if misc_data is not None: - misc_data = list(misc_data) - misc_data_changed = False - for location_id, (offset, mask) in location_table_misc_id.items(): - assert (0x3c6 <= offset <= 0x3c9) - if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: - new_check(location_id) - if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \ - and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot: - misc_data_changed = True - misc_data[offset - 0x3c6] |= mask - if misc_data_changed: - snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data)) - - - if new_locations: - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) - await snes_flush_writes(ctx) - - -async def game_watcher(ctx: Context): - prev_game_timer = 0 +async def game_watcher(ctx: SNIContext) -> None: perf_counter = time.perf_counter() while not ctx.exit_event.is_set(): try: @@ -1041,54 +587,24 @@ async def game_watcher(ctx: Context): pass ctx.watcher_event.clear() - if not ctx.rom: + if not ctx.rom or not ctx.client_handler: ctx.finished_game = False ctx.death_link_allow_survive = False - from worlds.dkc3.Client import dkc3_rom_init - init_handled = await dkc3_rom_init(ctx) - if not init_handled: - from worlds.smw.Client import smw_rom_init - init_handled = await smw_rom_init(ctx) - if not init_handled: - game_name = await snes_read(ctx, SM_ROMNAME_START, 5) - if game_name is None: - continue - elif game_name[:2] == b"SM": - ctx.game = GAME_SM - # versions lower than 0.3.0 dont have item handling flag nor remote item support - romVersion = int(game_name[2:5].decode('UTF-8')) - if romVersion < 30: - ctx.items_handling = 0b001 # full local - else: - item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1) - ctx.items_handling = 0b001 if item_handling is None else item_handling[0] - else: - game_name = await snes_read(ctx, SMZ3_ROMNAME_START, 3) - if game_name == b"ZSM": - ctx.game = GAME_SMZ3 - ctx.items_handling = 0b101 # local items and remote start inventory - else: - ctx.game = GAME_ALTTP - ctx.items_handling = 0b001 # full local + from worlds.AutoSNIClient import AutoSNIClientRegister + ctx.client_handler = await AutoSNIClientRegister.get_handler(ctx) - rom = await snes_read(ctx, SM_ROMNAME_START if ctx.game == GAME_SM else SMZ3_ROMNAME_START if ctx.game == GAME_SMZ3 else ROMNAME_START, ROMNAME_SIZE) - if rom is None or rom == bytes([0] * ROMNAME_SIZE): - continue + if not ctx.client_handler: + continue - ctx.rom = rom - if ctx.game != GAME_SMZ3: - 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: - ctx.allow_collect = bool(death_link[0] & 0b100) - ctx.death_link_allow_survive = bool(death_link[0] & 0b10) - 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() - ctx.locations_info = {} - ctx.prev_rom = ctx.rom + if not ctx.rom: + continue + + if not ctx.prev_rom or ctx.prev_rom != ctx.rom: + ctx.locations_checked = set() + ctx.locations_scouted = set() + ctx.locations_info = {} + ctx.prev_rom = ctx.rom if ctx.awaiting_rom: await ctx.server_auth(False) @@ -1096,234 +612,40 @@ async def game_watcher(ctx: Context): snes_logger.warning("ROM detected but no active multiworld server connection. " + "Connect using command: /connect server:port") - if ctx.auth and ctx.auth != ctx.rom: + if not ctx.client_handler: + continue + + rom_validated = await ctx.client_handler.validate_rom(ctx) + + if not rom_validated or (ctx.auth and ctx.auth != ctx.rom): snes_logger.warning("ROM change detected, please reconnect to the multiworld server") await ctx.disconnect() + ctx.client_handler = None + ctx.rom = None + ctx.command_processor(ctx).connect_to_snes() + continue - if ctx.game == GAME_ALTTP: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): - currently_dead = gamemode[0] in DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) + delay = 7 if ctx.slow_mode else 0 + if time.perf_counter() - perf_counter < delay: + continue - gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) - game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) - if gamemode is None or gameend is None or game_timer is None or \ - (gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES): - continue + perf_counter = time.perf_counter() - delay = 7 if ctx.slow_mode else 2 - if gameend[0]: - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - - if time.perf_counter() - perf_counter < delay: - continue - else: - perf_counter = time.perf_counter() - else: - game_timer = game_timer[0] | (game_timer[1] << 8) | (game_timer[2] << 16) | (game_timer[3] << 24) - if abs(game_timer - prev_game_timer) < (delay * 60): - continue - else: - prev_game_timer = game_timer - - if gamemode in ENDGAME_MODES: # triforce room and credits - continue - - data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] - roomid = data[4] | (data[5] << 8) - roomdata = data[6] - scout_location = data[7] - - if recv_index < len(ctx.items_received) and recv_item == 0: - item = ctx.items_received[recv_index] - recv_index += 1 - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), - color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], recv_index, len(ctx.items_received))) - - snes_buffered_write(ctx, RECV_PROGRESS_ADDR, - bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - snes_buffered_write(ctx, RECV_ITEM_ADDR, - bytes([item.item])) - snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, - bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0])) - if scout_location > 0 and scout_location in ctx.locations_info: - snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, - bytes([scout_location])) - snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, - bytes([ctx.locations_info[scout_location].item])) - snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, - bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)])) - - await snes_flush_writes(ctx) - - if scout_location > 0 and scout_location not in ctx.locations_scouted: - ctx.locations_scouted.add(scout_location) - await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) - await track_locations(ctx, roomid, roomdata) - elif ctx.game == GAME_SM: - if ctx.server is None or ctx.slot is None: - # not successfully connected to a multiworld server, cannot process the game sending items - continue - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): - currently_dead = gamemode[0] in SM_DEATH_MODES - await ctx.handle_deathlink_state(currently_dead) - if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES: - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - continue - - data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT - - while (recv_index < recv_item): - itemAdress = recv_index * 8 - message = await snes_read(ctx, SM_SEND_QUEUE_START + itemAdress, 8) - # worldId = message[0] | (message[1] << 8) # unused - # itemId = message[2] | (message[3] << 8) # unused - itemIndex = (message[4] | (message[5] << 8)) >> 3 - - recv_index += 1 - snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, - bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - - from worlds.sm import locations_start_id - location_id = locations_start_id + itemIndex - - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info( - f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - - data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) - if data is None: - continue - - itemOutPtr = data[0] | (data[1] << 8) - - from worlds.sm import items_start_id - from worlds.sm import locations_start_id - if itemOutPtr < len(ctx.items_received): - item = ctx.items_received[itemOutPtr] - itemId = item.item - items_start_id - if bool(ctx.items_handling & 0b010): - locationId = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF - else: - locationId = 0x00 #backward compat - - playerID = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SM_RECV_QUEUE_START + itemOutPtr * 4, bytes( - [playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, locationId & 0xFF])) - itemOutPtr += 1 - snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, - bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), - color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) - await snes_flush_writes(ctx) - elif ctx.game == GAME_SMZ3: - if ctx.server is None or ctx.slot is None: - # not successfully connected to a multiworld server, cannot process the game sending items - continue - currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) - if (currentGame is not None): - if (currentGame[0] != 0): - gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) - endGameModes = SM_ENDGAME_MODES - else: - gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) - endGameModes = ENDGAME_MODES - - if gamemode is not None and (gamemode[0] in endGameModes): - if not ctx.finished_game: - await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) - ctx.finished_game = True - continue - - data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4) - if data is None: - continue - - recv_index = data[0] | (data[1] << 8) - recv_item = data[2] | (data[3] << 8) - - while (recv_index < recv_item): - itemAdress = recv_index * 8 - message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + itemAdress, 8) - # worldId = message[0] | (message[1] << 8) # unused - # itemId = message[2] | (message[3] << 8) # unused - isZ3Item = ((message[5] & 0x80) != 0) - maskedPart = (message[5] & 0x7F) if isZ3Item else message[5] - itemIndex = ((message[4] | (maskedPart << 8)) >> 3) + (256 if isZ3Item else 0) - - recv_index += 1 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) - - from worlds.smz3.TotalSMZ3.Location import locations_start_id - from worlds.smz3 import convertLocSMZ3IDToAPID - location_id = locations_start_id + convertLocSMZ3IDToAPID(itemIndex) - - ctx.locations_checked.add(location_id) - location = ctx.location_names[location_id] - snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') - await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) - - data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4) - if data is None: - continue - - # recv_itemOutPtr = data[0] | (data[1] << 8) # unused - itemOutPtr = data[2] | (data[3] << 8) - - from worlds.smz3.TotalSMZ3.Item import items_start_id - if itemOutPtr < len(ctx.items_received): - item = ctx.items_received[itemOutPtr] - itemId = item.item - items_start_id - - playerID = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + itemOutPtr * 4, bytes([playerID & 0xFF, (playerID >> 8) & 0xFF, itemId & 0xFF, (itemId >> 8) & 0xFF])) - itemOutPtr += 1 - snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([itemOutPtr & 0xFF, (itemOutPtr >> 8) & 0xFF])) - logging.info('Received %s from %s (%s) (%d/%d in list)' % ( - color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), - ctx.location_names[item.location], itemOutPtr, len(ctx.items_received))) - await snes_flush_writes(ctx) - elif ctx.game == GAME_DKC3: - from worlds.dkc3.Client import dkc3_game_watcher - await dkc3_game_watcher(ctx) - elif ctx.game == GAME_SMW: - from worlds.smw.Client import smw_game_watcher - await smw_game_watcher(ctx) + await ctx.client_handler.game_watcher(ctx) -async def run_game(romfile): - auto_start = Utils.get_options()["lttp_options"].get("rom_start", True) +async def run_game(romfile: str) -> None: + auto_start = typing.cast(typing.Union[bool, str], + Utils.get_options()["sni_options"].get("snes_rom_start", True)) if auto_start is True: import webbrowser webbrowser.open(romfile) - elif os.path.isfile(auto_start): + elif isinstance(auto_start, str) and os.path.isfile(auto_start): subprocess.Popen([auto_start, romfile], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -async def main(): +async def main() -> None: multiprocessing.freeze_support() parser = get_base_parser() parser.add_argument('diff_file', default="", type=str, nargs="?", @@ -1350,12 +672,13 @@ async def main(): time.sleep(3) sys.exit() elif args.diff_file.endswith(".aplttp"): + from worlds.alttp.Client import get_alttp_settings adjustedromfile, adjusted = get_alttp_settings(romfile) 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) + ctx = SNIContext(args.snes, args.connect, args.password) if ctx.server_task is None: ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") @@ -1376,132 +699,6 @@ async def main(): await ctx.shutdown() -def get_alttp_settings(romfile: str): - lastSettings = Utils.get_adjuster_settings(GAME_ALTTP) - adjustedromfile = '' - if lastSettings: - choice = 'no' - if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply: - - whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap", - "uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes", - "reduceflashing", "deathlink", "allowcollect"} - printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist} - if hasattr(lastSettings, "sprite_pool"): - sprite_pool = {} - for sprite in lastSettings.sprite_pool: - if sprite in sprite_pool: - sprite_pool[sprite] += 1 - else: - sprite_pool[sprite] = 1 - if sprite_pool: - printed_options["sprite_pool"] = sprite_pool - import pprint - - if gui_enabled: - - try: - from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button - applyPromptWindow = Tk() - except Exception as e: - logging.error('Could not load tkinter, which is likely not installed.') - return '', False - - applyPromptWindow.resizable(False, False) - applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick()) - logo = PhotoImage(file=Utils.local_path('data', 'icon.png')) - applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo) - applyPromptWindow.wm_title("Last adjuster settings LttP") - - label = LabelFrame(applyPromptWindow, - text='Last used adjuster settings were found. Would you like to apply these?') - label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5) - label.grid_columnconfigure(0, weight=1) - label.grid_columnconfigure(1, weight=1) - label.grid_columnconfigure(2, weight=1) - label.grid_columnconfigure(3, weight=1) - - def onButtonClick(answer: str = 'no'): - setattr(onButtonClick, 'choice', answer) - applyPromptWindow.destroy() - - framedOptions = Frame(label) - framedOptions.grid(column=0, columnspan=4, row=0) - framedOptions.grid_columnconfigure(0, weight=1) - framedOptions.grid_columnconfigure(1, weight=1) - framedOptions.grid_columnconfigure(2, weight=1) - curRow = 0 - curCol = 0 - for name, value in printed_options.items(): - Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5) - if (curCol == 2): - curRow += 1 - curCol = 0 - else: - curCol += 1 - - yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10) - yesButton.grid(column=0, row=1) - noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10) - noButton.grid(column=1, row=1) - alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10) - alwaysButton.grid(column=2, row=1) - neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10) - neverButton.grid(column=3, row=1) - - Utils.tkinter_center_window(applyPromptWindow) - applyPromptWindow.mainloop() - choice = getattr(onButtonClick, 'choice') - else: - choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n" - f"{pprint.pformat(printed_options)}\n" - f"Enter yes, no, always or never: ") - if choice and choice.startswith("y"): - choice = 'yes' - elif choice and "never" in choice: - choice = 'no' - lastSettings.auto_apply = 'never' - Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) - elif choice and "always" in choice: - choice = 'yes' - lastSettings.auto_apply = 'always' - Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) - else: - choice = 'no' - elif 'never' in lastSettings.auto_apply: - choice = 'no' - elif 'always' in lastSettings.auto_apply: - choice = 'yes' - - if 'yes' in choice: - from worlds.alttp.Rom import get_base_rom_path - lastSettings.rom = romfile - lastSettings.baserom = get_base_rom_path() - lastSettings.world = None - - if hasattr(lastSettings, "sprite_pool"): - from LttPAdjuster import AdjusterWorld - lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool")) - - adjusted = True - import LttPAdjuster - _, adjustedromfile = LttPAdjuster.adjust(lastSettings) - - if hasattr(lastSettings, "world"): - delattr(lastSettings, "world") - else: - adjusted = False - if adjusted: - try: - shutil.move(adjustedromfile, romfile) - adjustedromfile = romfile - except Exception as e: - logging.exception(e) - else: - adjusted = False - return adjustedromfile, adjusted - - if __name__ == '__main__': colorama.init() asyncio.run(main()) diff --git a/Starcraft2Client.py b/Starcraft2Client.py index de0a90411e..7431b6ea61 100644 --- a/Starcraft2Client.py +++ b/Starcraft2Client.py @@ -155,7 +155,9 @@ class SC2Context(CommonContext): items_handling = 0b111 difficulty = -1 all_in_choice = 0 + mission_order = 0 mission_req_table: typing.Dict[str, MissionInfo] = {} + final_mission: int = 29 announcements = queue.Queue() sc2_run_task: typing.Optional[asyncio.Task] = None missions_unlocked: bool = False # allow launching missions ignoring requirements @@ -180,9 +182,15 @@ class SC2Context(CommonContext): self.difficulty = args["slot_data"]["game_difficulty"] self.all_in_choice = args["slot_data"]["all_in_map"] slot_req_table = args["slot_data"]["mission_req"] + # Maintaining backwards compatibility with older slot data self.mission_req_table = { - mission: MissionInfo(**slot_req_table[mission]) for mission in slot_req_table + mission: MissionInfo( + **{field: value for field, value in mission_info.items() if field in MissionInfo._fields} + ) + for mission, mission_info in slot_req_table.items() } + self.mission_order = args["slot_data"].get("mission_order", 0) + self.final_mission = args["slot_data"].get("final_mission", 29) self.build_location_to_mission_mapping() @@ -304,7 +312,6 @@ class SC2Context(CommonContext): self.refresh_from_launching = True self.mission_panel.clear_widgets() - if self.ctx.mission_req_table: self.last_checked_locations = self.ctx.checked_locations.copy() self.first_check = False @@ -322,17 +329,20 @@ class SC2Context(CommonContext): for category in categories: category_panel = MissionCategory() + if category.startswith('_'): + category_display_name = '' + else: + category_display_name = category category_panel.add_widget( - Label(text=category, size_hint_y=None, height=50, outline_width=1)) + Label(text=category_display_name, size_hint_y=None, height=50, outline_width=1)) for mission in categories[category]: text: str = mission tooltip: str = "" - + mission_id: int = self.ctx.mission_req_table[mission].id # Map has uncollected locations if mission in unfinished_missions: text = f"[color=6495ED]{text}[/color]" - elif mission in available_missions: text = f"[color=FFFFFF]{text}[/color]" # Map requirements not met @@ -351,6 +361,16 @@ class SC2Context(CommonContext): remaining_location_names: typing.List[str] = [ self.ctx.location_names[loc] for loc in self.ctx.locations_for_mission(mission) if loc in self.ctx.missing_locations] + + if mission_id == self.ctx.final_mission: + if mission in available_missions: + text = f"[color=FFBC95]{mission}[/color]" + else: + text = f"[color=D0C0BE]{mission}[/color]" + if tooltip: + tooltip += "\n" + tooltip += "Final Mission" + if remaining_location_names: if tooltip: tooltip += "\n" @@ -360,7 +380,7 @@ class SC2Context(CommonContext): mission_button = MissionButton(text=text, size_hint_y=None, height=50) mission_button.tooltip_text = tooltip mission_button.bind(on_press=self.mission_callback) - self.mission_id_to_button[self.ctx.mission_req_table[mission].id] = mission_button + self.mission_id_to_button[mission_id] = mission_button category_panel.add_widget(mission_button) category_panel.add_widget(Label(text="")) @@ -469,6 +489,9 @@ wol_default_categories = [ "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Rebellion", "Prophecy", "Prophecy", "Prophecy", "Prophecy", "Char", "Char", "Char", "Char" ] +wol_default_category_names = [ + "Mar Sara", "Colonist", "Artifact", "Covert", "Rebellion", "Prophecy", "Char" +] def calculate_items(items: typing.List[NetworkItem]) -> typing.List[int]: @@ -586,7 +609,7 @@ class ArchipelagoBot(sc2.bot_ai.BotAI): if self.can_read_game: if game_state & (1 << 1) and not self.mission_completed: - if self.mission_id != 29: + if self.mission_id != self.ctx.final_mission: print("Mission Completed") await self.ctx.send_msgs( [{"cmd": 'LocationChecks', @@ -742,13 +765,14 @@ def calc_available_missions(ctx: SC2Context, unlocks=None): return available_missions -def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete): +def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete: int): """Returns a bool signifying if the mission has all requirements complete and can be done Arguments: ctx -- instance of SC2Context locations_to_check -- the mission string name to check missions_complete -- an int of how many missions have been completed + mission_path -- a list of missions that have already been checked """ if len(ctx.mission_req_table[mission_name].required_world) >= 1: # A check for when the requirements are being or'd @@ -766,7 +790,18 @@ def mission_reqs_completed(ctx: SC2Context, mission_name: str, missions_complete else: req_success = False + # Grid-specific logic (to avoid long path checks and infinite recursion) + if ctx.mission_order in (3, 4): + if req_success: + return True + else: + if req_mission is ctx.mission_req_table[mission_name].required_world[-1]: + return False + else: + continue + # Recursively check required mission to see if it's requirements are met, in case !collect has been done + # Skipping recursive check on Grid settings to speed up checks and avoid infinite recursion if not mission_reqs_completed(ctx, list(ctx.mission_req_table)[req_mission - 1], missions_complete): if not ctx.mission_req_table[mission_name].or_requirements: return False diff --git a/Utils.py b/Utils.py index d28834b698..e0c86ddb39 100644 --- a/Utils.py +++ b/Utils.py @@ -141,7 +141,7 @@ def user_path(*path: str) -> str: return os.path.join(user_path.cached_path, *path) -def output_path(*path: str): +def output_path(*path: str) -> str: if hasattr(output_path, 'cached_path'): return os.path.join(output_path.cached_path, *path) output_path.cached_path = user_path(get_options()["general_options"]["output_path"]) @@ -231,20 +231,21 @@ def get_default_options() -> OptionsType: }, "factorio_options": { "executable": os.path.join("factorio", "bin", "x64", "factorio"), + "filter_item_sends": False, + "bridge_chat_out": True, + }, + "sni_options": { + "sni": "SNI", + "snes_rom_start": True, }, "sm_options": { "rom_file": "Super Metroid (JU).sfc", - "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", - "rom_start": True, - }, "server_options": { "host": None, @@ -287,13 +288,9 @@ def get_default_options() -> OptionsType: }, "dkc3_options": { "rom_file": "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc", - "sni": "SNI", - "rom_start": True, }, "smw_options": { "rom_file": "Super Mario World (USA).sfc", - "sni": "SNI", - "rom_start": True, }, "zillion_options": { "rom_file": "Zillion (UE) [!].sms", diff --git a/ZillionClient.py b/ZillionClient.py index dee5c2b756..e2ce697c8a 100644 --- a/ZillionClient.py +++ b/ZillionClient.py @@ -1,7 +1,7 @@ import asyncio import base64 import platform -from typing import Any, Coroutine, Dict, Optional, Type, cast +from typing import Any, ClassVar, Coroutine, Dict, List, Optional, Protocol, Tuple, Type, cast # CommonClient import first to trigger ModuleUpdater from CommonClient import CommonContext, server_loop, gui_enabled, \ @@ -18,7 +18,7 @@ from zilliandomizer.options import Chars from zilliandomizer.patch import RescueInfo from worlds.zillion.id_maps import make_id_to_others -from worlds.zillion.config import base_id +from worlds.zillion.config import base_id, zillion_map class ZillionCommandProcessor(ClientCommandProcessor): @@ -29,6 +29,18 @@ class ZillionCommandProcessor(ClientCommandProcessor): logger.info("ready to look for game") self.ctx.look_for_retroarch.set() + def _cmd_map(self) -> None: + """ Toggle view of the map tracker. """ + self.ctx.ui_toggle_map() + + +class ToggleCallback(Protocol): + def __call__(self) -> None: ... + + +class SetRoomCallback(Protocol): + def __call__(self, rooms: List[List[int]]) -> None: ... + class ZillionContext(CommonContext): game = "Zillion" @@ -46,6 +58,8 @@ class ZillionContext(CommonContext): start_char: Chars = "JJ" rescues: Dict[int, RescueInfo] = {} loc_mem_to_id: Dict[int, int] = {} + got_room_info: asyncio.Event + """ flag for connected to server """ got_slot_data: asyncio.Event """ serves as a flag for whether I am logged in to the server """ @@ -59,13 +73,20 @@ class ZillionContext(CommonContext): As a workaround, we don't look for RetroArch until this event is set. """ + ui_toggle_map: ToggleCallback + ui_set_rooms: SetRoomCallback + """ parameter is y 16 x 8 numbers to show in each room """ + def __init__(self, server_address: str, password: str) -> None: super().__init__(server_address, password) self.from_game = asyncio.Queue() self.to_game = asyncio.Queue() + self.got_room_info = asyncio.Event() self.got_slot_data = asyncio.Event() + self.ui_toggle_map = lambda: None + self.ui_set_rooms = lambda rooms: None self.look_for_retroarch = asyncio.Event() if platform.system() != "Windows": @@ -112,6 +133,10 @@ class ZillionContext(CommonContext): # override def run_gui(self) -> None: from kvui import GameManager + from kivy.core.text import Label as CoreLabel + from kivy.graphics import Ellipse, Color, Rectangle + from kivy.uix.layout import Layout + from kivy.uix.widget import Widget class ZillionManager(GameManager): logging_pairs = [ @@ -119,12 +144,76 @@ class ZillionContext(CommonContext): ] base_title = "Archipelago Zillion Client" + class MapPanel(Widget): + MAP_WIDTH: ClassVar[int] = 281 + + _number_textures: List[Any] = [] + rooms: List[List[int]] = [] + + def __init__(self, **kwargs: Any) -> None: + super().__init__(**kwargs) + + self.rooms = [[0 for _ in range(8)] for _ in range(16)] + + self._make_numbers() + self.update_map() + + self.bind(pos=self.update_map) + # self.bind(size=self.update_bg) + + def _make_numbers(self) -> None: + self._number_textures = [] + for n in range(10): + label = CoreLabel(text=str(n), font_size=22, color=(0.1, 0.9, 0, 1)) + label.refresh() + self._number_textures.append(label.texture) + + def update_map(self, *args: Any) -> None: + self.canvas.clear() + + with self.canvas: + Color(1, 1, 1, 1) + Rectangle(source=zillion_map, + pos=self.pos, + size=(ZillionManager.MapPanel.MAP_WIDTH, + int(ZillionManager.MapPanel.MAP_WIDTH * 1.456))) # aspect ratio of that image + for y in range(16): + for x in range(8): + num = self.rooms[15 - y][x] + if num > 0: + Color(0, 0, 0, 0.4) + pos = [self.pos[0] + 17 + x * 32, self.pos[1] + 14 + y * 24] + Ellipse(size=[22, 22], pos=pos) + Color(1, 1, 1, 1) + pos = [self.pos[0] + 22 + x * 32, self.pos[1] + 12 + y * 24] + num_texture = self._number_textures[num] + Rectangle(texture=num_texture, size=num_texture.size, pos=pos) + + def build(self) -> Layout: + container = super().build() + self.map_widget = ZillionManager.MapPanel(size_hint_x=None, width=0) + self.main_area_container.add_widget(self.map_widget) + return container + + def toggle_map_width(self) -> None: + if self.map_widget.width == 0: + self.map_widget.width = ZillionManager.MapPanel.MAP_WIDTH + else: + self.map_widget.width = 0 + self.container.do_layout() + + def set_rooms(self, rooms: List[List[int]]) -> None: + self.map_widget.rooms = rooms + self.map_widget.update_map() + self.ui = ZillionManager(self) - run_co: Coroutine[Any, Any, None] = self.ui.async_run() # type: ignore - # kivy types missing + self.ui_toggle_map = lambda: self.ui.toggle_map_width() + self.ui_set_rooms = lambda rooms: self.ui.set_rooms(rooms) + run_co: Coroutine[Any, Any, None] = self.ui.async_run() self.ui_task = asyncio.create_task(run_co, name="UI") def on_package(self, cmd: str, args: Dict[str, Any]) -> None: + self.room_item_numbers_to_ui() if cmd == "Connected": logger.info("logged in to Archipelago server") if "slot_data" not in args: @@ -185,6 +274,24 @@ class ZillionContext(CommonContext): logger.info("received door data from server") doors = base64.b64decode(doors_b64) self.to_game.put_nowait(events.DoorEventToGame(doors)) + elif cmd == "RoomInfo": + self.seed_name = args["seed_name"] + self.got_room_info.set() + + def room_item_numbers_to_ui(self) -> None: + rooms = [[0 for _ in range(8)] for _ in range(16)] + for loc_id in self.missing_locations: + loc_id_small = loc_id - base_id + loc_name = id_to_loc[loc_id_small] + y = ord(loc_name[0]) - 65 + x = ord(loc_name[2]) - 49 + if y == 9 and x == 5: + # don't show main computer in numbers + continue + assert (0 <= y < 16) and (0 <= x < 8), f"invalid index from location name {loc_name}" + rooms[y][x] += 1 + # TODO: also add locations with locals lost from loading save state or reset + self.ui_set_rooms(rooms) def process_from_game_queue(self) -> None: if self.from_game.qsize(): @@ -238,6 +345,24 @@ class ZillionContext(CommonContext): self.next_item = len(self.items_received) +def name_seed_from_ram(data: bytes) -> Tuple[str, str]: + """ returns player name, and end of seed string """ + if len(data) == 0: + # no connection to game + return "", "xxx" + null_index = data.find(b'\x00') + if null_index == -1: + logger.warning(f"invalid game id in rom {repr(data)}") + null_index = len(data) + name = data[:null_index].decode() + null_index_2 = data.find(b'\x00', null_index + 1) + if null_index_2 == -1: + null_index_2 = len(data) + seed_name = data[null_index + 1:null_index_2].decode() + + return name, seed_name + + async def zillion_sync_task(ctx: ZillionContext) -> None: logger.info("started zillion sync task") @@ -263,47 +388,58 @@ async def zillion_sync_task(ctx: ZillionContext) -> None: with Memory(ctx.from_game, ctx.to_game) as memory: while not ctx.exit_event.is_set(): ram = await memory.read() - name = memory.get_player_name(ram).decode() + game_id = memory.get_rom_to_ram_data(ram) + name, seed_end = name_seed_from_ram(game_id) if len(name): if name == ctx.auth: # this is the name we know if ctx.server and ctx.server.socket: # type: ignore - if memory.have_generation_info(): - log_no_spam("everything connected") - await memory.process_ram(ram) - ctx.process_from_game_queue() - ctx.process_items_received() - else: # no generation info - if ctx.got_slot_data.is_set(): - memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id) - ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \ - make_id_to_others(ctx.start_char) - ctx.next_item = 0 - ctx.ap_local_count = len(ctx.checked_locations) - else: # no slot data yet - asyncio.create_task(ctx.send_connect()) - log_no_spam("logging in to server...") - await asyncio.wait(( - ctx.got_slot_data.wait(), - ctx.exit_event.wait(), - asyncio.sleep(6) - ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets + if ctx.got_room_info.is_set(): + if ctx.seed_name and ctx.seed_name.endswith(seed_end): + # correct seed + if memory.have_generation_info(): + log_no_spam("everything connected") + await memory.process_ram(ram) + ctx.process_from_game_queue() + ctx.process_items_received() + else: # no generation info + if ctx.got_slot_data.is_set(): + memory.set_generation_info(ctx.rescues, ctx.loc_mem_to_id) + ctx.ap_id_to_name, ctx.ap_id_to_zz_id, _ap_id_to_zz_item = \ + make_id_to_others(ctx.start_char) + ctx.next_item = 0 + ctx.ap_local_count = len(ctx.checked_locations) + else: # no slot data yet + asyncio.create_task(ctx.send_connect()) + log_no_spam("logging in to server...") + await asyncio.wait(( + ctx.got_slot_data.wait(), + ctx.exit_event.wait(), + asyncio.sleep(6) + ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets + else: # not correct seed name + log_no_spam("incorrect seed - did you mix up roms?") + else: # no room info + # If we get here, it looks like `RoomInfo` packet got lost + log_no_spam("waiting for room info from server...") else: # server not connected log_no_spam("waiting for server connection...") else: # new game log_no_spam("connected to new game") await ctx.disconnect() ctx.reset_server_state() + ctx.seed_name = None + ctx.got_room_info.clear() ctx.reset_game_state() memory.reset_game_state() ctx.auth = name asyncio.create_task(ctx.connect()) await asyncio.wait(( - ctx.got_slot_data.wait(), + ctx.got_room_info.wait(), ctx.exit_event.wait(), asyncio.sleep(6) - ), return_when=asyncio.FIRST_COMPLETED) # to not spam connect packets + ), return_when=asyncio.FIRST_COMPLETED) else: # no name found in game if not help_message_shown: logger.info('In RetroArch, make sure "Settings > Network > Network Commands" is on.') diff --git a/host.yaml b/host.yaml index 2bb0e5ef5d..4e94a9a30c 100644 --- a/host.yaml +++ b/host.yaml @@ -82,28 +82,27 @@ generator: # List of options that can be plando'd. Can be combined, for example "bosses, items" # Available options: bosses, items, texts, connections plando_options: "bosses" +sni_options: + # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found + sni_path: "SNI" + # Set this to false to never autostart a rom (such as after patching) + # True for operating system default program + # Alternatively, a path to a program to open the .sfc file with + snes_rom_start: true lttp_options: # File name of the v1.0 J rom rom_file: "Zelda no Densetsu - Kamigami no Triforce (Japan).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true sm_options: # File name of the v1.0 J rom rom_file: "Super Metroid (JU).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true factorio_options: executable: "factorio/bin/x64/factorio" # by default, no settings are loaded if this file does not exist. If this file does exist, then it will be used. # server_settings: "factorio\\data\\server-settings.json" + # Whether to filter item send messages displayed in-game to only those that involve you. + filter_item_sends: false + # Whether to send chat messages from players on the Factorio server to Archipelago. + bridge_chat_out: true minecraft_options: forge_directory: "Minecraft Forge server" max_heap_size: "2G" @@ -122,22 +121,12 @@ soe_options: rom_file: "Secret of Evermore (USA).sfc" ffr_options: display_msgs: true -smz3_options: - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true dkc3_options: # File name of the DKC3 US rom rom_file: "Donkey Kong Country 3 - Dixie Kong's Double Trouble! (USA) (En,Fr).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true +smw_options: + # File name of the SMW US rom + rom_file: "Super Mario World (USA).sfc" pokemon_rb_options: # File names of the Pokemon Red and Blue roms red_rom_file: "Pokemon Red (UE) [S][!].gb" @@ -146,15 +135,6 @@ pokemon_rb_options: # True for operating system default program # Alternatively, a path to a program to open the .gb file with rom_start: true -smw_options: - # File name of the SMW US rom - rom_file: "Super Mario World (USA).sfc" - # Set this to your SNI folder location if you want the MultiClient to attempt an auto start, does nothing if not found - sni: "SNI" - # Set this to false to never autostart a rom (such as after patching) - # True for operating system default program - # Alternatively, a path to a program to open the .sfc file with - rom_start: true zillion_options: # File name of the Zillion US rom rom_file: "Zillion (UE) [!].sms" diff --git a/kvui.py b/kvui.py index 3c1161f99b..3820864538 100644 --- a/kvui.py +++ b/kvui.py @@ -28,6 +28,7 @@ from kivy.factory import Factory from kivy.properties import BooleanProperty, ObjectProperty from kivy.uix.button import Button from kivy.uix.gridlayout import GridLayout +from kivy.uix.layout import Layout from kivy.uix.textinput import TextInput from kivy.uix.recycleview import RecycleView from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem @@ -299,6 +300,9 @@ class GameManager(App): base_title: str = "Archipelago Client" last_autofillable_command: str + main_area_container: GridLayout + """ subclasses can add more columns beside the tabs """ + def __init__(self, ctx: context_type): self.title = self.base_title self.ctx = ctx @@ -325,7 +329,7 @@ class GameManager(App): super(GameManager, self).__init__() - def build(self): + def build(self) -> Layout: self.container = ContainerLayout() self.grid = MainLayout() @@ -358,7 +362,10 @@ class GameManager(App): self.log_panels[display_name] = panel.content = UILog(bridge_logger) self.tabs.add_widget(panel) - self.grid.add_widget(self.tabs) + self.main_area_container = GridLayout(size_hint_y=1, rows=1) + self.main_area_container.add_widget(self.tabs) + + self.grid.add_widget(self.main_area_container) if len(self.logging_pairs) == 1: # Hide Tab selection if only one tab diff --git a/test/minecraft/TestAdvancements.py b/test/minecraft/TestAdvancements.py index 6ddebcbfd2..f86d5a7333 100644 --- a/test/minecraft/TestAdvancements.py +++ b/test/minecraft/TestAdvancements.py @@ -312,9 +312,10 @@ class TestAdvancements(TestMinecraft): ["Two by Two", False, [], ['Flint and Steel']], ["Two by Two", False, [], ['Progressive Tools']], ["Two by Two", False, [], ['Progressive Weapons']], - ["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], + ["Two by Two", False, [], ['Bucket']], + ["Two by Two", False, ['Progressive Tools', 'Progressive Tools'], ['Bucket', 'Progressive Tools']], + ["Two by Two", False, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], ["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Bucket', 'Progressive Weapons']], - ["Two by Two", True, ['Progressive Resource Crafting', 'Progressive Tools', 'Flint and Steel', 'Progressive Tools', 'Progressive Tools', 'Progressive Weapons']], ]) def test_42023(self): diff --git a/test/worlds/test_base.py b/test/worlds/test_base.py index 1aa6ff2317..0d7272be19 100644 --- a/test/worlds/test_base.py +++ b/test/worlds/test_base.py @@ -19,13 +19,13 @@ class WorldTestBase(unittest.TestCase): if self.auto_construct: self.world_setup() - def world_setup(self) -> None: + def world_setup(self, seed: typing.Optional[int] = None) -> None: if not hasattr(self, "game"): raise NotImplementedError("didn't define game name") self.world = MultiWorld(1) self.world.game[1] = self.game self.world.player_name = {1: "Tester"} - self.world.set_seed() + self.world.set_seed(seed) args = Namespace() for name, option in AutoWorld.AutoWorldRegister.world_types[self.game].option_definitions.items(): setattr(args, name, { diff --git a/test/worlds/zillion/TestGoal.py b/test/worlds/zillion/TestGoal.py index 96701e8352..1c79305699 100644 --- a/test/worlds/zillion/TestGoal.py +++ b/test/worlds/zillion/TestGoal.py @@ -10,7 +10,7 @@ class TestGoalVanilla(ZillionTestBase): "floppy_req": 6, } - def test_floppies(self): + def test_floppies(self) -> None: self.collect_by_name(["Apple", "Champ", "Red ID Card"]) self.assertBeatable(False) # 0 floppies floppies = self.get_items_by_name("Floppy Disk") @@ -26,19 +26,20 @@ class TestGoalVanilla(ZillionTestBase): self.assertEqual(self.count("Floppy Disk"), 7) self.assertBeatable(True) - def test_with_everything(self): + def test_with_everything(self) -> None: self.collect_by_name(["Apple", "Champ", "Red ID Card", "Floppy Disk"]) self.assertBeatable(True) - def test_no_jump(self): + def test_no_jump(self) -> None: self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk"]) self.assertBeatable(False) - def test_no_gun(self): + def test_no_gun(self) -> None: + self.ensure_gun_3_requirement() self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk"]) self.assertBeatable(False) - def test_no_red(self): + def test_no_red(self) -> None: self.collect_by_name(["Apple", "Champ", "Floppy Disk"]) self.assertBeatable(False) @@ -50,7 +51,7 @@ class TestGoalBalanced(ZillionTestBase): "gun_levels": "balanced", } - def test_jump(self): + def test_jump(self) -> None: self.collect_by_name(["Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump opas = self.get_items_by_name("Opa-Opa") @@ -60,7 +61,8 @@ class TestGoalBalanced(ZillionTestBase): self.collect(opas[1:]) self.assertBeatable(True) - def test_guns(self): + def test_guns(self) -> None: + self.ensure_gun_3_requirement() self.collect_by_name(["Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun guns = self.get_items_by_name("Zillion") @@ -78,7 +80,7 @@ class TestGoalRestrictive(ZillionTestBase): "gun_levels": "restrictive", } - def test_jump(self): + def test_jump(self) -> None: self.collect_by_name(["Champ", "Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump self.collect_by_name("Opa-Opa") @@ -86,7 +88,8 @@ class TestGoalRestrictive(ZillionTestBase): self.collect_by_name("Apple") self.assertBeatable(True) - def test_guns(self): + def test_guns(self) -> None: + self.ensure_gun_3_requirement() self.collect_by_name(["Apple", "Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun self.collect_by_name("Zillion") @@ -104,15 +107,17 @@ class TestGoalAppleStart(ZillionTestBase): "zillion_count": 5 } - def test_guns_jj_first(self): + def test_guns_jj_first(self) -> None: """ with low gun levels, 5 Zillion is enough to get JJ to gun 3 """ + self.ensure_gun_3_requirement() self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun self.collect_by_name("Zillion") self.assertBeatable(True) - def test_guns_zillions_first(self): + def test_guns_zillions_first(self) -> None: """ with low gun levels, 5 Zillion is enough to get JJ to gun 3 """ + self.ensure_gun_3_requirement() self.collect_by_name(["Zillion", "Red ID Card", "Floppy Disk", "Opa-Opa"]) self.assertBeatable(False) # not enough gun self.collect_by_name("JJ") @@ -129,14 +134,14 @@ class TestGoalChampStart(ZillionTestBase): "opas_per_level": 1 } - def test_jump_jj_first(self): + def test_jump_jj_first(self) -> None: """ with low jump levels, 5 level-ups is enough to get JJ to jump 3 """ self.collect_by_name(["JJ", "Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump self.collect_by_name("Opa-Opa") self.assertBeatable(True) - def test_jump_opa_first(self): + def test_jump_opa_first(self) -> None: """ with low jump levels, 5 level-ups is enough to get JJ to jump 3 """ self.collect_by_name(["Opa-Opa", "Red ID Card", "Floppy Disk", "Zillion"]) self.assertBeatable(False) # not enough jump diff --git a/test/worlds/zillion/__init__.py b/test/worlds/zillion/__init__.py index 43100d3a8c..fb81bda522 100644 --- a/test/worlds/zillion/__init__.py +++ b/test/worlds/zillion/__init__.py @@ -1,13 +1,20 @@ +from typing import cast from test.worlds.test_base import WorldTestBase -from worlds.zillion.region import ZillionLocation +from worlds.zillion import ZillionWorld class ZillionTestBase(WorldTestBase): game = "Zillion" - def world_setup(self) -> None: - super().world_setup() - # make sure game requires gun 3 for tests - for location in self.world.get_locations(): - if isinstance(location, ZillionLocation) and location.name.startswith("O-7"): - location.zz_loc.req.gun = 3 + def ensure_gun_3_requirement(self) -> None: + """ + There's a low probability that gun 3 is not required. + + This makes sure that gun 3 is required by making all the canisters + in O-7 (including key word canisters) require gun 3. + """ + zz_world = cast(ZillionWorld, self.world.worlds[1]) + assert zz_world.zz_system.randomizer + for zz_loc_name, zz_loc in zz_world.zz_system.randomizer.locations.items(): + if zz_loc_name.startswith("r15c6"): + zz_loc.req.gun = 3 diff --git a/typings/kivy/__init__.pyi b/typings/kivy/__init__.pyi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typings/kivy/app.pyi b/typings/kivy/app.pyi new file mode 100644 index 0000000000..bb41bf6b2b --- /dev/null +++ b/typings/kivy/app.pyi @@ -0,0 +1,2 @@ +class App: + async def async_run(self) -> None: ... diff --git a/typings/kivy/core/__init__.pyi b/typings/kivy/core/__init__.pyi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typings/kivy/core/text.pyi b/typings/kivy/core/text.pyi new file mode 100644 index 0000000000..7b13ad3424 --- /dev/null +++ b/typings/kivy/core/text.pyi @@ -0,0 +1,7 @@ +from typing import Tuple +from ..graphics import FillType_Shape +from ..uix.widget import Widget + + +class Label(FillType_Shape, Widget): + def __init__(self, *, text: str, font_size: int, color: Tuple[float, float, float, float]) -> None: ... diff --git a/typings/kivy/graphics.pyi b/typings/kivy/graphics.pyi new file mode 100644 index 0000000000..1950910661 --- /dev/null +++ b/typings/kivy/graphics.pyi @@ -0,0 +1,40 @@ +""" FillType_* is not a real kivy type - just something to fill unknown typing. """ + +from typing import Sequence + +FillType_Vec = Sequence[int] + + +class FillType_Drawable: + def __init__(self, *, pos: FillType_Vec = ..., size: FillType_Vec = ...) -> None: ... + + +class FillType_Texture(FillType_Drawable): + pass + + +class FillType_Shape(FillType_Drawable): + texture: FillType_Texture + + def __init__(self, + *, + texture: FillType_Texture = ..., + pos: FillType_Vec = ..., + size: FillType_Vec = ...) -> None: ... + + +class Ellipse(FillType_Shape): + pass + + +class Color: + def __init__(self, r: float, g: float, b: float, a: float) -> None: ... + + +class Rectangle(FillType_Shape): + def __init__(self, + *, + source: str = ..., + texture: FillType_Texture = ..., + pos: FillType_Vec = ..., + size: FillType_Vec = ...) -> None: ... diff --git a/typings/kivy/uix/__init__.pyi b/typings/kivy/uix/__init__.pyi new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typings/kivy/uix/layout.pyi b/typings/kivy/uix/layout.pyi new file mode 100644 index 0000000000..2a418a1d8b --- /dev/null +++ b/typings/kivy/uix/layout.pyi @@ -0,0 +1,8 @@ +from typing import Any +from .widget import Widget + + +class Layout(Widget): + def add_widget(self, widget: Widget) -> None: ... + + def do_layout(self, *largs: Any, **kwargs: Any) -> None: ... diff --git a/typings/kivy/uix/tabbedpanel.pyi b/typings/kivy/uix/tabbedpanel.pyi new file mode 100644 index 0000000000..9183b4c8b3 --- /dev/null +++ b/typings/kivy/uix/tabbedpanel.pyi @@ -0,0 +1,12 @@ +from .layout import Layout +from .widget import Widget + + +class TabbedPanel(Layout): + pass + + +class TabbedPanelItem(Widget): + content: Widget + + def __init__(self, *, text: str = ...) -> None: ... diff --git a/typings/kivy/uix/widget.pyi b/typings/kivy/uix/widget.pyi new file mode 100644 index 0000000000..54e3b781ea --- /dev/null +++ b/typings/kivy/uix/widget.pyi @@ -0,0 +1,31 @@ +""" FillType_* is not a real kivy type - just something to fill unknown typing. """ + +from typing import Any, Optional, Protocol +from ..graphics import FillType_Drawable, FillType_Vec + + +class FillType_BindCallback(Protocol): + def __call__(self, *args: Any) -> None: ... + + +class FillType_Canvas: + def add(self, drawable: FillType_Drawable) -> None: ... + + def clear(self) -> None: ... + + def __enter__(self) -> None: ... + + def __exit__(self, *args: Any) -> None: ... + + +class Widget: + canvas: FillType_Canvas + width: int + pos: FillType_Vec + + def bind(self, + *, + pos: Optional[FillType_BindCallback] = ..., + size: Optional[FillType_BindCallback] = ...) -> None: ... + + def refresh(self) -> None: ... diff --git a/worlds/AutoSNIClient.py b/worlds/AutoSNIClient.py new file mode 100644 index 0000000000..a30dbbb46d --- /dev/null +++ b/worlds/AutoSNIClient.py @@ -0,0 +1,42 @@ + +from __future__ import annotations +import abc +from typing import TYPE_CHECKING, ClassVar, Dict, Tuple, Any, Optional + +if TYPE_CHECKING: + from SNIClient import SNIContext + + +class AutoSNIClientRegister(abc.ABCMeta): + game_handlers: ClassVar[Dict[str, SNIClient]] = {} + + def __new__(cls, name: str, bases: Tuple[type, ...], dct: Dict[str, Any]) -> AutoSNIClientRegister: + # construct class + new_class = super().__new__(cls, name, bases, dct) + if "game" in dct: + AutoSNIClientRegister.game_handlers[dct["game"]] = new_class() + return new_class + + @staticmethod + async def get_handler(ctx: SNIContext) -> Optional[SNIClient]: + for _game, handler in AutoSNIClientRegister.game_handlers.items(): + if await handler.validate_rom(ctx): + return handler + return None + + +class SNIClient(abc.ABC, metaclass=AutoSNIClientRegister): + + @abc.abstractmethod + async def validate_rom(self, ctx: SNIContext) -> bool: + """ TODO: interface documentation here """ + ... + + @abc.abstractmethod + async def game_watcher(self, ctx: SNIContext) -> None: + """ TODO: interface documentation here """ + ... + + async def deathlink_kill_player(self, ctx: SNIContext) -> None: + """ override this with implementation to kill player """ + pass diff --git a/worlds/alttp/Client.py b/worlds/alttp/Client.py new file mode 100644 index 0000000000..b3a12a7ff8 --- /dev/null +++ b/worlds/alttp/Client.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +import logging +import asyncio +import shutil +import time + +import Utils + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient + +from worlds.alttp import Shops, Regions +from .Rom import ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +GAME_ALTTP = "A Link to the Past" + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +ROMNAME_START = SRAM_START + 0x2000 +ROMNAME_SIZE = 0x15 + +INGAME_MODES = {0x07, 0x09, 0x0b} +ENDGAME_MODES = {0x19, 0x1a} +DEATH_MODES = {0x12} + +SAVEDATA_START = WRAM_START + 0xF000 +SAVEDATA_SIZE = 0x500 + +RECV_PROGRESS_ADDR = SAVEDATA_START + 0x4D0 # 2 bytes +RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte +RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte +ROOMID_ADDR = SAVEDATA_START + 0x4D4 # 2 bytes +ROOMDATA_ADDR = SAVEDATA_START + 0x4D6 # 1 byte +SCOUT_LOCATION_ADDR = SAVEDATA_START + 0x4D7 # 1 byte +SCOUTREPLY_LOCATION_ADDR = SAVEDATA_START + 0x4D8 # 1 byte +SCOUTREPLY_ITEM_ADDR = SAVEDATA_START + 0x4D9 # 1 byte +SCOUTREPLY_PLAYER_ADDR = SAVEDATA_START + 0x4DA # 1 byte +SHOP_ADDR = SAVEDATA_START + 0x302 # 2 bytes +SHOP_LEN = (len(Shops.shop_table) * 3) + 5 + +DEATH_LINK_ACTIVE_ADDR = ROMNAME_START + 0x15 # 1 byte + +location_shop_ids = set([info[0] for name, info in Shops.shop_table.items()]) + +location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), + "Blind's Hideout - Left": (0x11d, 0x20), + "Blind's Hideout - Right": (0x11d, 0x40), + "Blind's Hideout - Far Left": (0x11d, 0x80), + "Blind's Hideout - Far Right": (0x11d, 0x100), + 'Secret Passage': (0x55, 0x10), + 'Waterfall Fairy - Left': (0x114, 0x10), + 'Waterfall Fairy - Right': (0x114, 0x20), + "King's Tomb": (0x113, 0x10), + 'Floodgate Chest': (0x10b, 0x10), + "Link's House": (0x104, 0x10), + 'Kakariko Tavern': (0x103, 0x10), + 'Chicken House': (0x108, 0x10), + "Aginah's Cave": (0x10a, 0x10), + "Sahasrahla's Hut - Left": (0x105, 0x10), + "Sahasrahla's Hut - Middle": (0x105, 0x20), + "Sahasrahla's Hut - Right": (0x105, 0x40), + 'Kakariko Well - Top': (0x2f, 0x10), + 'Kakariko Well - Left': (0x2f, 0x20), + 'Kakariko Well - Middle': (0x2f, 0x40), + 'Kakariko Well - Right': (0x2f, 0x80), + 'Kakariko Well - Bottom': (0x2f, 0x100), + 'Lost Woods Hideout': (0xe1, 0x200), + 'Lumberjack Tree': (0xe2, 0x200), + 'Cave 45': (0x11b, 0x400), + 'Graveyard Cave': (0x11b, 0x200), + 'Checkerboard Cave': (0x126, 0x200), + 'Mini Moldorm Cave - Far Left': (0x123, 0x10), + 'Mini Moldorm Cave - Left': (0x123, 0x20), + 'Mini Moldorm Cave - Right': (0x123, 0x40), + 'Mini Moldorm Cave - Far Right': (0x123, 0x80), + 'Mini Moldorm Cave - Generous Guy': (0x123, 0x400), + 'Ice Rod Cave': (0x120, 0x10), + 'Bonk Rock Cave': (0x124, 0x10), + 'Desert Palace - Big Chest': (0x73, 0x10), + 'Desert Palace - Torch': (0x73, 0x400), + 'Desert Palace - Map Chest': (0x74, 0x10), + 'Desert Palace - Compass Chest': (0x85, 0x10), + 'Desert Palace - Big Key Chest': (0x75, 0x10), + 'Desert Palace - Desert Tiles 1 Pot Key': (0x63, 0x400), + 'Desert Palace - Beamos Hall Pot Key': (0x53, 0x400), + 'Desert Palace - Desert Tiles 2 Pot Key': (0x43, 0x400), + 'Desert Palace - Boss': (0x33, 0x800), + 'Eastern Palace - Compass Chest': (0xa8, 0x10), + 'Eastern Palace - Big Chest': (0xa9, 0x10), + 'Eastern Palace - Dark Square Pot Key': (0xba, 0x400), + 'Eastern Palace - Dark Eyegore Key Drop': (0x99, 0x400), + 'Eastern Palace - Cannonball Chest': (0xb9, 0x10), + 'Eastern Palace - Big Key Chest': (0xb8, 0x10), + 'Eastern Palace - Map Chest': (0xaa, 0x10), + 'Eastern Palace - Boss': (0xc8, 0x800), + 'Hyrule Castle - Boomerang Chest': (0x71, 0x10), + 'Hyrule Castle - Boomerang Guard Key Drop': (0x71, 0x400), + 'Hyrule Castle - Map Chest': (0x72, 0x10), + 'Hyrule Castle - Map Guard Key Drop': (0x72, 0x400), + "Hyrule Castle - Zelda's Chest": (0x80, 0x10), + 'Hyrule Castle - Big Key Drop': (0x80, 0x400), + 'Sewers - Dark Cross': (0x32, 0x10), + 'Hyrule Castle - Key Rat Key Drop': (0x21, 0x400), + 'Sewers - Secret Room - Left': (0x11, 0x10), + 'Sewers - Secret Room - Middle': (0x11, 0x20), + 'Sewers - Secret Room - Right': (0x11, 0x40), + 'Sanctuary': (0x12, 0x10), + 'Castle Tower - Room 03': (0xe0, 0x10), + 'Castle Tower - Dark Maze': (0xd0, 0x10), + 'Castle Tower - Dark Archer Key Drop': (0xc0, 0x400), + 'Castle Tower - Circle of Pots Key Drop': (0xb0, 0x400), + 'Spectacle Rock Cave': (0xea, 0x400), + 'Paradox Cave Lower - Far Left': (0xef, 0x10), + 'Paradox Cave Lower - Left': (0xef, 0x20), + 'Paradox Cave Lower - Right': (0xef, 0x40), + 'Paradox Cave Lower - Far Right': (0xef, 0x80), + 'Paradox Cave Lower - Middle': (0xef, 0x100), + 'Paradox Cave Upper - Left': (0xff, 0x10), + 'Paradox Cave Upper - Right': (0xff, 0x20), + 'Spiral Cave': (0xfe, 0x10), + 'Tower of Hera - Basement Cage': (0x87, 0x400), + 'Tower of Hera - Map Chest': (0x77, 0x10), + 'Tower of Hera - Big Key Chest': (0x87, 0x10), + 'Tower of Hera - Compass Chest': (0x27, 0x20), + 'Tower of Hera - Big Chest': (0x27, 0x10), + 'Tower of Hera - Boss': (0x7, 0x800), + 'Hype Cave - Top': (0x11e, 0x10), + 'Hype Cave - Middle Right': (0x11e, 0x20), + 'Hype Cave - Middle Left': (0x11e, 0x40), + 'Hype Cave - Bottom': (0x11e, 0x80), + 'Hype Cave - Generous Guy': (0x11e, 0x400), + 'Peg Cave': (0x127, 0x400), + 'Pyramid Fairy - Left': (0x116, 0x10), + 'Pyramid Fairy - Right': (0x116, 0x20), + 'Brewery': (0x106, 0x10), + 'C-Shaped House': (0x11c, 0x10), + 'Chest Game': (0x106, 0x400), + 'Mire Shed - Left': (0x10d, 0x10), + 'Mire Shed - Right': (0x10d, 0x20), + 'Superbunny Cave - Top': (0xf8, 0x10), + 'Superbunny Cave - Bottom': (0xf8, 0x20), + 'Spike Cave': (0x117, 0x10), + 'Hookshot Cave - Top Right': (0x3c, 0x10), + 'Hookshot Cave - Top Left': (0x3c, 0x20), + 'Hookshot Cave - Bottom Right': (0x3c, 0x80), + 'Hookshot Cave - Bottom Left': (0x3c, 0x40), + 'Mimic Cave': (0x10c, 0x10), + 'Swamp Palace - Entrance': (0x28, 0x10), + 'Swamp Palace - Map Chest': (0x37, 0x10), + 'Swamp Palace - Pot Row Pot Key': (0x38, 0x400), + 'Swamp Palace - Trench 1 Pot Key': (0x37, 0x400), + 'Swamp Palace - Hookshot Pot Key': (0x36, 0x400), + 'Swamp Palace - Big Chest': (0x36, 0x10), + 'Swamp Palace - Compass Chest': (0x46, 0x10), + 'Swamp Palace - Trench 2 Pot Key': (0x35, 0x400), + 'Swamp Palace - Big Key Chest': (0x35, 0x10), + 'Swamp Palace - West Chest': (0x34, 0x10), + 'Swamp Palace - Flooded Room - Left': (0x76, 0x10), + 'Swamp Palace - Flooded Room - Right': (0x76, 0x20), + 'Swamp Palace - Waterfall Room': (0x66, 0x10), + 'Swamp Palace - Waterway Pot Key': (0x16, 0x400), + 'Swamp Palace - Boss': (0x6, 0x800), + "Thieves' Town - Big Key Chest": (0xdb, 0x20), + "Thieves' Town - Map Chest": (0xdb, 0x10), + "Thieves' Town - Compass Chest": (0xdc, 0x10), + "Thieves' Town - Ambush Chest": (0xcb, 0x10), + "Thieves' Town - Hallway Pot Key": (0xbc, 0x400), + "Thieves' Town - Spike Switch Pot Key": (0xab, 0x400), + "Thieves' Town - Attic": (0x65, 0x10), + "Thieves' Town - Big Chest": (0x44, 0x10), + "Thieves' Town - Blind's Cell": (0x45, 0x10), + "Thieves' Town - Boss": (0xac, 0x800), + 'Skull Woods - Compass Chest': (0x67, 0x10), + 'Skull Woods - Map Chest': (0x58, 0x20), + 'Skull Woods - Big Chest': (0x58, 0x10), + 'Skull Woods - Pot Prison': (0x57, 0x20), + 'Skull Woods - Pinball Room': (0x68, 0x10), + 'Skull Woods - Big Key Chest': (0x57, 0x10), + 'Skull Woods - West Lobby Pot Key': (0x56, 0x400), + 'Skull Woods - Bridge Room': (0x59, 0x10), + 'Skull Woods - Spike Corner Key Drop': (0x39, 0x400), + 'Skull Woods - Boss': (0x29, 0x800), + 'Ice Palace - Jelly Key Drop': (0x0e, 0x400), + 'Ice Palace - Compass Chest': (0x2e, 0x10), + 'Ice Palace - Conveyor Key Drop': (0x3e, 0x400), + 'Ice Palace - Freezor Chest': (0x7e, 0x10), + 'Ice Palace - Big Chest': (0x9e, 0x10), + 'Ice Palace - Iced T Room': (0xae, 0x10), + 'Ice Palace - Many Pots Pot Key': (0x9f, 0x400), + 'Ice Palace - Spike Room': (0x5f, 0x10), + 'Ice Palace - Big Key Chest': (0x1f, 0x10), + 'Ice Palace - Hammer Block Key Drop': (0x3f, 0x400), + 'Ice Palace - Map Chest': (0x3f, 0x10), + 'Ice Palace - Boss': (0xde, 0x800), + 'Misery Mire - Big Chest': (0xc3, 0x10), + 'Misery Mire - Map Chest': (0xc3, 0x20), + 'Misery Mire - Main Lobby': (0xc2, 0x10), + 'Misery Mire - Bridge Chest': (0xa2, 0x10), + 'Misery Mire - Spikes Pot Key': (0xb3, 0x400), + 'Misery Mire - Spike Chest': (0xb3, 0x10), + 'Misery Mire - Fishbone Pot Key': (0xa1, 0x400), + 'Misery Mire - Conveyor Crystal Key Drop': (0xc1, 0x400), + 'Misery Mire - Compass Chest': (0xc1, 0x10), + 'Misery Mire - Big Key Chest': (0xd1, 0x10), + 'Misery Mire - Boss': (0x90, 0x800), + 'Turtle Rock - Compass Chest': (0xd6, 0x10), + 'Turtle Rock - Roller Room - Left': (0xb7, 0x10), + 'Turtle Rock - Roller Room - Right': (0xb7, 0x20), + 'Turtle Rock - Pokey 1 Key Drop': (0xb6, 0x400), + 'Turtle Rock - Chain Chomps': (0xb6, 0x10), + 'Turtle Rock - Pokey 2 Key Drop': (0x13, 0x400), + 'Turtle Rock - Big Key Chest': (0x14, 0x10), + 'Turtle Rock - Big Chest': (0x24, 0x10), + 'Turtle Rock - Crystaroller Room': (0x4, 0x10), + 'Turtle Rock - Eye Bridge - Bottom Left': (0xd5, 0x80), + 'Turtle Rock - Eye Bridge - Bottom Right': (0xd5, 0x40), + 'Turtle Rock - Eye Bridge - Top Left': (0xd5, 0x20), + 'Turtle Rock - Eye Bridge - Top Right': (0xd5, 0x10), + 'Turtle Rock - Boss': (0xa4, 0x800), + 'Palace of Darkness - Shooter Room': (0x9, 0x10), + 'Palace of Darkness - The Arena - Bridge': (0x2a, 0x20), + 'Palace of Darkness - Stalfos Basement': (0xa, 0x10), + 'Palace of Darkness - Big Key Chest': (0x3a, 0x10), + 'Palace of Darkness - The Arena - Ledge': (0x2a, 0x10), + 'Palace of Darkness - Map Chest': (0x2b, 0x10), + 'Palace of Darkness - Compass Chest': (0x1a, 0x20), + 'Palace of Darkness - Dark Basement - Left': (0x6a, 0x10), + 'Palace of Darkness - Dark Basement - Right': (0x6a, 0x20), + 'Palace of Darkness - Dark Maze - Top': (0x19, 0x10), + 'Palace of Darkness - Dark Maze - Bottom': (0x19, 0x20), + 'Palace of Darkness - Big Chest': (0x1a, 0x10), + 'Palace of Darkness - Harmless Hellway': (0x1a, 0x40), + 'Palace of Darkness - Boss': (0x5a, 0x800), + 'Ganons Tower - Conveyor Cross Pot Key': (0x8b, 0x400), + "Ganons Tower - Bob's Torch": (0x8c, 0x400), + 'Ganons Tower - Hope Room - Left': (0x8c, 0x20), + 'Ganons Tower - Hope Room - Right': (0x8c, 0x40), + 'Ganons Tower - Tile Room': (0x8d, 0x10), + 'Ganons Tower - Compass Room - Top Left': (0x9d, 0x10), + 'Ganons Tower - Compass Room - Top Right': (0x9d, 0x20), + 'Ganons Tower - Compass Room - Bottom Left': (0x9d, 0x40), + 'Ganons Tower - Compass Room - Bottom Right': (0x9d, 0x80), + 'Ganons Tower - Conveyor Star Pits Pot Key': (0x7b, 0x400), + 'Ganons Tower - DMs Room - Top Left': (0x7b, 0x10), + 'Ganons Tower - DMs Room - Top Right': (0x7b, 0x20), + 'Ganons Tower - DMs Room - Bottom Left': (0x7b, 0x40), + 'Ganons Tower - DMs Room - Bottom Right': (0x7b, 0x80), + 'Ganons Tower - Map Chest': (0x8b, 0x10), + 'Ganons Tower - Double Switch Pot Key': (0x9b, 0x400), + 'Ganons Tower - Firesnake Room': (0x7d, 0x10), + 'Ganons Tower - Randomizer Room - Top Left': (0x7c, 0x10), + 'Ganons Tower - Randomizer Room - Top Right': (0x7c, 0x20), + 'Ganons Tower - Randomizer Room - Bottom Left': (0x7c, 0x40), + 'Ganons Tower - Randomizer Room - Bottom Right': (0x7c, 0x80), + "Ganons Tower - Bob's Chest": (0x8c, 0x80), + 'Ganons Tower - Big Chest': (0x8c, 0x10), + 'Ganons Tower - Big Key Room - Left': (0x1c, 0x20), + 'Ganons Tower - Big Key Room - Right': (0x1c, 0x40), + 'Ganons Tower - Big Key Chest': (0x1c, 0x10), + 'Ganons Tower - Mini Helmasaur Room - Left': (0x3d, 0x10), + 'Ganons Tower - Mini Helmasaur Room - Right': (0x3d, 0x20), + 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), + 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), + 'Ganons Tower - Validation Chest': (0x4d, 0x10)} + +boss_locations = {Regions.lookup_name_to_id[name] for name in {'Eastern Palace - Boss', + 'Desert Palace - Boss', + 'Tower of Hera - Boss', + 'Palace of Darkness - Boss', + 'Swamp Palace - Boss', + 'Skull Woods - Boss', + "Thieves' Town - Boss", + 'Ice Palace - Boss', + 'Misery Mire - Boss', + 'Turtle Rock - Boss', + 'Sahasrahla'}} + +location_table_uw_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_uw.items()} + +location_table_npc = {'Mushroom': 0x1000, + 'King Zora': 0x2, + 'Sahasrahla': 0x10, + 'Blacksmith': 0x400, + 'Magic Bat': 0x8000, + 'Sick Kid': 0x4, + 'Library': 0x80, + 'Potion Shop': 0x2000, + 'Old Man': 0x1, + 'Ether Tablet': 0x100, + 'Catfish': 0x20, + 'Stumpy': 0x8, + 'Bombos Tablet': 0x200} + +location_table_npc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_npc.items()} + +location_table_ow = {'Flute Spot': 0x2a, + 'Sunken Treasure': 0x3b, + "Zora's Ledge": 0x81, + 'Lake Hylia Island': 0x35, + 'Maze Race': 0x28, + 'Desert Ledge': 0x30, + 'Master Sword Pedestal': 0x80, + 'Spectacle Rock': 0x3, + 'Pyramid': 0x5b, + 'Digging Game': 0x68, + 'Bumper Cave Ledge': 0x4a, + 'Floating Island': 0x5} + +location_table_ow_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_ow.items()} + +location_table_misc = {'Bottle Merchant': (0x3c9, 0x2), + 'Purple Chest': (0x3c9, 0x10), + "Link's Uncle": (0x3c6, 0x1), + 'Hobo': (0x3c9, 0x1)} +location_table_misc_id = {Regions.lookup_name_to_id[name]: data for name, data in location_table_misc.items()} + + +async def track_locations(ctx, roomid, roomdata): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + new_locations = [] + + def new_check(location_id): + new_locations.append(location_id) + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + + try: + shop_data = await snes_read(ctx, SHOP_ADDR, SHOP_LEN) + shop_data_changed = False + shop_data = list(shop_data) + for cnt, b in enumerate(shop_data): + location = Shops.SHOP_ID_START + cnt + if int(b) and location not in ctx.locations_checked: + new_check(location) + if ctx.allow_collect and location in ctx.checked_locations and location not in ctx.locations_checked \ + and location in ctx.locations_info and ctx.locations_info[location].player != ctx.slot: + if not int(b): + shop_data[cnt] += 1 + shop_data_changed = True + if shop_data_changed: + snes_buffered_write(ctx, SHOP_ADDR, bytes(shop_data)) + except Exception as e: + snes_logger.info(f"Exception: {e}") + + for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): + try: + if location_id not in ctx.locations_checked and loc_roomid == roomid and \ + (roomdata << 4) & loc_mask != 0: + new_check(location_id) + except Exception as e: + snes_logger.exception(f"Exception: {e}") + + uw_begin = 0x129 + ow_end = uw_end = 0 + uw_unchecked = {} + uw_checked = {} + for location, (roomid, mask) in location_table_uw.items(): + location_id = Regions.lookup_name_to_id[location] + if location_id not in ctx.locations_checked: + uw_unchecked[location_id] = (roomid, mask) + uw_begin = min(uw_begin, roomid) + uw_end = max(uw_end, roomid + 1) + if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ + and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + uw_begin = min(uw_begin, roomid) + uw_end = max(uw_end, roomid + 1) + uw_checked[location_id] = (roomid, mask) + + if uw_begin < uw_end: + uw_data = await snes_read(ctx, SAVEDATA_START + (uw_begin * 2), (uw_end - uw_begin) * 2) + if uw_data is not None: + for location_id, (roomid, mask) in uw_unchecked.items(): + offset = (roomid - uw_begin) * 2 + roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) + if roomdata & mask != 0: + new_check(location_id) + if uw_checked: + uw_data = list(uw_data) + for location_id, (roomid, mask) in uw_checked.items(): + offset = (roomid - uw_begin) * 2 + roomdata = uw_data[offset] | (uw_data[offset + 1] << 8) + roomdata |= mask + uw_data[offset] = roomdata & 0xFF + uw_data[offset + 1] = roomdata >> 8 + snes_buffered_write(ctx, SAVEDATA_START + (uw_begin * 2), bytes(uw_data)) + + ow_begin = 0x82 + ow_unchecked = {} + ow_checked = {} + for location_id, screenid in location_table_ow_id.items(): + if location_id not in ctx.locations_checked: + ow_unchecked[location_id] = screenid + ow_begin = min(ow_begin, screenid) + ow_end = max(ow_end, screenid + 1) + if ctx.allow_collect and location_id in ctx.checked_locations and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + ow_checked[location_id] = screenid + + if ow_begin < ow_end: + ow_data = await snes_read(ctx, SAVEDATA_START + 0x280 + ow_begin, ow_end - ow_begin) + if ow_data is not None: + for location_id, screenid in ow_unchecked.items(): + if ow_data[screenid - ow_begin] & 0x40 != 0: + new_check(location_id) + if ow_checked: + ow_data = list(ow_data) + for location_id, screenid in ow_checked.items(): + ow_data[screenid - ow_begin] |= 0x40 + snes_buffered_write(ctx, SAVEDATA_START + 0x280 + ow_begin, bytes(ow_data)) + + if not ctx.locations_checked.issuperset(location_table_npc_id): + npc_data = await snes_read(ctx, SAVEDATA_START + 0x410, 2) + if npc_data is not None: + npc_value_changed = False + npc_value = npc_data[0] | (npc_data[1] << 8) + for location_id, mask in location_table_npc_id.items(): + if npc_value & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) + if ctx.allow_collect and location_id not in boss_locations and location_id in ctx.checked_locations \ + and location_id not in ctx.locations_checked and location_id in ctx.locations_info \ + and ctx.locations_info[location_id].player != ctx.slot: + npc_value |= mask + npc_value_changed = True + if npc_value_changed: + npc_data = bytes([npc_value & 0xFF, npc_value >> 8]) + snes_buffered_write(ctx, SAVEDATA_START + 0x410, npc_data) + + if not ctx.locations_checked.issuperset(location_table_misc_id): + misc_data = await snes_read(ctx, SAVEDATA_START + 0x3c6, 4) + if misc_data is not None: + misc_data = list(misc_data) + misc_data_changed = False + for location_id, (offset, mask) in location_table_misc_id.items(): + assert (0x3c6 <= offset <= 0x3c9) + if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) + if ctx.allow_collect and location_id in ctx.checked_locations and location_id not in ctx.locations_checked \ + and location_id in ctx.locations_info and ctx.locations_info[location_id].player != ctx.slot: + misc_data_changed = True + misc_data[offset - 0x3c6] |= mask + if misc_data_changed: + snes_buffered_write(ctx, SAVEDATA_START + 0x3c6, bytes(misc_data)) + + + if new_locations: + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": new_locations}]) + await snes_flush_writes(ctx) + + +def get_alttp_settings(romfile: str): + lastSettings = Utils.get_adjuster_settings(GAME_ALTTP) + adjustedromfile = '' + if lastSettings: + choice = 'no' + if not hasattr(lastSettings, 'auto_apply') or 'ask' in lastSettings.auto_apply: + + whitelist = {"music", "menuspeed", "heartbeep", "heartcolor", "ow_palettes", "quickswap", + "uw_palettes", "sprite", "sword_palettes", "shield_palettes", "hud_palettes", + "reduceflashing", "deathlink", "allowcollect"} + printed_options = {name: value for name, value in vars(lastSettings).items() if name in whitelist} + if hasattr(lastSettings, "sprite_pool"): + sprite_pool = {} + for sprite in lastSettings.sprite_pool: + if sprite in sprite_pool: + sprite_pool[sprite] += 1 + else: + sprite_pool[sprite] = 1 + if sprite_pool: + printed_options["sprite_pool"] = sprite_pool + import pprint + + from CommonClient import gui_enabled + if gui_enabled: + + try: + from tkinter import Tk, PhotoImage, Label, LabelFrame, Frame, Button + applyPromptWindow = Tk() + except Exception as e: + logging.error('Could not load tkinter, which is likely not installed.') + return '', False + + applyPromptWindow.resizable(False, False) + applyPromptWindow.protocol('WM_DELETE_WINDOW', lambda: onButtonClick()) + logo = PhotoImage(file=Utils.local_path('data', 'icon.png')) + applyPromptWindow.tk.call('wm', 'iconphoto', applyPromptWindow._w, logo) + applyPromptWindow.wm_title("Last adjuster settings LttP") + + label = LabelFrame(applyPromptWindow, + text='Last used adjuster settings were found. Would you like to apply these?') + label.grid(column=0, row=0, padx=5, pady=5, ipadx=5, ipady=5) + label.grid_columnconfigure(0, weight=1) + label.grid_columnconfigure(1, weight=1) + label.grid_columnconfigure(2, weight=1) + label.grid_columnconfigure(3, weight=1) + + def onButtonClick(answer: str = 'no'): + setattr(onButtonClick, 'choice', answer) + applyPromptWindow.destroy() + + framedOptions = Frame(label) + framedOptions.grid(column=0, columnspan=4, row=0) + framedOptions.grid_columnconfigure(0, weight=1) + framedOptions.grid_columnconfigure(1, weight=1) + framedOptions.grid_columnconfigure(2, weight=1) + curRow = 0 + curCol = 0 + for name, value in printed_options.items(): + Label(framedOptions, text=name + ": " + str(value)).grid(column=curCol, row=curRow, padx=5) + if (curCol == 2): + curRow += 1 + curCol = 0 + else: + curCol += 1 + + yesButton = Button(label, text='Yes', command=lambda: onButtonClick('yes'), width=10) + yesButton.grid(column=0, row=1) + noButton = Button(label, text='No', command=lambda: onButtonClick('no'), width=10) + noButton.grid(column=1, row=1) + alwaysButton = Button(label, text='Always', command=lambda: onButtonClick('always'), width=10) + alwaysButton.grid(column=2, row=1) + neverButton = Button(label, text='Never', command=lambda: onButtonClick('never'), width=10) + neverButton.grid(column=3, row=1) + + Utils.tkinter_center_window(applyPromptWindow) + applyPromptWindow.mainloop() + choice = getattr(onButtonClick, 'choice') + else: + choice = input(f"Last used adjuster settings were found. Would you like to apply these? \n" + f"{pprint.pformat(printed_options)}\n" + f"Enter yes, no, always or never: ") + if choice and choice.startswith("y"): + choice = 'yes' + elif choice and "never" in choice: + choice = 'no' + lastSettings.auto_apply = 'never' + Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) + elif choice and "always" in choice: + choice = 'yes' + lastSettings.auto_apply = 'always' + Utils.persistent_store("adjuster", GAME_ALTTP, lastSettings) + else: + choice = 'no' + elif 'never' in lastSettings.auto_apply: + choice = 'no' + elif 'always' in lastSettings.auto_apply: + choice = 'yes' + + if 'yes' in choice: + from worlds.alttp.Rom import get_base_rom_path + lastSettings.rom = romfile + lastSettings.baserom = get_base_rom_path() + lastSettings.world = None + + if hasattr(lastSettings, "sprite_pool"): + from LttPAdjuster import AdjusterWorld + lastSettings.world = AdjusterWorld(getattr(lastSettings, "sprite_pool")) + + adjusted = True + import LttPAdjuster + _, adjustedromfile = LttPAdjuster.adjust(lastSettings) + + if hasattr(lastSettings, "world"): + delattr(lastSettings, "world") + else: + adjusted = False + if adjusted: + try: + shutil.move(adjustedromfile, romfile) + adjustedromfile = romfile + except Exception as e: + logging.exception(e) + else: + adjusted = False + return adjustedromfile, adjusted + + +class ALTTPSNIClient(SNIClient): + game = "A Link to the Past" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_read, snes_buffered_write, snes_flush_writes + invincible = await snes_read(ctx, WRAM_START + 0x037B, 1) + last_health = await snes_read(ctx, WRAM_START + 0xF36D, 1) + await asyncio.sleep(0.25) + health = await snes_read(ctx, WRAM_START + 0xF36D, 1) + if not invincible or not last_health or not health: + ctx.death_state = DeathState.dead + ctx.last_death_link = time.time() + return + if not invincible[0] and last_health[0] == health[0]: + snes_buffered_write(ctx, WRAM_START + 0xF36D, bytes([0])) # set current health to 0 + snes_buffered_write(ctx, WRAM_START + 0x0373, + bytes([8])) # deal 1 full heart of damage at next opportunity + + await snes_flush_writes(ctx) + await asyncio.sleep(1) + + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if not gamemode or gamemode[0] in DEATH_MODES: + ctx.death_state = DeathState.dead + + + async def validate_rom(self, ctx): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + + rom_name = await snes_read(ctx, ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"AP": + return False + + ctx.game = self.game + ctx.items_handling = 0b001 # full local + + ctx.rom = rom_name + + death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) + + if death_link: + ctx.allow_collect = bool(death_link[0] & 0b100) + ctx.death_link_allow_survive = bool(death_link[0] & 0b10) + await ctx.update_death_link(bool(death_link[0] & 0b1)) + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_read, snes_buffered_write, snes_flush_writes + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + currently_dead = gamemode[0] in DEATH_MODES + await ctx.handle_deathlink_state(currently_dead) + + gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1) + game_timer = await snes_read(ctx, SAVEDATA_START + 0x42E, 4) + if gamemode is None or gameend is None or game_timer is None or \ + (gamemode[0] not in INGAME_MODES and gamemode[0] not in ENDGAME_MODES): + return + + if gameend[0]: + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + if gamemode in ENDGAME_MODES: # triforce room and credits + return + + data = await snes_read(ctx, RECV_PROGRESS_ADDR, 8) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] + roomid = data[4] | (data[5] << 8) + roomdata = data[6] + scout_location = data[7] + + if recv_index < len(ctx.items_received) and recv_item == 0: + item = ctx.items_received[recv_index] + recv_index += 1 + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], recv_index, len(ctx.items_received))) + + snes_buffered_write(ctx, RECV_PROGRESS_ADDR, + bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + snes_buffered_write(ctx, RECV_ITEM_ADDR, + bytes([item.item])) + snes_buffered_write(ctx, RECV_ITEM_PLAYER_ADDR, + bytes([min(ROM_PLAYER_LIMIT, item.player) if item.player != ctx.slot else 0])) + if scout_location > 0 and scout_location in ctx.locations_info: + snes_buffered_write(ctx, SCOUTREPLY_LOCATION_ADDR, + bytes([scout_location])) + snes_buffered_write(ctx, SCOUTREPLY_ITEM_ADDR, + bytes([ctx.locations_info[scout_location].item])) + snes_buffered_write(ctx, SCOUTREPLY_PLAYER_ADDR, + bytes([min(ROM_PLAYER_LIMIT, ctx.locations_info[scout_location].player)])) + + await snes_flush_writes(ctx) + + if scout_location > 0 and scout_location not in ctx.locations_scouted: + ctx.locations_scouted.add(scout_location) + await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) + await track_locations(ctx, roomid, roomdata) diff --git a/worlds/alttp/__init__.py b/worlds/alttp/__init__.py index ce53154e92..8431af9a26 100644 --- a/worlds/alttp/__init__.py +++ b/worlds/alttp/__init__.py @@ -15,6 +15,7 @@ from .Items import item_init_table, item_name_groups, item_table, GetBeemizerIte from .Options import alttp_options, smallkey_shuffle from .Regions import lookup_name_to_id, create_regions, mark_light_world_regions, lookup_vanilla_location_to_entrance, \ is_main_entrance +from .Client import ALTTPSNIClient from .Rom import LocalRom, patch_rom, patch_race_rom, check_enemizer, patch_enemizer, apply_rom_settings, \ get_hash_string, get_base_rom_path, LttPDeltaPatch from .Rules import set_rules diff --git a/worlds/dkc3/Client.py b/worlds/dkc3/Client.py index 7ab82187b0..77ed51fecb 100644 --- a/worlds/dkc3/Client.py +++ b/worlds/dkc3/Client.py @@ -2,75 +2,69 @@ import logging import asyncio from NetUtils import ClientStatus, color -from SNIClient import Context, snes_buffered_write, snes_flush_writes, snes_read -from Patch import GAME_DKC3 +from worlds.AutoSNIClient import SNIClient snes_logger = logging.getLogger("SNES") -# DKC3 - DKC3_TODO: Check these values +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 SRAM_START = 0xE00000 -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - DKC3_ROMNAME_START = 0x00FFC0 DKC3_ROMHASH_START = 0x7FC0 ROMNAME_SIZE = 0x15 ROMHASH_SIZE = 0x15 -DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632 # DKC3_TODO: Find a permanent home for this +DKC3_RECV_PROGRESS_ADDR = WRAM_START + 0x632 DKC3_FILE_NAME_ADDR = WRAM_START + 0x5D9 DEATH_LINK_ACTIVE_ADDR = DKC3_ROMNAME_START + 0x15 # DKC3_TODO: Find a permanent home for this -async def deathlink_kill_player(ctx: Context): - pass - #if ctx.game == GAME_DKC3: +class DKC3SNIClient(SNIClient): + game = "Donkey Kong Country 3" + + async def deathlink_kill_player(self, ctx): + pass # DKC3_TODO: Handle Receiving Deathlink -async def dkc3_rom_init(ctx: Context): - if not ctx.rom: - ctx.finished_game = False - ctx.death_link_allow_survive = False - game_name = await snes_read(ctx, DKC3_ROMNAME_START, 0x15) - if game_name is None or game_name != b"DONKEY KONG COUNTRY 3": - return False - else: - ctx.game = GAME_DKC3 - ctx.items_handling = 0b111 # remote items + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) - if rom is None or rom == bytes([0] * ROMHASH_SIZE): + rom_name = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:2] != b"D3": return False - ctx.rom = rom + ctx.game = self.game + ctx.items_handling = 0b111 # remote items + + ctx.rom = rom_name #death_link = await snes_read(ctx, DEATH_LINK_ACTIVE_ADDR, 1) ## DKC3_TODO: Handle Deathlink #if death_link: # ctx.allow_collect = bool(death_link[0] & 0b100) # await ctx.update_death_link(bool(death_link[0] & 0b1)) - return True + return True -async def dkc3_game_watcher(ctx: Context): - if ctx.game == GAME_DKC3: + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read # DKC3_TODO: Handle Deathlink save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5) - if save_file_name is None or save_file_name[0] == 0x00: + if save_file_name is None or save_file_name[0] == 0x00 or save_file_name == bytes([0x55] * 0x05): # We haven't loaded a save file return new_checks = [] from worlds.dkc3.Rom import location_rom_data, item_rom_data, boss_location_ids, level_unlock_map + location_ram_data = await snes_read(ctx, WRAM_START + 0x5FE, 0x81) for loc_id, loc_data in location_rom_data.items(): if loc_id not in ctx.locations_checked: - data = await snes_read(ctx, WRAM_START + loc_data[0], 1) - masked_data = data[0] & (1 << loc_data[1]) + data = location_ram_data[loc_data[0] - 0x5FE] + masked_data = data & (1 << loc_data[1]) bit_set = (masked_data != 0) invert_bit = ((len(loc_data) >= 3) and loc_data[2]) if bit_set != invert_bit: @@ -78,8 +72,9 @@ async def dkc3_game_watcher(ctx: Context): new_checks.append(loc_id) verify_save_file_name = await snes_read(ctx, DKC3_FILE_NAME_ADDR, 0x5) - if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name != save_file_name: + if verify_save_file_name is None or verify_save_file_name[0] == 0x00 or verify_save_file_name == bytes([0x55] * 0x05) or verify_save_file_name != save_file_name: # We have somehow exited the save file (or worse) + ctx.rom = None return rom = await snes_read(ctx, DKC3_ROMHASH_START, ROMHASH_SIZE) @@ -184,8 +179,9 @@ async def dkc3_game_watcher(ctx: Context): await snes_flush_writes(ctx) - # DKC3_TODO: This method of collect should work, however it does not unlock the next level correctly when previous is flagged # Handle Collected Locations + levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60) + tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60) for loc_id in ctx.checked_locations: if loc_id not in ctx.locations_checked and loc_id not in boss_location_ids: loc_data = location_rom_data[loc_id] @@ -193,30 +189,24 @@ async def dkc3_game_watcher(ctx: Context): invert_bit = ((len(loc_data) >= 3) and loc_data[2]) if not invert_bit: masked_data = data[0] | (1 << loc_data[1]) - #print("Collected Location: ", hex(loc_data[0]), " | ", loc_data[1]) snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data])) if (loc_data[1] == 1): # Make the next levels accessible level_id = loc_data[0] - 0x632 - levels_to_tiles = await snes_read(ctx, ROM_START + 0x3FF800, 0x60) - tiles_to_levels = await snes_read(ctx, ROM_START + 0x3FF860, 0x60) tile_id = levels_to_tiles[level_id] if levels_to_tiles[level_id] != 0xFF else level_id tile_id = tile_id + 0x632 - #print("Tile ID: ", hex(tile_id)) if tile_id in level_unlock_map: for next_level_address in level_unlock_map[tile_id]: next_level_id = next_level_address - 0x632 next_tile_id = tiles_to_levels[next_level_id] if tiles_to_levels[next_level_id] != 0xFF else next_level_id next_tile_id = next_tile_id + 0x632 - #print("Next Level ID: ", hex(next_tile_id)) next_data = await snes_read(ctx, WRAM_START + next_tile_id, 1) snes_buffered_write(ctx, WRAM_START + next_tile_id, bytes([next_data[0] | 0x01])) await snes_flush_writes(ctx) else: masked_data = data[0] & ~(1 << loc_data[1]) - print("Collected Inverted Location: ", hex(loc_data[0]), " | ", loc_data[1]) snes_buffered_write(ctx, WRAM_START + loc_data[0], bytes([masked_data])) await snes_flush_writes(ctx) ctx.locations_checked.add(loc_id) diff --git a/worlds/dkc3/__init__.py b/worlds/dkc3/__init__.py index d45de8f85a..332f23e491 100644 --- a/worlds/dkc3/__init__.py +++ b/worlds/dkc3/__init__.py @@ -11,6 +11,7 @@ from .Regions import create_regions, connect_regions from .Levels import level_list from .Rules import set_rules from .Names import ItemName, LocationName +from .Client import DKC3SNIClient from ..AutoWorld import WebWorld, World from .Rom import LocalRom, patch_rom, get_base_rom_path, DKC3DeltaPatch import Patch diff --git a/worlds/factorio/data/mod_template/control.lua b/worlds/factorio/data/mod_template/control.lua index 51cd21e4da..98c9ca621a 100644 --- a/worlds/factorio/data/mod_template/control.lua +++ b/worlds/factorio/data/mod_template/control.lua @@ -157,6 +157,7 @@ function on_player_created(event) {%- if silo == 2 %} check_spawn_silo(game.players[event.player_index].force) {%- endif %} + dumpInfo(player.force) end script.on_event(defines.events.on_player_created, on_player_created) @@ -491,6 +492,7 @@ commands.add_command("ap-sync", "Used by the Archipelago client to get progress ["death_link"] = DEATH_LINK, ["energy"] = chain_lookup(forcedata, "energy"), ["energy_bridges"] = chain_lookup(forcedata, "energy_bridges"), + ["multiplayer"] = #game.players > 1, } for tech_name, tech in pairs(force.technologies) do @@ -596,5 +598,13 @@ commands.add_command("ap-energylink", "Used by the Archipelago client to manage global.forcedata[force].energy = global.forcedata[force].energy + change end) +commands.add_command("toggle-ap-send-filter", "Toggle filtering of item sends that get displayed in-game to only those that involve you.", function(call) + log("Player command toggle-ap-send-filter") -- notifies client +end) + +commands.add_command("toggle-ap-chat", "Toggle sending of chat messages from players on the Factorio server to Archipelago.", function(call) + log("Player command toggle-ap-chat") -- notifies client +end) + -- data progressive_technologies = {{ dict_to_lua(progressive_technology_table) }} diff --git a/worlds/factorio/docs/setup_en.md b/worlds/factorio/docs/setup_en.md index 560a37d1e3..73ff5c8c48 100644 --- a/worlds/factorio/docs/setup_en.md +++ b/worlds/factorio/docs/setup_en.md @@ -132,6 +132,8 @@ This allows you to host your own Factorio game. 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`. +For more information about the commands you can use, see the [Commands Guide](/tutorial/Archipelago/commands/en) and +[Other Settings](#other-settings). ## Allowing Other People to Join Your Game @@ -141,19 +143,34 @@ you can also issue the `!help` command to learn about additional commands like ` 4. Provide your IP address to anyone you want to join your game, and have them follow the steps for "Connecting to Someone Else's Factorio Game" above. +## Other Settings + +- By default, all item sends are displayed in-game. In larger async seeds this may become overly spammy. + To hide all item sends that are not to or from your factory, do one of the following: + - Type `/toggle-ap-send-filter` in-game + - Type `/toggle_send_filter` in the Archipelago Client + - In your `host.yaml` set +``` +factorio_options: + filter_item_sends: true +``` +- By default, in-game chat is bridged to Archipelago. If you prefer to be able to speak privately, you can disable this + feature by doing one of the following: + - Type `/toggle-ap-chat` in-game + - Type `/toggle_chat` in the Archipelago Client + - In your `host.yaml` set +``` +factorio_options: + bridge_chat_out: false +``` + Note that this will also disable `!` commands from within the game, and that it will not affect incoming chat. + ## Troubleshooting In case any problems should occur, the Archipelago Client will create a file `FactorioClient.txt` in the `/logs`. The contents of this file may help you troubleshoot an issue on your own and is vital for requesting help from other people in Archipelago. -## Commands in game - -Once you have connected to the server successfully using the Archipelago Factorio Client you should see a message -stating you can get help using Archipelago commands by typing `!help`. Commands cannot currently be sent from within -the Factorio session, but you can send them from the Archipelago Factorio Client. For more information about the commands -you can use see the [commands guide](/tutorial/Archipelago/commands/en). - ## Additional Resources - Alternate Tutorial by diff --git a/worlds/generic/docs/advanced_settings_en.md b/worlds/generic/docs/advanced_settings_en.md index d19c9d5ee6..45f653e8bb 100644 --- a/worlds/generic/docs/advanced_settings_en.md +++ b/worlds/generic/docs/advanced_settings_en.md @@ -106,7 +106,7 @@ settings. If a game can be rolled it **must** have a settings section even if it 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` +Currently, these options are `start_inventory`, `early_items`, `start_hints`, `local_items`, `non_local_items`, `start_location_hints` , `exclude_locations`, and various plando options. See the plando guide for more info on plando options. Plando @@ -115,6 +115,8 @@ guide: [Archipelago Plando Guide](/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. +* `early_items` is formatted in the same way as `start_inventory` and will force the number of each item specified to be +forced into locations that are reachable from the start, before obtaining any items. * `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. @@ -172,6 +174,8 @@ A Link to the Past: - Quake non_local_items: - Moon Pearl + early_items: + Flute: 1 start_location_hints: - Spike Cave priority_locations: @@ -235,6 +239,9 @@ Timespinner: * `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. +* `early_items` forces the `Flute` to be placed in a location that is available from the beginning of the game ("Sphere +1"). Since it is not specified in `local_items` or `non_local_items`, it can be placed one of these locations in any +world. * `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. * `priority_locations` forces a progression item to be placed on the `Link's House` location. diff --git a/worlds/minecraft/Rules.py b/worlds/minecraft/Rules.py index ecdb459769..06576d7012 100644 --- a/worlds/minecraft/Rules.py +++ b/worlds/minecraft/Rules.py @@ -173,7 +173,7 @@ def set_advancement_rules(world: MultiWorld, player: int): state.can_reach("Hero of the Village", "Location", player)) # Bad Omen, Hero of the Village set_rule(world.get_location("Bullseye", player), lambda state: state.has("Archery", player) and state.has("Progressive Tools", player, 2) and state._mc_has_iron_ingots(player)) set_rule(world.get_location("Spooky Scary Skeleton", player), lambda state: state._mc_basic_combat(player)) - set_rule(world.get_location("Two by Two", player), lambda state: state._mc_has_iron_ingots(player) and state._mc_can_adventure(player)) # shears > seagrass > turtles; nether > striders; gold carrots > horses skips ingots + set_rule(world.get_location("Two by Two", player), lambda state: state._mc_has_iron_ingots(player) and state.has("Bucket", player) and state._mc_can_adventure(player)) # shears > seagrass > turtles; buckets of tropical fish > axolotls; nether > striders; gold carrots > horses skips ingots # set_rule(world.get_location("Stone Age", player), lambda state: True) set_rule(world.get_location("Two Birds, One Arrow", player), lambda state: state._mc_craft_crossbow(player) and state._mc_can_enchant(player)) # set_rule(world.get_location("We Need to Go Deeper", player), lambda state: True) diff --git a/worlds/overcooked2/Logic.py b/worlds/overcooked2/Logic.py index 6fb1a50a41..a4f1f0fb86 100644 --- a/worlds/overcooked2/Logic.py +++ b/worlds/overcooked2/Logic.py @@ -291,6 +291,11 @@ level_logic = { "Progressive Throw/Catch", ], { # Additive + ("Sharp Knife", 1.0), + ("Dish Scrubber", 1.0), + ("Clean Dishes", 0.5), + ("Guest Patience", 0.25), + ("Burn Leniency", 0.25), }, ) ), diff --git a/worlds/sa2b/Names/__init__.py b/worlds/sa2b/Names/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/worlds/sc2wol/Items.py b/worlds/sc2wol/Items.py index 6bb74076fb..6cb768de58 100644 --- a/worlds/sc2wol/Items.py +++ b/worlds/sc2wol/Items.py @@ -1,5 +1,7 @@ -from BaseClasses import Item, ItemClassification +from BaseClasses import Item, ItemClassification, MultiWorld import typing + +from .Options import get_option_value from .MissionTables import vanilla_mission_req_table @@ -9,6 +11,7 @@ class ItemData(typing.NamedTuple): number: typing.Optional[int] classification: ItemClassification = ItemClassification.useful quantity: int = 1 + parent_item: str = None class StarcraftWoLItem(Item): @@ -48,51 +51,51 @@ item_table = { "Progressive Ship Weapon": ItemData(105 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 8, quantity=3), "Progressive Ship Armor": ItemData(106 + SC2WOL_ITEM_ID_OFFSET, "Upgrade", 10, quantity=3), - "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0), - "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1), - "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler), - "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3), - "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4), - "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5), + "Projectile Accelerator (Bunker)": ItemData(200 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 0, parent_item="Bunker"), + "Neosteel Bunker (Bunker)": ItemData(201 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 1, parent_item="Bunker"), + "Titanium Housing (Missile Turret)": ItemData(202 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 2, classification=ItemClassification.filler, parent_item="Missile Turret"), + "Hellstorm Batteries (Missile Turret)": ItemData(203 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 3, parent_item="Missile Turret"), + "Advanced Construction (SCV)": ItemData(204 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 4, parent_item="SCV"), + "Dual-Fusion Welders (SCV)": ItemData(205 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 5, parent_item="SCV"), "Fire-Suppression System (Building)": ItemData(206 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 6, classification=ItemClassification.filler), "Orbital Command (Building)": ItemData(207 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 7), - "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8), - "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression), - "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.progression), - "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression), - "Incinerator Gauntlets (Firebat)": ItemData(212 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 12, classification=ItemClassification.filler), - "Juggernaut Plating (Firebat)": ItemData(213 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 13), - "Concussive Shells (Marauder)": ItemData(214 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 14), - "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15), - "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16), - "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression), + "Stimpack (Marine)": ItemData(208 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 8, parent_item="Marine"), + "Combat Shield (Marine)": ItemData(209 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 9, classification=ItemClassification.progression, parent_item="Marine"), + "Advanced Medic Facilities (Medic)": ItemData(210 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 10, classification=ItemClassification.progression, parent_item="Medic"), + "Stabilizer Medpacks (Medic)": ItemData(211 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 11, classification=ItemClassification.progression, parent_item="Medic"), + "Incinerator Gauntlets (Firebat)": ItemData(212 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 12, classification=ItemClassification.filler, parent_item="Firebat"), + "Juggernaut Plating (Firebat)": ItemData(213 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 13, parent_item="Firebat"), + "Concussive Shells (Marauder)": ItemData(214 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 14, parent_item="Marauder"), + "Kinetic Foam (Marauder)": ItemData(215 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 15, parent_item="Marauder"), + "U-238 Rounds (Reaper)": ItemData(216 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 16, parent_item="Reaper"), + "G-4 Clusterbomb (Reaper)": ItemData(217 + SC2WOL_ITEM_ID_OFFSET, "Armory 1", 17, classification=ItemClassification.progression, parent_item="Reaper"), - "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler), - "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1), - "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler), - "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler), - "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4), - "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5), - "Tri-Lithium Power Cell (Diamondback)": ItemData(306 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 6, classification=ItemClassification.filler), - "Shaped Hull (Diamondback)": ItemData(307 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 7, classification=ItemClassification.filler), - "Maelstrom Rounds (Siege Tank)": ItemData(308 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 8), - "Shaped Blast (Siege Tank)": ItemData(309 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 9), - "Rapid Deployment Tube (Medivac)": ItemData(310 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 10, classification=ItemClassification.filler), - "Advanced Healing AI (Medivac)": ItemData(311 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 11, classification=ItemClassification.filler), - "Tomahawk Power Cells (Wraith)": ItemData(312 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 12, classification=ItemClassification.filler), - "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler), - "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14), - "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15), - "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler), - "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17), - "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler), - "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler), - "Ocular Implants (Ghost)": ItemData(320 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 20), - "Crius Suit (Ghost)": ItemData(321 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 21), - "Psionic Lash (Spectre)": ItemData(322 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 22, classification=ItemClassification.progression), - "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23), - "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler), - "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler), + "Twin-Linked Flamethrower (Hellion)": ItemData(300 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 0, classification=ItemClassification.filler, parent_item="Hellion"), + "Thermite Filaments (Hellion)": ItemData(301 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 1, parent_item="Hellion"), + "Cerberus Mine (Vulture)": ItemData(302 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 2, classification=ItemClassification.filler, parent_item="Vulture"), + "Replenishable Magazine (Vulture)": ItemData(303 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 3, classification=ItemClassification.filler, parent_item="Vulture"), + "Multi-Lock Weapons System (Goliath)": ItemData(304 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 4, parent_item="Goliath"), + "Ares-Class Targeting System (Goliath)": ItemData(305 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 5, parent_item="Goliath"), + "Tri-Lithium Power Cell (Diamondback)": ItemData(306 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 6, classification=ItemClassification.filler, parent_item="Diamondback"), + "Shaped Hull (Diamondback)": ItemData(307 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 7, classification=ItemClassification.filler, parent_item="Diamondback"), + "Maelstrom Rounds (Siege Tank)": ItemData(308 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 8, classification=ItemClassification.progression, parent_item="Siege Tank"), + "Shaped Blast (Siege Tank)": ItemData(309 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 9, parent_item="Siege Tank"), + "Rapid Deployment Tube (Medivac)": ItemData(310 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 10, classification=ItemClassification.filler, parent_item="Medivac"), + "Advanced Healing AI (Medivac)": ItemData(311 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 11, classification=ItemClassification.filler, parent_item="Medivac"), + "Tomahawk Power Cells (Wraith)": ItemData(312 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 12, classification=ItemClassification.filler, parent_item="Wraith"), + "Displacement Field (Wraith)": ItemData(313 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 13, classification=ItemClassification.filler, parent_item="Wraith"), + "Ripwave Missiles (Viking)": ItemData(314 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 14, parent_item="Viking"), + "Phobos-Class Weapons System (Viking)": ItemData(315 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 15, parent_item="Viking"), + "Cross-Spectrum Dampeners (Banshee)": ItemData(316 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 16, classification=ItemClassification.filler, parent_item="Banshee"), + "Shockwave Missile Battery (Banshee)": ItemData(317 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 17, parent_item="Banshee"), + "Missile Pods (Battlecruiser)": ItemData(318 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 18, classification=ItemClassification.filler, parent_item="Battlecruiser"), + "Defensive Matrix (Battlecruiser)": ItemData(319 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 19, classification=ItemClassification.filler, parent_item="Battlecruiser"), + "Ocular Implants (Ghost)": ItemData(320 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 20, parent_item="Ghost"), + "Crius Suit (Ghost)": ItemData(321 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 21, parent_item="Ghost"), + "Psionic Lash (Spectre)": ItemData(322 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 22, classification=ItemClassification.progression, parent_item="Spectre"), + "Nyx-Class Cloaking Module (Spectre)": ItemData(323 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 23, parent_item="Spectre"), + "330mm Barrage Cannon (Thor)": ItemData(324 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 24, classification=ItemClassification.filler, parent_item="Thor"), + "Immortality Protocol (Thor)": ItemData(325 + SC2WOL_ITEM_ID_OFFSET, "Armory 2", 25, classification=ItemClassification.filler, parent_item="Thor"), "Bunker": ItemData(400 + SC2WOL_ITEM_ID_OFFSET, "Building", 0, classification=ItemClassification.progression), "Missile Turret": ItemData(401 + SC2WOL_ITEM_ID_OFFSET, "Building", 1, classification=ItemClassification.progression), @@ -117,16 +120,16 @@ item_table = { "Science Vessel": ItemData(607 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 7, classification=ItemClassification.progression), "Tech Reactor": ItemData(608 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 8), "Orbital Strike": ItemData(609 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 9), - "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10), - "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11), - "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12), - "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13), + "Shrike Turret": ItemData(610 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 10, parent_item="Bunker"), + "Fortified Bunker": ItemData(611 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 11, parent_item="Bunker"), + "Planetary Fortress": ItemData(612 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 12, classification=ItemClassification.progression), + "Perdition Turret": ItemData(613 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 13, classification=ItemClassification.progression), "Predator": ItemData(614 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 14, classification=ItemClassification.filler), "Hercules": ItemData(615 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 15, classification=ItemClassification.progression), "Cellular Reactor": ItemData(616 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 16, classification=ItemClassification.filler), "Regenerative Bio-Steel": ItemData(617 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 17, classification=ItemClassification.filler), - "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18), - "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.filler), + "Hive Mind Emulator": ItemData(618 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 18, ItemClassification.progression), + "Psi Disrupter": ItemData(619 + SC2WOL_ITEM_ID_OFFSET, "Laboratory", 19, classification=ItemClassification.progression), "Zealot": ItemData(700 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 0, classification=ItemClassification.progression), "Stalker": ItemData(701 + SC2WOL_ITEM_ID_OFFSET, "Protoss", 1, classification=ItemClassification.progression), @@ -141,15 +144,33 @@ item_table = { "+15 Starting Minerals": ItemData(800 + SC2WOL_ITEM_ID_OFFSET, "Minerals", 15, quantity=0, classification=ItemClassification.filler), "+15 Starting Vespene": ItemData(801 + SC2WOL_ITEM_ID_OFFSET, "Vespene", 15, quantity=0, classification=ItemClassification.filler), "+2 Starting Supply": ItemData(802 + SC2WOL_ITEM_ID_OFFSET, "Supply", 2, quantity=0, classification=ItemClassification.filler), + + # "Keystone Piece": ItemData(850 + SC2WOL_ITEM_ID_OFFSET, "Goal", 0, quantity=0, classification=ItemClassification.progression_skip_balancing) } -basic_unit: typing.Tuple[str, ...] = ( + +basic_units = { 'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture' -) +} + +advanced_basic_units = { + 'Reaper', + 'Goliath', + 'Diamondback', + 'Viking' +} + + +def get_basic_units(world: MultiWorld, player: int) -> typing.Set[str]: + if get_option_value(world, player, 'required_tactics') > 0: + return basic_units.union(advanced_basic_units) + else: + return basic_units + item_name_groups = {} for item, data in item_table.items(): @@ -161,6 +182,22 @@ filler_items: typing.Tuple[str, ...] = ( '+15 Starting Vespene' ) +defense_ratings = { + "Siege Tank": 5, + "Maelstrom Rounds": 2, + "Planetary Fortress": 3, + # Bunker w/ Marine/Marauder: 3, + "Perdition Turret": 2, + "Missile Turret": 2, + "Vulture": 2 +} +zerg_defense_ratings = { + "Perdition Turret": 2, + # Bunker w/ Firebat + "Hive Mind Emulator": 3, + "Psi Disruptor": 3 +} + lookup_id_to_name: typing.Dict[int, str] = {data.code: item_name for item_name, data in get_full_item_list().items() if data.code} # Map type to expected int @@ -176,4 +213,5 @@ type_flaggroups: typing.Dict[str, int] = { "Minerals": 8, "Vespene": 9, "Supply": 10, + "Goal": 11 } diff --git a/worlds/sc2wol/Locations.py b/worlds/sc2wol/Locations.py index 14dd25fd52..f778c91be8 100644 --- a/worlds/sc2wol/Locations.py +++ b/worlds/sc2wol/Locations.py @@ -1,5 +1,6 @@ from typing import List, Tuple, Optional, Callable, NamedTuple from BaseClasses import MultiWorld +from .Options import get_option_value from BaseClasses import Location @@ -19,6 +20,7 @@ class LocationData(NamedTuple): def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[LocationData, ...]: # Note: rules which are ended with or True are rules identified as needed later when restricted units is an option + logic_level = get_option_value(world, player, 'required_tactics') location_table: List[LocationData] = [ LocationData("Liberation Day", "Liberation Day: Victory", SC2WOL_LOC_ID_OFFSET + 100), LocationData("Liberation Day", "Liberation Day: First Statue", SC2WOL_LOC_ID_OFFSET + 101), @@ -32,26 +34,33 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Outlaws", "The Outlaws: Rebel Base", SC2WOL_LOC_ID_OFFSET + 201, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Zero Hour", "Zero Hour: Victory", SC2WOL_LOC_ID_OFFSET + 300, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 2 and + (logic_level > 0 or state._sc2wol_has_anti_air(world, player))), LocationData("Zero Hour", "Zero Hour: First Group Rescued", SC2WOL_LOC_ID_OFFSET + 301), LocationData("Zero Hour", "Zero Hour: Second Group Rescued", SC2WOL_LOC_ID_OFFSET + 302, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Zero Hour", "Zero Hour: Third Group Rescued", SC2WOL_LOC_ID_OFFSET + 303, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 2), LocationData("Evacuation", "Evacuation: Victory", SC2WOL_LOC_ID_OFFSET + 400, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Evacuation", "Evacuation: First Chysalis", SC2WOL_LOC_ID_OFFSET + 401), LocationData("Evacuation", "Evacuation: Second Chysalis", SC2WOL_LOC_ID_OFFSET + 402, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Evacuation", "Evacuation: Third Chysalis", SC2WOL_LOC_ID_OFFSET + 403, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Outbreak", "Outbreak: Victory", SC2WOL_LOC_ID_OFFSET + 500, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 4 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Outbreak", "Outbreak: Left Infestor", SC2WOL_LOC_ID_OFFSET + 501, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Outbreak", "Outbreak: Right Infestor", SC2WOL_LOC_ID_OFFSET + 502, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: state._sc2wol_defense_rating(world, player, True, False) >= 2 and + (state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Safe Haven", "Safe Haven: Victory", SC2WOL_LOC_ID_OFFSET + 600, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_competent_anti_air(world, player)), @@ -66,38 +75,48 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L state._sc2wol_has_competent_anti_air(world, player)), LocationData("Haven's Fall", "Haven's Fall: Victory", SC2WOL_LOC_ID_OFFSET + 700, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: North Hive", SC2WOL_LOC_ID_OFFSET + 701, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: East Hive", SC2WOL_LOC_ID_OFFSET + 702, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Haven's Fall", "Haven's Fall: South Hive", SC2WOL_LOC_ID_OFFSET + 703, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + state._sc2wol_has_competent_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, True) >= 3), LocationData("Smash and Grab", "Smash and Grab: Victory", SC2WOL_LOC_ID_OFFSET + 800, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_competent_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Smash and Grab", "Smash and Grab: First Relic", SC2WOL_LOC_ID_OFFSET + 801), LocationData("Smash and Grab", "Smash and Grab: Second Relic", SC2WOL_LOC_ID_OFFSET + 802), LocationData("Smash and Grab", "Smash and Grab: Third Relic", SC2WOL_LOC_ID_OFFSET + 803, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("Smash and Grab", "Smash and Grab: Fourth Relic", SC2WOL_LOC_ID_OFFSET + 804, lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_anti_air(world, player)), + (logic_level > 0 and state._sc2wol_has_anti_air(world, player) + or state._sc2wol_has_competent_anti_air(world, player))), LocationData("The Dig", "The Dig: Victory", SC2WOL_LOC_ID_OFFSET + 900, - lambda state: state._sc2wol_has_common_unit(world, player) and - state._sc2wol_has_anti_air(world, player) and - state._sc2wol_has_heavy_defense(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + state._sc2wol_defense_rating(world, player, False) >= 7), LocationData("The Dig", "The Dig: Left Relic", SC2WOL_LOC_ID_OFFSET + 901, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Dig", "The Dig: Right Ground Relic", SC2WOL_LOC_ID_OFFSET + 902, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Dig", "The Dig: Right Cliff Relic", SC2WOL_LOC_ID_OFFSET + 903, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_defense_rating(world, player, False) >= 5), LocationData("The Moebius Factor", "The Moebius Factor: Victory", SC2WOL_LOC_ID_OFFSET + 1000, - lambda state: state._sc2wol_has_air(world, player) and state._sc2wol_has_anti_air(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + (state._sc2wol_has_air(world, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(world, player))), LocationData("The Moebius Factor", "The Moebius Factor: South Rescue", SC2WOL_LOC_ID_OFFSET + 1003, lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Wall Rescue", SC2WOL_LOC_ID_OFFSET + 1004, @@ -109,7 +128,10 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Moebius Factor", "The Moebius Factor: Alive Inside Rescue", SC2WOL_LOC_ID_OFFSET + 1007, lambda state: state._sc2wol_able_to_rescue(world, player)), LocationData("The Moebius Factor", "The Moebius Factor: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1008, - lambda state: state._sc2wol_has_air(world, player)), + lambda state: state._sc2wol_has_anti_air(world, player) and + (state._sc2wol_has_air(world, player) + or state.has_any({'Medivac', 'Hercules'}, player) + and state._sc2wol_has_common_unit(world, player))), LocationData("Supernova", "Supernova: Victory", SC2WOL_LOC_ID_OFFSET + 1100, lambda state: state._sc2wol_beats_protoss_deathball(world, player)), LocationData("Supernova", "Supernova: West Relic", SC2WOL_LOC_ID_OFFSET + 1101), @@ -119,37 +141,23 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("Supernova", "Supernova: East Relic", SC2WOL_LOC_ID_OFFSET + 1104, lambda state: state._sc2wol_beats_protoss_deathball(world, player)), LocationData("Maw of the Void", "Maw of the Void: Victory", SC2WOL_LOC_ID_OFFSET + 1200, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: Landing Zone Cleared", SC2WOL_LOC_ID_OFFSET + 1201), LocationData("Maw of the Void", "Maw of the Void: Expansion Prisoners", SC2WOL_LOC_ID_OFFSET + 1202, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: South Close Prisoners", SC2WOL_LOC_ID_OFFSET + 1203, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: logic_level > 0 or state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: South Far Prisoners", SC2WOL_LOC_ID_OFFSET + 1204, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Maw of the Void", "Maw of the Void: North Prisoners", SC2WOL_LOC_ID_OFFSET + 1205, - lambda state: state.has('Battlecruiser', player) or - state._sc2wol_has_air(world, player) and - state._sc2wol_has_competent_anti_air(world, player) and - state.has('Science Vessel', player)), + lambda state: state._sc2wol_survives_rip_field(world, player)), LocationData("Devil's Playground", "Devil's Playground: Victory", SC2WOL_LOC_ID_OFFSET + 1300, - lambda state: state._sc2wol_has_anti_air(world, player) and ( - state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), + lambda state: logic_level > 0 or + state._sc2wol_has_anti_air(world, player) and ( + state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player))), LocationData("Devil's Playground", "Devil's Playground: Tosh's Miners", SC2WOL_LOC_ID_OFFSET + 1301), LocationData("Devil's Playground", "Devil's Playground: Brutalisk", SC2WOL_LOC_ID_OFFSET + 1302, - lambda state: state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), + lambda state: logic_level > 0 or state._sc2wol_has_common_unit(world, player) or state.has("Reaper", player)), LocationData("Welcome to the Jungle", "Welcome to the Jungle: Victory", SC2WOL_LOC_ID_OFFSET + 1400, lambda state: state._sc2wol_has_common_unit(world, player) and state._sc2wol_has_competent_anti_air(world, player)), @@ -176,7 +184,8 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L LocationData("The Great Train Robbery", "The Great Train Robbery: Mid Defiler", SC2WOL_LOC_ID_OFFSET + 1702), LocationData("The Great Train Robbery", "The Great Train Robbery: South Defiler", SC2WOL_LOC_ID_OFFSET + 1703), LocationData("Cutthroat", "Cutthroat: Victory", SC2WOL_LOC_ID_OFFSET + 1800, - lambda state: state._sc2wol_has_common_unit(world, player)), + lambda state: state._sc2wol_has_common_unit(world, player) and + (logic_level > 0 or state._sc2wol_has_anti_air)), LocationData("Cutthroat", "Cutthroat: Mira Han", SC2WOL_LOC_ID_OFFSET + 1801, lambda state: state._sc2wol_has_common_unit(world, player)), LocationData("Cutthroat", "Cutthroat: North Relic", SC2WOL_LOC_ID_OFFSET + 1802, @@ -208,40 +217,44 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Media Blitz", "Media Blitz: Science Facility", SC2WOL_LOC_ID_OFFSET + 2004), LocationData("Piercing the Shroud", "Piercing the Shroud: Victory", SC2WOL_LOC_ID_OFFSET + 2100, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Holding Cell Relic", SC2WOL_LOC_ID_OFFSET + 2101), LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk Relic", SC2WOL_LOC_ID_OFFSET + 2102, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: First Escape Relic", SC2WOL_LOC_ID_OFFSET + 2103, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Second Escape Relic", SC2WOL_LOC_ID_OFFSET + 2104, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Piercing the Shroud", "Piercing the Shroud: Brutalisk ", SC2WOL_LOC_ID_OFFSET + 2105, - lambda state: state.has_any({'Combat Shield (Marine)', 'Stabilizer Medpacks (Medic)'}, player)), + lambda state: state._sc2wol_has_mm_upgrade(world, player)), LocationData("Whispers of Doom", "Whispers of Doom: Victory", SC2WOL_LOC_ID_OFFSET + 2200), LocationData("Whispers of Doom", "Whispers of Doom: First Hatchery", SC2WOL_LOC_ID_OFFSET + 2201), LocationData("Whispers of Doom", "Whispers of Doom: Second Hatchery", SC2WOL_LOC_ID_OFFSET + 2202), LocationData("Whispers of Doom", "Whispers of Doom: Third Hatchery", SC2WOL_LOC_ID_OFFSET + 2203), LocationData("A Sinister Turn", "A Sinister Turn: Victory", SC2WOL_LOC_ID_OFFSET + 2300, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), - LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301), - LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302), + LocationData("A Sinister Turn", "A Sinister Turn: Robotics Facility", SC2WOL_LOC_ID_OFFSET + 2301, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), + LocationData("A Sinister Turn", "A Sinister Turn: Dark Shrine", SC2WOL_LOC_ID_OFFSET + 2302, + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), LocationData("A Sinister Turn", "A Sinister Turn: Templar Archives", SC2WOL_LOC_ID_OFFSET + 2303, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Echoes of the Future", "Echoes of the Future: Victory", SC2WOL_LOC_ID_OFFSET + 2400, - lambda state: state._sc2wol_has_protoss_medium_units(world, player)), + lambda state: logic_level > 0 or state._sc2wol_has_protoss_medium_units(world, player)), LocationData("Echoes of the Future", "Echoes of the Future: Close Obelisk", SC2WOL_LOC_ID_OFFSET + 2401), LocationData("Echoes of the Future", "Echoes of the Future: West Obelisk", SC2WOL_LOC_ID_OFFSET + 2402, - lambda state: state._sc2wol_has_protoss_common_units(world, player)), + lambda state: logic_level > 0 or state._sc2wol_has_protoss_common_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Defeat", SC2WOL_LOC_ID_OFFSET + 2500), LocationData("In Utter Darkness", "In Utter Darkness: Protoss Archive", SC2WOL_LOC_ID_OFFSET + 2501, lambda state: state._sc2wol_has_protoss_medium_units(world, player)), LocationData("In Utter Darkness", "In Utter Darkness: Kills", SC2WOL_LOC_ID_OFFSET + 2502, lambda state: state._sc2wol_has_protoss_common_units(world, player)), LocationData("Gates of Hell", "Gates of Hell: Victory", SC2WOL_LOC_ID_OFFSET + 2600, - lambda state: state._sc2wol_has_competent_comp(world, player)), + lambda state: state._sc2wol_has_competent_comp(world, player) and + state._sc2wol_defense_rating(world, player, True) > 6), LocationData("Gates of Hell", "Gates of Hell: Large Army", SC2WOL_LOC_ID_OFFSET + 2601, - lambda state: state._sc2wol_has_competent_comp(world, player)), + lambda state: state._sc2wol_has_competent_comp(world, player) and + state._sc2wol_defense_rating(world, player, True) > 6), LocationData("Belly of the Beast", "Belly of the Beast: Victory", SC2WOL_LOC_ID_OFFSET + 2700), LocationData("Belly of the Beast", "Belly of the Beast: First Charge", SC2WOL_LOC_ID_OFFSET + 2701), LocationData("Belly of the Beast", "Belly of the Beast: Second Charge", SC2WOL_LOC_ID_OFFSET + 2702), @@ -258,15 +271,19 @@ def get_locations(world: Optional[MultiWorld], player: Optional[int]) -> Tuple[L lambda state: state._sc2wol_has_competent_comp(world, player)), LocationData("Shatter the Sky", "Shatter the Sky: Leviathan", SC2WOL_LOC_ID_OFFSET + 2805, lambda state: state._sc2wol_has_competent_comp(world, player)), - LocationData("All-In", "All-In: Victory", None) + LocationData("All-In", "All-In: Victory", None, + lambda state: state._sc2wol_final_mission_requirements(world, player)) ] beat_events = [] - for location_data in location_table: + for i, location_data in enumerate(location_table): + # Removing all item-based logic on No Logic + if logic_level == 2: + location_table[i] = location_data._replace(rule=Location.access_rule) + # Generating Beat event locations if location_data.name.endswith((": Victory", ": Defeat")): beat_events.append( location_data._replace(name="Beat " + location_data.name.rsplit(": ", 1)[0], code=None) ) - return tuple(location_table + beat_events) diff --git a/worlds/sc2wol/LogicMixin.py b/worlds/sc2wol/LogicMixin.py index 52bb6b09a8..1de8295970 100644 --- a/worlds/sc2wol/LogicMixin.py +++ b/worlds/sc2wol/LogicMixin.py @@ -1,31 +1,43 @@ from BaseClasses import MultiWorld from worlds.AutoWorld import LogicMixin +from .Options import get_option_value +from .Items import get_basic_units, defense_ratings, zerg_defense_ratings class SC2WoLLogic(LogicMixin): def _sc2wol_has_common_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder', 'Firebat', 'Hellion', 'Vulture'}, player) - - def _sc2wol_has_bunker_unit(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Marine', 'Marauder'}, player) + return self.has_any(get_basic_units(world, player), player) def _sc2wol_has_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or \ - self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(world, player) + return self.has_any({'Viking', 'Wraith', 'Banshee'}, player) or get_option_value(world, player, 'required_tactics') > 0 \ + and self.has_any({'Hercules', 'Medivac'}, player) and self._sc2wol_has_common_unit(world, player) def _sc2wol_has_air_anti_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Viking', 'Wraith'}, player) + return self.has('Viking', player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has('Wraith', player) def _sc2wol_has_competent_anti_air(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Marine', 'Goliath'}, player) or self._sc2wol_has_air_anti_air(world, player) def _sc2wol_has_anti_air(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser'}, player) or self._sc2wol_has_competent_anti_air(world, player) + return self.has_any({'Missile Turret', 'Thor', 'War Pigs', 'Spartan Company', "Hel's Angel", 'Battlecruiser', 'Wraith'}, player) \ + or self._sc2wol_has_competent_anti_air(world, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) - def _sc2wol_has_heavy_defense(self, world: MultiWorld, player: int) -> bool: - return (self.has_any({'Siege Tank', 'Vulture'}, player) or - self.has('Bunker', player) and self._sc2wol_has_bunker_unit(world, player)) and \ - self._sc2wol_has_anti_air(world, player) + def _sc2wol_defense_rating(self, world: MultiWorld, player: int, zerg_enemy: bool, air_enemy: bool = True) -> bool: + defense_score = sum((defense_ratings[item] for item in defense_ratings if self.has(item, player))) + if self.has_any({'Marine', 'Marauder'}, player) and self.has('Bunker', player): + defense_score += 3 + if zerg_enemy: + defense_score += sum((zerg_defense_ratings[item] for item in zerg_defense_ratings if self.has(item, player))) + if self.has('Firebat', player) and self.has('Bunker', player): + defense_score += 2 + if not air_enemy and self.has('Missile Turret', player): + defense_score -= defense_ratings['Missile Turret'] + # Advanced Tactics bumps defense rating requirements down by 2 + if get_option_value(world, player, 'required_tactics') > 0: + defense_score += 2 + return defense_score def _sc2wol_has_competent_comp(self, world: MultiWorld, player: int) -> bool: return (self.has('Marine', player) or self.has('Marauder', player) and @@ -35,25 +47,50 @@ class SC2WoLLogic(LogicMixin): self.has('Siege Tank', player) and self._sc2wol_has_competent_anti_air(world, player) def _sc2wol_has_train_killers(self, world: MultiWorld, player: int) -> bool: - return (self.has_any({'Siege Tank', 'Diamondback'}, player) or - self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player) - or self.has('Marauders', player)) + return (self.has_any({'Siege Tank', 'Diamondback', 'Marauder'}, player) or get_option_value(world, player, 'required_tactics') > 0 + and self.has_all({'Reaper', "G-4 Clusterbomb"}, player) or self.has_all({'Spectre', 'Psionic Lash'}, player)) def _sc2wol_able_to_rescue(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) + return self.has_any({'Medivac', 'Hercules', 'Raven', 'Viking'}, player) or get_option_value(world, player, 'required_tactics') > 0 def _sc2wol_has_protoss_common_units(self, world: MultiWorld, player: int) -> bool: - return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) + return self.has_any({'Zealot', 'Immortal', 'Stalker', 'Dark Templar'}, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) def _sc2wol_has_protoss_medium_units(self, world: MultiWorld, player: int) -> bool: return self._sc2wol_has_protoss_common_units(world, player) and \ - self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) + self.has_any({'Stalker', 'Void Ray', 'Phoenix', 'Carrier'}, player) \ + or get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'High Templar', 'Dark Templar'}, player) def _sc2wol_beats_protoss_deathball(self, world: MultiWorld, player: int) -> bool: return self.has_any({'Banshee', 'Battlecruiser'}, player) and self._sc2wol_has_competent_anti_air or \ self._sc2wol_has_competent_comp(world, player) and self._sc2wol_has_air_anti_air(world, player) + def _sc2wol_has_mm_upgrade(self, world: MultiWorld, player: int) -> bool: + return self.has_any({"Combat Shield (Marine)", "Stabilizer Medpacks (Medic)"}, player) + + def _sc2wol_survives_rip_field(self, world: MultiWorld, player: int) -> bool: + return self.has("Battlecruiser", player) or \ + self._sc2wol_has_air(world, player) and \ + self._sc2wol_has_competent_anti_air(world, player) and \ + self.has("Science Vessel", player) + + def _sc2wol_has_nukes(self, world: MultiWorld, player: int) -> bool: + return get_option_value(world, player, 'required_tactics') > 0 and self.has_any({'Ghost', 'Spectre'}, player) + + def _sc2wol_final_mission_requirements(self, world: MultiWorld, player: int): + defense_rating = self._sc2wol_defense_rating(world, player, True) + beats_kerrigan = self.has_any({'Marine', 'Banshee', 'Ghost'}, player) or get_option_value(world, player, 'required_tactics') > 0 + if get_option_value(world, player, 'all_in_map') == 0: + # Ground + if self.has_any({'Battlecruiser', 'Banshee'}, player): + defense_rating += 3 + return defense_rating >= 12 and beats_kerrigan + else: + # Air + return defense_rating >= 8 and beats_kerrigan \ + and self.has_any({'Viking', 'Battlecruiser'}, player) \ + and self.has_any({'Hive Mind Emulator', 'Psi Disruptor', 'Missile Turret'}, player) + def _sc2wol_cleared_missions(self, world: MultiWorld, player: int, mission_count: int) -> bool: return self.has_group("Missions", player, mission_count) - - diff --git a/worlds/sc2wol/MissionTables.py b/worlds/sc2wol/MissionTables.py index 4f1b1157ec..8d06944662 100644 --- a/worlds/sc2wol/MissionTables.py +++ b/worlds/sc2wol/MissionTables.py @@ -1,4 +1,7 @@ -from typing import NamedTuple, Dict, List +from typing import NamedTuple, Dict, List, Set + +from BaseClasses import MultiWorld +from .Options import get_option_value no_build_regions_list = ["Liberation Day", "Breakout", "Ghost of a Chance", "Piercing the Shroud", "Whispers of Doom", "Belly of the Beast"] @@ -12,7 +15,6 @@ hard_regions_list = ["Maw of the Void", "Engine of Destruction", "In Utter Darkn class MissionInfo(NamedTuple): id: int - extra_locations: int required_world: List[int] category: str number: int = 0 # number of worlds need beaten @@ -62,38 +64,156 @@ vanilla_shuffle_order = [ FillMission("all_in", [26, 27], "Char", completion_critical=True, or_requirements=True) ] +mini_campaign_order = [ + FillMission("no_build", [-1], "Mar Sara", completion_critical=True), + FillMission("easy", [0], "Colonist"), + FillMission("medium", [1], "Colonist"), + FillMission("medium", [0], "Artifact", completion_critical=True), + FillMission("medium", [3], "Artifact", number=4, completion_critical=True), + FillMission("hard", [4], "Artifact", number=8, completion_critical=True), + FillMission("medium", [0], "Covert", number=2), + FillMission("hard", [6], "Covert"), + FillMission("medium", [0], "Rebellion", number=3), + FillMission("hard", [8], "Rebellion"), + FillMission("medium", [4], "Prophecy"), + FillMission("hard", [10], "Prophecy"), + FillMission("hard", [5], "Char", completion_critical=True), + FillMission("hard", [5], "Char", completion_critical=True), + FillMission("all_in", [12, 13], "Char", completion_critical=True, or_requirements=True) +] + +gauntlet_order = [ + FillMission("no_build", [-1], "I", completion_critical=True), + FillMission("easy", [0], "II", completion_critical=True), + FillMission("medium", [1], "III", completion_critical=True), + FillMission("medium", [2], "IV", completion_critical=True), + FillMission("hard", [3], "V", completion_critical=True), + FillMission("hard", [4], "VI", completion_critical=True), + FillMission("all_in", [5], "Final", completion_critical=True) +] + +grid_order = [ + FillMission("no_build", [-1], "_1"), + FillMission("medium", [0], "_1"), + FillMission("medium", [1, 6, 3], "_1", or_requirements=True), + FillMission("hard", [2, 7], "_1", or_requirements=True), + FillMission("easy", [0], "_2"), + FillMission("medium", [1, 4], "_2", or_requirements=True), + FillMission("hard", [2, 5, 10, 7], "_2", or_requirements=True), + FillMission("hard", [3, 6, 11], "_2", or_requirements=True), + FillMission("medium", [4, 9, 12], "_3", or_requirements=True), + FillMission("hard", [5, 8, 10, 13], "_3", or_requirements=True), + FillMission("hard", [6, 9, 11, 14], "_3", or_requirements=True), + FillMission("hard", [7, 10], "_3", or_requirements=True), + FillMission("hard", [8, 13], "_4", or_requirements=True), + FillMission("hard", [9, 12, 14], "_4", or_requirements=True), + FillMission("hard", [10, 13], "_4", or_requirements=True), + FillMission("all_in", [11, 14], "_4", or_requirements=True) +] + +mini_grid_order = [ + FillMission("no_build", [-1], "_1"), + FillMission("medium", [0], "_1"), + FillMission("medium", [1, 5], "_1", or_requirements=True), + FillMission("easy", [0], "_2"), + FillMission("medium", [1, 3], "_2", or_requirements=True), + FillMission("hard", [2, 4], "_2", or_requirements=True), + FillMission("medium", [3, 7], "_3", or_requirements=True), + FillMission("hard", [4, 6], "_3", or_requirements=True), + FillMission("all_in", [5, 7], "_3", or_requirements=True) +] + +blitz_order = [ + FillMission("no_build", [-1], "I"), + FillMission("easy", [-1], "I"), + FillMission("medium", [0, 1], "II", number=1, or_requirements=True), + FillMission("medium", [0, 1], "II", number=1, or_requirements=True), + FillMission("medium", [0, 1], "III", number=2, or_requirements=True), + FillMission("medium", [0, 1], "III", number=2, or_requirements=True), + FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), + FillMission("hard", [0, 1], "IV", number=3, or_requirements=True), + FillMission("hard", [0, 1], "V", number=4, or_requirements=True), + FillMission("hard", [0, 1], "V", number=4, or_requirements=True), + FillMission("hard", [0, 1], "Final", number=5, or_requirements=True), + FillMission("all_in", [0, 1], "Final", number=5, or_requirements=True) +] + +mission_orders = [vanilla_shuffle_order, vanilla_shuffle_order, mini_campaign_order, grid_order, mini_grid_order, blitz_order, gauntlet_order] + vanilla_mission_req_table = { - "Liberation Day": MissionInfo(1, 7, [], "Mar Sara", completion_critical=True), - "The Outlaws": MissionInfo(2, 2, [1], "Mar Sara", completion_critical=True), - "Zero Hour": MissionInfo(3, 4, [2], "Mar Sara", completion_critical=True), - "Evacuation": MissionInfo(4, 4, [3], "Colonist"), - "Outbreak": MissionInfo(5, 3, [4], "Colonist"), - "Safe Haven": MissionInfo(6, 4, [5], "Colonist", number=7), - "Haven's Fall": MissionInfo(7, 4, [5], "Colonist", number=7), - "Smash and Grab": MissionInfo(8, 5, [3], "Artifact", completion_critical=True), - "The Dig": MissionInfo(9, 4, [8], "Artifact", number=8, completion_critical=True), - "The Moebius Factor": MissionInfo(10, 9, [9], "Artifact", number=11, completion_critical=True), - "Supernova": MissionInfo(11, 5, [10], "Artifact", number=14, completion_critical=True), - "Maw of the Void": MissionInfo(12, 6, [11], "Artifact", completion_critical=True), - "Devil's Playground": MissionInfo(13, 3, [3], "Covert", number=4), - "Welcome to the Jungle": MissionInfo(14, 4, [13], "Covert"), - "Breakout": MissionInfo(15, 3, [14], "Covert", number=8), - "Ghost of a Chance": MissionInfo(16, 6, [14], "Covert", number=8), - "The Great Train Robbery": MissionInfo(17, 4, [3], "Rebellion", number=6), - "Cutthroat": MissionInfo(18, 5, [17], "Rebellion"), - "Engine of Destruction": MissionInfo(19, 6, [18], "Rebellion"), - "Media Blitz": MissionInfo(20, 5, [19], "Rebellion"), - "Piercing the Shroud": MissionInfo(21, 6, [20], "Rebellion"), - "Whispers of Doom": MissionInfo(22, 4, [9], "Prophecy"), - "A Sinister Turn": MissionInfo(23, 4, [22], "Prophecy"), - "Echoes of the Future": MissionInfo(24, 3, [23], "Prophecy"), - "In Utter Darkness": MissionInfo(25, 3, [24], "Prophecy"), - "Gates of Hell": MissionInfo(26, 2, [12], "Char", completion_critical=True), - "Belly of the Beast": MissionInfo(27, 4, [26], "Char", completion_critical=True), - "Shatter the Sky": MissionInfo(28, 5, [26], "Char", completion_critical=True), - "All-In": MissionInfo(29, -1, [27, 28], "Char", completion_critical=True, or_requirements=True) + "Liberation Day": MissionInfo(1, [], "Mar Sara", completion_critical=True), + "The Outlaws": MissionInfo(2, [1], "Mar Sara", completion_critical=True), + "Zero Hour": MissionInfo(3, [2], "Mar Sara", completion_critical=True), + "Evacuation": MissionInfo(4, [3], "Colonist"), + "Outbreak": MissionInfo(5, [4], "Colonist"), + "Safe Haven": MissionInfo(6, [5], "Colonist", number=7), + "Haven's Fall": MissionInfo(7, [5], "Colonist", number=7), + "Smash and Grab": MissionInfo(8, [3], "Artifact", completion_critical=True), + "The Dig": MissionInfo(9, [8], "Artifact", number=8, completion_critical=True), + "The Moebius Factor": MissionInfo(10, [9], "Artifact", number=11, completion_critical=True), + "Supernova": MissionInfo(11, [10], "Artifact", number=14, completion_critical=True), + "Maw of the Void": MissionInfo(12, [11], "Artifact", completion_critical=True), + "Devil's Playground": MissionInfo(13, [3], "Covert", number=4), + "Welcome to the Jungle": MissionInfo(14, [13], "Covert"), + "Breakout": MissionInfo(15, [14], "Covert", number=8), + "Ghost of a Chance": MissionInfo(16, [14], "Covert", number=8), + "The Great Train Robbery": MissionInfo(17, [3], "Rebellion", number=6), + "Cutthroat": MissionInfo(18, [17], "Rebellion"), + "Engine of Destruction": MissionInfo(19, [18], "Rebellion"), + "Media Blitz": MissionInfo(20, [19], "Rebellion"), + "Piercing the Shroud": MissionInfo(21, [20], "Rebellion"), + "Whispers of Doom": MissionInfo(22, [9], "Prophecy"), + "A Sinister Turn": MissionInfo(23, [22], "Prophecy"), + "Echoes of the Future": MissionInfo(24, [23], "Prophecy"), + "In Utter Darkness": MissionInfo(25, [24], "Prophecy"), + "Gates of Hell": MissionInfo(26, [12], "Char", completion_critical=True), + "Belly of the Beast": MissionInfo(27, [26], "Char", completion_critical=True), + "Shatter the Sky": MissionInfo(28, [26], "Char", completion_critical=True), + "All-In": MissionInfo(29, [27, 28], "Char", completion_critical=True, or_requirements=True) } lookup_id_to_mission: Dict[int, str] = { data.id: mission_name for mission_name, data in vanilla_mission_req_table.items() if data.id} + +no_build_starting_mission_locations = { + "Liberation Day": "Liberation Day: Victory", + "Breakout": "Breakout: Victory", + "Ghost of a Chance": "Ghost of a Chance: Victory", + "Piercing the Shroud": "Piercing the Shroud: Victory", + "Whispers of Doom": "Whispers of Doom: Victory", + "Belly of the Beast": "Belly of the Beast: Victory", +} + +build_starting_mission_locations = { + "Zero Hour": "Zero Hour: First Group Rescued", + "Evacuation": "Evacuation: First Chysalis", + "Devil's Playground": "Devil's Playground: Tosh's Miners" +} + +advanced_starting_mission_locations = { + "Smash and Grab": "Smash and Grab: First Relic", + "The Great Train Robbery": "The Great Train Robbery: North Defiler" +} + + +def get_starting_mission_locations(world: MultiWorld, player: int) -> Set[str]: + if get_option_value(world, player, 'shuffle_no_build') or get_option_value(world, player, 'mission_order') < 2: + # Always start with a no-build mission unless explicitly relegating them + # Vanilla and Vanilla Shuffled always start with a no-build even when relegated + return no_build_starting_mission_locations + elif get_option_value(world, player, 'required_tactics') > 0: + # Advanced Tactics/No Logic add more starting missions to the pool + return {**build_starting_mission_locations, **advanced_starting_mission_locations} + else: + # Standard starting missions when relegate is on + return build_starting_mission_locations + + +alt_final_mission_locations = { + "Maw of the Void": "Maw of the Void: Victory", + "Engine of Destruction": "Engine of Destruction: Victory", + "Supernova": "Supernova: Victory", + "Gates of Hell": "Gates of Hell: Victory", + "Shatter the Sky": "Shatter the Sky: Victory" +} \ No newline at end of file diff --git a/worlds/sc2wol/Options.py b/worlds/sc2wol/Options.py index efd0872527..9cd86f2c0b 100644 --- a/worlds/sc2wol/Options.py +++ b/worlds/sc2wol/Options.py @@ -1,6 +1,6 @@ from typing import Dict from BaseClasses import MultiWorld -from Options import Choice, Option, DefaultOnToggle +from Options import Choice, Option, Toggle, DefaultOnToggle, ItemSet, OptionSet, Range class GameDifficulty(Choice): @@ -36,25 +36,75 @@ class AllInMap(Choice): class MissionOrder(Choice): - """Determines the order the missions are played in. - Vanilla: Keeps the standard mission order and branching from the WoL Campaign. - Vanilla Shuffled: Keeps same branching paths from the WoL Campaign but randomizes the order of missions within.""" + """Determines the order the missions are played in. The last three mission orders end in a random mission. + Vanilla (29): Keeps the standard mission order and branching from the WoL Campaign. + Vanilla Shuffled (29): Keeps same branching paths from the WoL Campaign but randomizes the order of missions within. + Mini Campaign (15): Shorter version of the campaign with randomized missions and optional branches. + Grid (16): A 4x4 grid of random missions. Start at the top-left and forge a path towards All-In. + Mini Grid (9): A 3x3 version of Grid. Complete the bottom-right mission to win. + Blitz (12): 12 random missions that open up very quickly. Complete the bottom-right mission to win. + Gauntlet (7): Linear series of 7 random missions to complete the campaign.""" display_name = "Mission Order" option_vanilla = 0 option_vanilla_shuffled = 1 + option_mini_campaign = 2 + option_grid = 3 + option_mini_grid = 4 + option_blitz = 5 + option_gauntlet = 6 class ShuffleProtoss(DefaultOnToggle): - """Determines if the 3 protoss missions are included in the shuffle if Vanilla Shuffled is enabled. If this is - not the 3 protoss missions will stay in their vanilla order in the mission order making them optional to complete - the game.""" + """Determines if the 3 protoss missions are included in the shuffle if Vanilla mission order is not enabled. + If turned off with Vanilla Shuffled, the 3 protoss missions will be in their normal position on the Prophecy chain if not shuffled. + If turned off with reduced mission settings, the 3 protoss missions will not appear and Protoss units are removed from the pool.""" display_name = "Shuffle Protoss Missions" -class RelegateNoBuildMissions(DefaultOnToggle): - """If enabled, all no build missions besides the needed first one will be placed at the end of optional routes so - that none of them become required to complete the game. Only takes effect if mission order is not set to vanilla.""" - display_name = "Relegate No-Build Missions" +class ShuffleNoBuild(DefaultOnToggle): + """Determines if the 5 no-build missions are included in the shuffle if Vanilla mission order is not enabled. + If turned off with Vanilla Shuffled, one no-build mission will be placed as the first mission and the rest will be placed at the end of optional routes. + If turned off with reduced mission settings, the 5 no-build missions will not appear.""" + display_name = "Shuffle No-Build Missions" + + +class EarlyUnit(DefaultOnToggle): + """Guarantees that the first mission will contain a unit.""" + display_name = "Early Unit" + + +class RequiredTactics(Choice): + """Determines the maximum tactical difficulty of the seed (separate from mission difficulty). Higher settings increase randomness. + Standard: All missions can be completed with good micro and macro. + Advanced: Completing missions may require relying on starting units and micro-heavy units. + No Logic: Units and upgrades may be placed anywhere. LIKELY TO RENDER THE RUN IMPOSSIBLE ON HARDER DIFFICULTIES!""" + display_name = "Required Tactics" + option_standard = 0 + option_advanced = 1 + option_no_logic = 2 + + +class UnitsAlwaysHaveUpgrades(DefaultOnToggle): + """If turned on, both upgrades will be present for each unit and structure in the seed. + This usually results in fewer units.""" + display_name = "Units Always Have Upgrades" + + +class LockedItems(ItemSet): + """Guarantees that these items will be unlockable""" + display_name = "Locked Items" + + +class ExcludedItems(ItemSet): + """Guarantees that these items will not be unlockable""" + display_name = "Excluded Items" + + +class ExcludedMissions(OptionSet): + """Guarantees that these missions will not appear in the campaign + Only applies on shortened mission orders. + It may be impossible to build a valid campaign if too many missions are excluded.""" + display_name = "Excluded Missions" # noinspection PyTypeChecker @@ -65,14 +115,29 @@ sc2wol_options: Dict[str, Option] = { "all_in_map": AllInMap, "mission_order": MissionOrder, "shuffle_protoss": ShuffleProtoss, - "relegate_no_build": RelegateNoBuildMissions + "shuffle_no_build": ShuffleNoBuild, + "early_unit": EarlyUnit, + "required_tactics": RequiredTactics, + "units_always_have_upgrades": UnitsAlwaysHaveUpgrades, + "locked_items": LockedItems, + "excluded_items": ExcludedItems, + "excluded_missions": ExcludedMissions } def get_option_value(world: MultiWorld, player: int, name: str) -> int: option = getattr(world, name, None) - if option == None: + if option is None: return 0 return int(option[player].value) + + +def get_option_set_value(world: MultiWorld, player: int, name: str) -> set: + option = getattr(world, name, None) + + if option is None: + return set() + + return option[player].value diff --git a/worlds/sc2wol/PoolFilter.py b/worlds/sc2wol/PoolFilter.py new file mode 100644 index 0000000000..5b6970e721 --- /dev/null +++ b/worlds/sc2wol/PoolFilter.py @@ -0,0 +1,257 @@ +from typing import Callable, Dict, List, Set +from BaseClasses import MultiWorld, ItemClassification, Item, Location +from .Items import item_table +from .MissionTables import no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list,\ + mission_orders, get_starting_mission_locations, MissionInfo, vanilla_mission_req_table, alt_final_mission_locations +from .Options import get_option_value, get_option_set_value +from .LogicMixin import SC2WoLLogic + +# Items with associated upgrades +UPGRADABLE_ITEMS = [ + "Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre", + "Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", + "Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", + "Bunker", "Missile Turret" +] + +BARRACKS_UNITS = {"Marine", "Medic", "Firebat", "Marauder", "Reaper", "Ghost", "Spectre"} +FACTORY_UNITS = {"Hellion", "Vulture", "Goliath", "Diamondback", "Siege Tank", "Thor", "Predator"} +STARPORT_UNITS = {"Medivac", "Wraith", "Viking", "Banshee", "Battlecruiser", "Hercules", "Science Vessel", "Raven"} + +PROTOSS_REGIONS = {"A Sinister Turn", "Echoes of the Future", "In Utter Darkness"} + + +def filter_missions(world: MultiWorld, player: int) -> Dict[str, List[str]]: + """ + Returns a semi-randomly pruned tuple of no-build, easy, medium, and hard mission sets + """ + + mission_order_type = get_option_value(world, player, "mission_order") + shuffle_protoss = get_option_value(world, player, "shuffle_protoss") + excluded_missions = set(get_option_set_value(world, player, "excluded_missions")) + invalid_mission_names = excluded_missions.difference(vanilla_mission_req_table.keys()) + if invalid_mission_names: + raise Exception("Error in locked_missions - the following are not valid mission names: " + ", ".join(invalid_mission_names)) + mission_count = len(mission_orders[mission_order_type]) - 1 + # Vanilla and Vanilla Shuffled use the entire mission pool + if mission_count == 28: + return { + "no_build": no_build_regions_list[:], + "easy": easy_regions_list[:], + "medium": medium_regions_list[:], + "hard": hard_regions_list[:], + "all_in": ["All-In"] + } + + mission_pools = [ + [], + easy_regions_list, + medium_regions_list, + hard_regions_list + ] + # Omitting Protoss missions if not shuffling protoss + if not shuffle_protoss: + excluded_missions = excluded_missions.union(PROTOSS_REGIONS) + # Replacing All-In on low mission counts + if mission_count < 14: + final_mission = world.random.choice([mission for mission in alt_final_mission_locations.keys() if mission not in excluded_missions]) + excluded_missions.add(final_mission) + else: + final_mission = 'All-In' + # Yaml settings determine which missions can be placed in the first slot + mission_pools[0] = [mission for mission in get_starting_mission_locations(world, player).keys() if mission not in excluded_missions] + # Removing the new no-build missions from their original sets + for i in range(1, len(mission_pools)): + mission_pools[i] = [mission for mission in mission_pools[i] if mission not in excluded_missions.union(mission_pools[0])] + # If the first mission is a build mission, there may not be enough locations to reach Outbreak as a second mission + if not get_option_value(world, player, 'shuffle_no_build'): + # Swapping Outbreak and The Great Train Robbery + if "Outbreak" in mission_pools[1]: + mission_pools[1].remove("Outbreak") + mission_pools[2].append("Outbreak") + if "The Great Train Robbery" in mission_pools[2]: + mission_pools[2].remove("The Great Train Robbery") + mission_pools[1].append("The Great Train Robbery") + # Removing random missions from each difficulty set in a cycle + set_cycle = 0 + current_count = sum(len(mission_pool) for mission_pool in mission_pools) + + if current_count < mission_count: + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") + while current_count > mission_count: + if set_cycle == 4: + set_cycle = 0 + # Must contain at least one mission per set + mission_pool = mission_pools[set_cycle] + if len(mission_pool) <= 1: + if all(len(mission_pool) <= 1 for mission_pool in mission_pools): + raise Exception("Not enough missions available to fill the campaign on current settings. Please exclude fewer missions.") + else: + mission_pool.remove(world.random.choice(mission_pool)) + current_count -= 1 + set_cycle += 1 + + return { + "no_build": mission_pools[0], + "easy": mission_pools[1], + "medium": mission_pools[2], + "hard": mission_pools[3], + "all_in": [final_mission] + } + + +def get_item_upgrades(inventory: List[Item], parent_item: Item or str): + item_name = parent_item.name if isinstance(parent_item, Item) else parent_item + return [ + inv_item for inv_item in inventory + if item_table[inv_item.name].parent_item == item_name + ] + + +class ValidInventory: + + def has(self, item: str, player: int): + return item in self.logical_inventory + + def has_any(self, items: Set[str], player: int): + return any(item in self.logical_inventory for item in items) + + def has_all(self, items: Set[str], player: int): + return all(item in self.logical_inventory for item in items) + + def has_units_per_structure(self) -> bool: + return len(BARRACKS_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure and \ + len(FACTORY_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure and \ + len(STARPORT_UNITS.intersection(self.logical_inventory)) > self.min_units_per_structure + + def generate_reduced_inventory(self, inventory_size: int, mission_requirements: List[Callable]) -> List[Item]: + """Attempts to generate a reduced inventory that can fulfill the mission requirements.""" + inventory = list(self.item_pool) + locked_items = list(self.locked_items) + self.logical_inventory = { + item.name for item in inventory + locked_items + self.existing_items + if item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing) + } + requirements = mission_requirements + cascade_keys = self.cascade_removal_map.keys() + units_always_have_upgrades = get_option_value(self.world, self.player, "units_always_have_upgrades") + if self.min_units_per_structure > 0: + requirements.append(lambda state: state.has_units_per_structure()) + + def attempt_removal(item: Item) -> bool: + # If item can be removed and has associated items, remove them as well + inventory.remove(item) + # Only run logic checks when removing logic items + if item.name in self.logical_inventory: + self.logical_inventory.remove(item.name) + if not all(requirement(self) for requirement in requirements): + # If item cannot be removed, lock or revert + self.logical_inventory.add(item.name) + locked_items.append(item) + return False + return True + + while len(inventory) + len(locked_items) > inventory_size: + if len(inventory) == 0: + raise Exception("Reduced item pool generation failed - not enough locations available to place items.") + # Select random item from removable items + item = self.world.random.choice(inventory) + # Cascade removals to associated items + if item in cascade_keys: + items_to_remove = self.cascade_removal_map[item] + transient_items = [] + while len(items_to_remove) > 0: + item_to_remove = items_to_remove.pop() + if item_to_remove not in inventory: + continue + success = attempt_removal(item_to_remove) + if success: + transient_items.append(item_to_remove) + elif units_always_have_upgrades: + # Lock all associated items if any of them cannot be removed + transient_items += items_to_remove + for transient_item in transient_items: + if transient_item not in inventory and transient_item not in locked_items: + locked_items += transient_item + if transient_item.classification in (ItemClassification.progression, ItemClassification.progression_skip_balancing): + self.logical_inventory.add(transient_item.name) + break + else: + attempt_removal(item) + + return inventory + locked_items + + def _read_logic(self): + self._sc2wol_has_common_unit = lambda world, player: SC2WoLLogic._sc2wol_has_common_unit(self, world, player) + self._sc2wol_has_air = lambda world, player: SC2WoLLogic._sc2wol_has_air(self, world, player) + self._sc2wol_has_air_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_air_anti_air(self, world, player) + self._sc2wol_has_competent_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_competent_anti_air(self, world, player) + self._sc2wol_has_anti_air = lambda world, player: SC2WoLLogic._sc2wol_has_anti_air(self, world, player) + self._sc2wol_defense_rating = lambda world, player, zerg_enemy, air_enemy=False: SC2WoLLogic._sc2wol_defense_rating(self, world, player, zerg_enemy, air_enemy) + self._sc2wol_has_competent_comp = lambda world, player: SC2WoLLogic._sc2wol_has_competent_comp(self, world, player) + self._sc2wol_has_train_killers = lambda world, player: SC2WoLLogic._sc2wol_has_train_killers(self, world, player) + self._sc2wol_able_to_rescue = lambda world, player: SC2WoLLogic._sc2wol_able_to_rescue(self, world, player) + self._sc2wol_beats_protoss_deathball = lambda world, player: SC2WoLLogic._sc2wol_beats_protoss_deathball(self, world, player) + self._sc2wol_survives_rip_field = lambda world, player: SC2WoLLogic._sc2wol_survives_rip_field(self, world, player) + self._sc2wol_has_protoss_common_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_common_units(self, world, player) + self._sc2wol_has_protoss_medium_units = lambda world, player: SC2WoLLogic._sc2wol_has_protoss_medium_units(self, world, player) + self._sc2wol_has_mm_upgrade = lambda world, player: SC2WoLLogic._sc2wol_has_mm_upgrade(self, world, player) + self._sc2wol_final_mission_requirements = lambda world, player: SC2WoLLogic._sc2wol_final_mission_requirements(self, world, player) + + def __init__(self, world: MultiWorld, player: int, + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item], + has_protoss: bool): + self.world = world + self.player = player + self.logical_inventory = set() + self.locked_items = locked_items[:] + self.existing_items = existing_items + self._read_logic() + # Initial filter of item pool + self.item_pool = [] + item_quantities: dict[str, int] = dict() + # Inventory restrictiveness based on number of missions with checks + mission_order_type = get_option_value(self.world, self.player, "mission_order") + mission_count = len(mission_orders[mission_order_type]) - 1 + self.min_units_per_structure = int(mission_count / 7) + min_upgrades = 1 if mission_count < 10 else 2 + for item in item_pool: + item_info = item_table[item.name] + if item_info.type == "Upgrade": + # Locking upgrades based on mission duration + if item.name not in item_quantities: + item_quantities[item.name] = 0 + item_quantities[item.name] += 1 + if item_quantities[item.name] < min_upgrades: + self.locked_items.append(item) + else: + self.item_pool.append(item) + elif item_info.type == "Goal": + locked_items.append(item) + elif item_info.type != "Protoss" or has_protoss: + self.item_pool.append(item) + self.cascade_removal_map: Dict[Item, List[Item]] = dict() + for item in self.item_pool + locked_items + existing_items: + if item.name in UPGRADABLE_ITEMS: + upgrades = get_item_upgrades(self.item_pool, item) + associated_items = [*upgrades, item] + self.cascade_removal_map[item] = associated_items + if get_option_value(world, player, "units_always_have_upgrades"): + for upgrade in upgrades: + self.cascade_removal_map[upgrade] = associated_items + + +def filter_items(world: MultiWorld, player: int, mission_req_table: Dict[str, MissionInfo], location_cache: List[Location], + item_pool: List[Item], existing_items: List[Item], locked_items: List[Item]) -> List[Item]: + """ + Returns a semi-randomly pruned set of items based on number of available locations. + The returned inventory must be capable of logically accessing every location in the world. + """ + open_locations = [location for location in location_cache if location.item is None] + inventory_size = len(open_locations) + has_protoss = bool(PROTOSS_REGIONS.intersection(mission_req_table.keys())) + mission_requirements = [location.access_rule for location in location_cache] + valid_inventory = ValidInventory(world, player, item_pool, existing_items, locked_items, has_protoss) + + valid_items = valid_inventory.generate_reduced_inventory(inventory_size, mission_requirements) + return valid_items diff --git a/worlds/sc2wol/Regions.py b/worlds/sc2wol/Regions.py index 8219a982c9..b0a3a51e44 100644 --- a/worlds/sc2wol/Regions.py +++ b/worlds/sc2wol/Regions.py @@ -2,55 +2,47 @@ from typing import List, Set, Dict, Tuple, Optional, Callable from BaseClasses import MultiWorld, Region, Entrance, Location, RegionType from .Locations import LocationData from .Options import get_option_value -from .MissionTables import MissionInfo, vanilla_shuffle_order, vanilla_mission_req_table, \ - no_build_regions_list, easy_regions_list, medium_regions_list, hard_regions_list +from .MissionTables import MissionInfo, mission_orders, vanilla_mission_req_table, alt_final_mission_locations +from .PoolFilter import filter_missions import random -def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location]): +def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData, ...], location_cache: List[Location])\ + -> Tuple[Dict[str, MissionInfo], int, str]: locations_per_region = get_locations_per_region(locations) - regions = [ - create_region(world, player, locations_per_region, location_cache, "Menu"), - create_region(world, player, locations_per_region, location_cache, "Liberation Day"), - create_region(world, player, locations_per_region, location_cache, "The Outlaws"), - create_region(world, player, locations_per_region, location_cache, "Zero Hour"), - create_region(world, player, locations_per_region, location_cache, "Evacuation"), - create_region(world, player, locations_per_region, location_cache, "Outbreak"), - create_region(world, player, locations_per_region, location_cache, "Safe Haven"), - create_region(world, player, locations_per_region, location_cache, "Haven's Fall"), - create_region(world, player, locations_per_region, location_cache, "Smash and Grab"), - create_region(world, player, locations_per_region, location_cache, "The Dig"), - create_region(world, player, locations_per_region, location_cache, "The Moebius Factor"), - create_region(world, player, locations_per_region, location_cache, "Supernova"), - create_region(world, player, locations_per_region, location_cache, "Maw of the Void"), - create_region(world, player, locations_per_region, location_cache, "Devil's Playground"), - create_region(world, player, locations_per_region, location_cache, "Welcome to the Jungle"), - create_region(world, player, locations_per_region, location_cache, "Breakout"), - create_region(world, player, locations_per_region, location_cache, "Ghost of a Chance"), - create_region(world, player, locations_per_region, location_cache, "The Great Train Robbery"), - create_region(world, player, locations_per_region, location_cache, "Cutthroat"), - create_region(world, player, locations_per_region, location_cache, "Engine of Destruction"), - create_region(world, player, locations_per_region, location_cache, "Media Blitz"), - create_region(world, player, locations_per_region, location_cache, "Piercing the Shroud"), - create_region(world, player, locations_per_region, location_cache, "Whispers of Doom"), - create_region(world, player, locations_per_region, location_cache, "A Sinister Turn"), - create_region(world, player, locations_per_region, location_cache, "Echoes of the Future"), - create_region(world, player, locations_per_region, location_cache, "In Utter Darkness"), - create_region(world, player, locations_per_region, location_cache, "Gates of Hell"), - create_region(world, player, locations_per_region, location_cache, "Belly of the Beast"), - create_region(world, player, locations_per_region, location_cache, "Shatter the Sky"), - create_region(world, player, locations_per_region, location_cache, "All-In") - ] + mission_order_type = get_option_value(world, player, "mission_order") + mission_order = mission_orders[mission_order_type] + + mission_pools = filter_missions(world, player) + final_mission = mission_pools['all_in'][0] + + used_regions = [mission for mission_pool in mission_pools.values() for mission in mission_pool] + regions = [create_region(world, player, locations_per_region, location_cache, "Menu")] + for region_name in used_regions: + regions.append(create_region(world, player, locations_per_region, location_cache, region_name)) + # Changing the completion condition for alternate final missions into an event + if final_mission != 'All-In': + final_location = alt_final_mission_locations[final_mission] + # Final location should be near the end of the cache + for i in range(len(location_cache) - 1, -1, -1): + if location_cache[i].name == final_location: + location_cache[i].locked = True + location_cache[i].event = True + location_cache[i].address = None + break + else: + final_location = 'All-In: Victory' if __debug__: - throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) + if mission_order_type in (0, 1): + throwIfAnyLocationIsNotAssignedToARegion(regions, locations_per_region.keys()) world.regions += regions names: Dict[str, int] = {} - if get_option_value(world, player, "mission_order") == 0: + if mission_order_type == 0: connect(world, player, names, 'Menu', 'Liberation Day'), connect(world, player, names, 'Liberation Day', 'The Outlaws', lambda state: state.has("Beat Liberation Day", player)), @@ -119,32 +111,30 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData lambda state: state.has('Beat Gates of Hell', player) and ( state.has('Beat Shatter the Sky', player) or state.has('Beat Belly of the Beast', player))) - return vanilla_mission_req_table + return vanilla_mission_req_table, 29, final_location - elif get_option_value(world, player, "mission_order") == 1: + else: missions = [] - no_build_pool = no_build_regions_list[:] - easy_pool = easy_regions_list[:] - medium_pool = medium_regions_list[:] - hard_pool = hard_regions_list[:] # Initial fill out of mission list and marking all-in mission - for mission in vanilla_shuffle_order: - if mission.type == "all_in": - missions.append("All-In") - elif get_option_value(world, player, "relegate_no_build") and mission.relegate: + for mission in mission_order: + if mission is None: + missions.append(None) + elif mission.type == "all_in": + missions.append(final_mission) + elif mission.relegate and not get_option_value(world, player, "shuffle_no_build"): missions.append("no_build") else: missions.append(mission.type) - # Place Protoss Missions if we are not using ShuffleProtoss - if get_option_value(world, player, "shuffle_protoss") == 0: + # Place Protoss Missions if we are not using ShuffleProtoss and are in Vanilla Shuffled + if get_option_value(world, player, "shuffle_protoss") == 0 and mission_order_type == 1: missions[22] = "A Sinister Turn" - medium_pool.remove("A Sinister Turn") + mission_pools['medium'].remove("A Sinister Turn") missions[23] = "Echoes of the Future" - medium_pool.remove("Echoes of the Future") + mission_pools['medium'].remove("Echoes of the Future") missions[24] = "In Utter Darkness" - hard_pool.remove("In Utter Darkness") + mission_pools['hard'].remove("In Utter Darkness") no_build_slots = [] easy_slots = [] @@ -153,6 +143,8 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData # Search through missions to find slots needed to fill for i in range(len(missions)): + if missions[i] is None: + continue if missions[i] == "no_build": no_build_slots.append(i) elif missions[i] == "easy": @@ -163,30 +155,30 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData hard_slots.append(i) # Add no_build missions to the pool and fill in no_build slots - missions_to_add = no_build_pool + missions_to_add = mission_pools['no_build'] for slot in no_build_slots: - filler = random.randint(0, len(missions_to_add)-1) + filler = world.random.randint(0, len(missions_to_add)-1) missions[slot] = missions_to_add.pop(filler) # Add easy missions into pool and fill in easy slots - missions_to_add = missions_to_add + easy_pool + missions_to_add = missions_to_add + mission_pools['easy'] for slot in easy_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add medium missions into pool and fill in medium slots - missions_to_add = missions_to_add + medium_pool + missions_to_add = missions_to_add + mission_pools['medium'] for slot in medium_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) # Add hard missions into pool and fill in hard slots - missions_to_add = missions_to_add + hard_pool + missions_to_add = missions_to_add + mission_pools['hard'] for slot in hard_slots: - filler = random.randint(0, len(missions_to_add) - 1) + filler = world.random.randint(0, len(missions_to_add) - 1) missions[slot] = missions_to_add.pop(filler) @@ -195,7 +187,7 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData mission_req_table = {} for i in range(len(missions)): connections = [] - for connection in vanilla_shuffle_order[i].connect_to: + for connection in mission_order[i].connect_to: if connection == -1: connect(world, player, names, "Menu", missions[i]) else: @@ -203,16 +195,17 @@ def create_regions(world: MultiWorld, player: int, locations: Tuple[LocationData (lambda name, missions_req: (lambda state: state.has(f"Beat {name}", player) and state._sc2wol_cleared_missions(world, player, missions_req))) - (missions[connection], vanilla_shuffle_order[i].number)) + (missions[connection], mission_order[i].number)) connections.append(connection + 1) mission_req_table.update({missions[i]: MissionInfo( - vanilla_mission_req_table[missions[i]].id, vanilla_mission_req_table[missions[i]].extra_locations, - connections, vanilla_shuffle_order[i].category, number=vanilla_shuffle_order[i].number, - completion_critical=vanilla_shuffle_order[i].completion_critical, - or_requirements=vanilla_shuffle_order[i].or_requirements)}) + vanilla_mission_req_table[missions[i]].id, connections, mission_order[i].category, + number=mission_order[i].number, + completion_critical=mission_order[i].completion_critical, + or_requirements=mission_order[i].or_requirements)}) - return mission_req_table + final_mission_id = vanilla_mission_req_table[final_mission].id + return mission_req_table, final_mission_id, final_mission + ': Victory' def throwIfAnyLocationIsNotAssignedToARegion(regions: List[Region], regionNames: Set[str]): diff --git a/worlds/sc2wol/__init__.py b/worlds/sc2wol/__init__.py index 6d056df808..70226e7afd 100644 --- a/worlds/sc2wol/__init__.py +++ b/worlds/sc2wol/__init__.py @@ -1,14 +1,16 @@ import typing -from typing import List, Set, Tuple +from typing import List, Set, Tuple, Dict from BaseClasses import Item, MultiWorld, Location, Tutorial, ItemClassification from worlds.AutoWorld import WebWorld, World from .Items import StarcraftWoLItem, item_table, filler_items, item_name_groups, get_full_item_list, \ - basic_unit + get_basic_units from .Locations import get_locations from .Regions import create_regions -from .Options import sc2wol_options, get_option_value +from .Options import sc2wol_options, get_option_value, get_option_set_value from .LogicMixin import SC2WoLLogic +from .PoolFilter import filter_missions, filter_items, get_item_upgrades +from .MissionTables import get_starting_mission_locations, MissionInfo class Starcraft2WoLWebWorld(WebWorld): @@ -42,6 +44,8 @@ class SC2WoLWorld(World): locked_locations: typing.List[str] location_cache: typing.List[Location] mission_req_table = {} + final_mission_id: int + victory_item: str required_client_version = 0, 3, 5 def __init__(self, world: MultiWorld, player: int): @@ -49,24 +53,21 @@ class SC2WoLWorld(World): self.location_cache = [] self.locked_locations = [] - def _create_items(self, name: str): - data = get_full_item_list()[name] - return [self.create_item(name) for _ in range(data.quantity)] - def create_item(self, name: str) -> Item: data = get_full_item_list()[name] return StarcraftWoLItem(name, data.classification, data.code, self.player) def create_regions(self): - self.mission_req_table = create_regions(self.world, self.player, get_locations(self.world, self.player), - self.location_cache) + self.mission_req_table, self.final_mission_id, self.victory_item = create_regions( + self.world, self.player, get_locations(self.world, self.player), self.location_cache + ) def generate_basic(self): excluded_items = get_excluded_items(self, self.world, self.player) - assign_starter_items(self.world, self.player, excluded_items, self.locked_locations) + starter_items = assign_starter_items(self.world, self.player, excluded_items, self.locked_locations) - pool = get_item_pool(self.world, self.player, excluded_items) + pool = get_item_pool(self.world, self.player, self.mission_req_table, starter_items, excluded_items, self.location_cache) fill_item_pool_with_dummy_items(self, self.world, self.player, self.locked_locations, self.location_cache, pool) @@ -74,8 +75,7 @@ class SC2WoLWorld(World): def set_rules(self): setup_events(self.world, self.player, self.locked_locations, self.location_cache) - - self.world.completion_condition[self.player] = lambda state: state.has('All-In: Victory', self.player) + self.world.completion_condition[self.player] = lambda state: state.has(self.victory_item, self.player) def get_filler_item_name(self) -> str: return self.world.random.choice(filler_items) @@ -91,6 +91,7 @@ class SC2WoLWorld(World): slot_req_table[mission] = self.mission_req_table[mission]._asdict() slot_data["mission_req"] = slot_req_table + slot_data["final_mission"] = self.final_mission_id return slot_data @@ -120,30 +121,37 @@ def get_excluded_items(self: SC2WoLWorld, world: MultiWorld, player: int) -> Set for item in world.precollected_items[player]: excluded_items.add(item.name) + excluded_items_option = getattr(world, 'excluded_items', []) + + excluded_items.update(excluded_items_option[player].value) + return excluded_items -def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]): +def assign_starter_items(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str]) -> List[Item]: non_local_items = world.non_local_items[player].value + if get_option_value(world, player, "early_unit"): + local_basic_unit = tuple(item for item in get_basic_units(world, player) if item not in non_local_items) + if not local_basic_unit: + raise Exception("At least one basic unit must be local") - local_basic_unit = tuple(item for item in basic_unit if item not in non_local_items) - if not local_basic_unit: - raise Exception("At least one basic unit must be local") + # The first world should also be the starting world + first_mission = list(world.worlds[player].mission_req_table)[0] + starting_mission_locations = get_starting_mission_locations(world, player) + if first_mission in starting_mission_locations: + first_location = starting_mission_locations[first_mission] + elif first_mission == "In Utter Darkness": + first_location = first_mission + ": Defeat" + else: + first_location = first_mission + ": Victory" - # The first world should also be the starting world - first_location = list(world.worlds[player].mission_req_table)[0] - - if first_location == "In Utter Darkness": - first_location = first_location + ": Defeat" + return [assign_starter_item(world, player, excluded_items, locked_locations, first_location, local_basic_unit)] else: - first_location = first_location + ": Victory" - - assign_starter_item(world, player, excluded_items, locked_locations, first_location, - local_basic_unit) + return [] def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str], locked_locations: List[str], - location: str, item_list: Tuple[str, ...]): + location: str, item_list: Tuple[str, ...]) -> Item: item_name = world.random.choice(item_list) @@ -155,17 +163,40 @@ def assign_starter_item(world: MultiWorld, player: int, excluded_items: Set[str] locked_locations.append(location) + return item -def get_item_pool(world: MultiWorld, player: int, excluded_items: Set[str]) -> List[Item]: + +def get_item_pool(world: MultiWorld, player: int, mission_req_table: Dict[str, MissionInfo], + starter_items: List[str], excluded_items: Set[str], location_cache: List[Location]) -> List[Item]: pool: List[Item] = [] + # For the future: goal items like Artifact Shards go here + locked_items = [] + + # YAML items + yaml_locked_items = get_option_set_value(world, player, 'locked_items') + for name, data in item_table.items(): if name not in excluded_items: for _ in range(data.quantity): item = create_item_with_correct_settings(world, player, name) - pool.append(item) + if name in yaml_locked_items: + locked_items.append(item) + else: + pool.append(item) - return pool + existing_items = starter_items + [item for item in world.precollected_items[player]] + existing_names = [item.name for item in existing_items] + # Removing upgrades for excluded items + for item_name in excluded_items: + if item_name in existing_names: + continue + invalid_upgrades = get_item_upgrades(pool, item_name) + for invalid_upgrade in invalid_upgrades: + pool.remove(invalid_upgrade) + + filtered_pool = filter_items(world, player, mission_req_table, location_cache, pool, existing_items, locked_items) + return filtered_pool def fill_item_pool_with_dummy_items(self: SC2WoLWorld, world: MultiWorld, player: int, locked_locations: List[str], diff --git a/worlds/sm/Client.py b/worlds/sm/Client.py new file mode 100644 index 0000000000..190ce29ecc --- /dev/null +++ b/worlds/sm/Client.py @@ -0,0 +1,158 @@ +import logging +import asyncio +import time + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Rom import ROM_PLAYER_LIMIT as SM_ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +GAME_SM = "Super Metroid" + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +# SM +SM_ROMNAME_START = ROM_START + 0x007FC0 +ROMNAME_SIZE = 0x15 + +SM_INGAME_MODES = {0x07, 0x09, 0x0b} +SM_ENDGAME_MODES = {0x26, 0x27} +SM_DEATH_MODES = {0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A} + +# RECV and SEND are from the gameplay's perspective: SNIClient writes to RECV queue and reads from SEND queue +SM_RECV_QUEUE_START = SRAM_START + 0x2000 +SM_RECV_QUEUE_WCOUNT = SRAM_START + 0x2602 +SM_SEND_QUEUE_START = SRAM_START + 0x2700 +SM_SEND_QUEUE_RCOUNT = SRAM_START + 0x2680 +SM_SEND_QUEUE_WCOUNT = SRAM_START + 0x2682 + +SM_DEATH_LINK_ACTIVE_ADDR = ROM_START + 0x277F04 # 1 byte +SM_REMOTE_ITEM_FLAG_ADDR = ROM_START + 0x277F06 # 1 byte + + +class SMSNIClient(SNIClient): + game = "Super Metroid" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read + snes_buffered_write(ctx, WRAM_START + 0x09C2, bytes([1, 0])) # set current health to 1 (to prevent saving with 0 energy) + snes_buffered_write(ctx, WRAM_START + 0x0A50, bytes([255])) # deal 255 of damage at next opportunity + if not ctx.death_link_allow_survive: + snes_buffered_write(ctx, WRAM_START + 0x09D6, bytes([0, 0])) # set current reserve to 0 + + await snes_flush_writes(ctx) + await asyncio.sleep(1) + + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + health = await snes_read(ctx, WRAM_START + 0x09C2, 2) + if health is not None: + health = health[0] | (health[1] << 8) + if not gamemode or gamemode[0] in SM_DEATH_MODES or ( + ctx.death_link_allow_survive and health is not None and health > 0): + ctx.death_state = DeathState.dead + + + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom_name = await snes_read(ctx, SM_ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:2] != b"SM" or rom_name[:3] == b"SMW": + return False + + ctx.game = self.game + + # versions lower than 0.3.0 dont have item handling flag nor remote item support + romVersion = int(rom_name[2:5].decode('UTF-8')) + if romVersion < 30: + ctx.items_handling = 0b001 # full local + else: + item_handling = await snes_read(ctx, SM_REMOTE_ITEM_FLAG_ADDR, 1) + ctx.items_handling = 0b001 if item_handling is None else item_handling[0] + + ctx.rom = rom_name + + death_link = await snes_read(ctx, SM_DEATH_LINK_ACTIVE_ADDR, 1) + + if death_link: + ctx.allow_collect = bool(death_link[0] & 0b100) + ctx.death_link_allow_survive = bool(death_link[0] & 0b10) + await ctx.update_death_link(bool(death_link[0] & 0b1)) + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + if "DeathLink" in ctx.tags and gamemode and ctx.last_death_link + 1 < time.time(): + currently_dead = gamemode[0] in SM_DEATH_MODES + await ctx.handle_deathlink_state(currently_dead) + if gamemode is not None and gamemode[0] in SM_ENDGAME_MODES: + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + return + + data = await snes_read(ctx, SM_SEND_QUEUE_RCOUNT, 4) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] | (data[3] << 8) # this is actually SM_SEND_QUEUE_WCOUNT + + while (recv_index < recv_item): + item_address = recv_index * 8 + message = await snes_read(ctx, SM_SEND_QUEUE_START + item_address, 8) + item_index = (message[4] | (message[5] << 8)) >> 3 + + recv_index += 1 + snes_buffered_write(ctx, SM_SEND_QUEUE_RCOUNT, + bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + + from worlds.sm import locations_start_id + location_id = locations_start_id + item_index + + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info( + f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + data = await snes_read(ctx, SM_RECV_QUEUE_WCOUNT, 2) + if data is None: + return + + item_out_ptr = data[0] | (data[1] << 8) + + from worlds.sm import items_start_id + from worlds.sm import locations_start_id + if item_out_ptr < len(ctx.items_received): + item = ctx.items_received[item_out_ptr] + item_id = item.item - items_start_id + if bool(ctx.items_handling & 0b010): + location_id = (item.location - locations_start_id) if (item.location >= 0 and item.player == ctx.slot) else 0xFF + else: + location_id = 0x00 #backward compat + + player_id = item.player if item.player <= SM_ROM_PLAYER_LIMIT else 0 + snes_buffered_write(ctx, SM_RECV_QUEUE_START + item_out_ptr * 4, bytes( + [player_id & 0xFF, (player_id >> 8) & 0xFF, item_id & 0xFF, location_id & 0xFF])) + item_out_ptr += 1 + snes_buffered_write(ctx, SM_RECV_QUEUE_WCOUNT, + bytes([item_out_ptr & 0xFF, (item_out_ptr >> 8) & 0xFF])) + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), + color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], item_out_ptr, len(ctx.items_received))) + + await snes_flush_writes(ctx) + diff --git a/worlds/sm/__init__.py b/worlds/sm/__init__.py index d901303215..fc19b4e133 100644 --- a/worlds/sm/__init__.py +++ b/worlds/sm/__init__.py @@ -14,6 +14,7 @@ logger = logging.getLogger("Super Metroid") from .Regions import create_regions from .Rules import set_rules, add_entrance_rule from .Options import sm_options +from .Client import SMSNIClient from .Rom import get_base_rom_path, ROM_PLAYER_LIMIT, SMDeltaPatch, get_sm_symbols import Utils @@ -657,12 +658,6 @@ class SMWorld(World): loc.place_locked_item(item) loc.address = loc.item.code = None - @classmethod - def stage_fill_hook(cls, world, progitempool, usefulitempool, filleritempool, fill_locations): - if world.get_game_players("Super Metroid"): - progitempool.sort( - key=lambda item: 1 if (item.name == 'Morph Ball') else 0) - @classmethod def stage_post_fill(cls, world): new_state = CollectionState(world) diff --git a/worlds/sm64ex/Options.py b/worlds/sm64ex/Options.py index f29a65c58d..88d27bb3ea 100644 --- a/worlds/sm64ex/Options.py +++ b/worlds/sm64ex/Options.py @@ -46,7 +46,7 @@ class MIPS1Cost(Range): class MIPS2Cost(Range): - """How many stars are required to spawn MIPS the secound time. Must be bigger or equal MIPS1Cost""" + """How many stars are required to spawn MIPS the second time.""" range_start = 0 range_end = 80 default = 50 @@ -72,7 +72,8 @@ class AreaRandomizer(Choice): display_name = "Entrance Randomizer" option_Off = 0 option_Courses_Only = 1 - option_Courses_and_Secrets = 2 + option_Courses_and_Secrets_Separate = 2 + option_Courses_and_Secrets = 3 class BuddyChecks(Toggle): diff --git a/worlds/sm64ex/Rules.py b/worlds/sm64ex/Rules.py index 2462206246..2397f2c807 100644 --- a/worlds/sm64ex/Rules.py +++ b/worlds/sm64ex/Rules.py @@ -11,13 +11,14 @@ def fix_reg(entrance_ids, reg, invalidspot, swaplist, world): def set_rules(world, player: int, area_connections): destination_regions = list(range(13)) + [12,13,14] + list(range(15,15+len(sm64secrets))) # Two instances of Destination Course THI. Past normal course idx are secret regions - if world.AreaRandomizer[player].value == 0: - entrance_ids = list(range(len(sm64paintings + sm64secrets))) - if world.AreaRandomizer[player].value >= 1: # Some randomization is happening, randomize Courses - entrance_ids = list(range(len(sm64paintings))) - world.random.shuffle(entrance_ids) - entrance_ids = entrance_ids + list(range(len(sm64paintings), len(sm64paintings) + len(sm64secrets))) - if world.AreaRandomizer[player].value == 2: # Secret Regions as well + secret_entrance_ids = list(range(len(sm64paintings), len(sm64paintings) + len(sm64secrets))) + course_entrance_ids = list(range(len(sm64paintings))) + if world.AreaRandomizer[player].value >= 1: # Some randomization is happening, randomize Courses + world.random.shuffle(course_entrance_ids) + if world.AreaRandomizer[player].value == 2: # Randomize Secrets as well + world.random.shuffle(secret_entrance_ids) + entrance_ids = course_entrance_ids + secret_entrance_ids + if world.AreaRandomizer[player].value == 3: # Randomize Courses and Secrets in one pool world.random.shuffle(entrance_ids) # Guarantee first entrance is a course swaplist = list(range(len(entrance_ids))) @@ -117,7 +118,7 @@ def set_rules(world, player: int, area_connections): add_rule(world.get_location("Toad (Third Floor)", player), lambda state: state.can_reach("Third Floor", 'Region', player) and state.has("Power Star", player, 35)) if world.MIPS1Cost[player].value > world.MIPS2Cost[player].value: - world.MIPS2Cost[player].value = world.MIPS1Cost[player].value + (world.MIPS2Cost[player].value, world.MIPS1Cost[player].value) = (world.MIPS1Cost[player].value, world.MIPS2Cost[player].value) add_rule(world.get_location("MIPS 1", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, world.MIPS1Cost[player].value)) add_rule(world.get_location("MIPS 2", player), lambda state: state.can_reach("Basement", 'Region', player) and state.has("Power Star", player, world.MIPS2Cost[player].value)) diff --git a/worlds/smw/Client.py b/worlds/smw/Client.py index 6ddd4e1073..9cf5a5fcfb 100644 --- a/worlds/smw/Client.py +++ b/worlds/smw/Client.py @@ -3,21 +3,17 @@ import asyncio import time from NetUtils import ClientStatus, color -from worlds import AutoWorldRegister -from SNIClient import Context, snes_buffered_write, snes_flush_writes, snes_read +from worlds.AutoSNIClient import SNIClient from .Names.TextBox import generate_received_text -from Patch import GAME_SMW snes_logger = logging.getLogger("SNES") +# FXPAK Pro protocol memory mapping used by SNI ROM_START = 0x000000 WRAM_START = 0xF50000 WRAM_SIZE = 0x20000 SRAM_START = 0xE00000 -SAVEDATA_START = WRAM_START + 0xF000 -SAVEDATA_SIZE = 0x500 - SMW_ROMHASH_START = 0x7FC0 ROMHASH_SIZE = 0x15 @@ -58,8 +54,12 @@ SMW_BAD_TEXT_BOX_LEVELS = [0x26, 0x02, 0x4B] SMW_BOSS_STATES = [0x80, 0xC0, 0xC1] SMW_UNCOLLECTABLE_LEVELS = [0x25, 0x07, 0x0B, 0x40, 0x0E, 0x1F, 0x20, 0x1B, 0x1A, 0x35, 0x34, 0x31, 0x32] -async def deathlink_kill_player(ctx: Context): - if ctx.game == GAME_SMW: + +class SMWSNIClient(SNIClient): + game = "Super Mario World" + + async def deathlink_kill_player(self, ctx): + from SNIClient import DeathState, snes_buffered_write, snes_flush_writes, snes_read game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) if game_state[0] != 0x14: return @@ -88,25 +88,19 @@ async def deathlink_kill_player(ctx: Context): await snes_flush_writes(ctx) - from SNIClient import DeathState ctx.death_state = DeathState.dead ctx.last_death_link = time.time() - return + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read -async def smw_rom_init(ctx: Context): - if not ctx.rom: - ctx.finished_game = False - ctx.death_link_allow_survive = False - game_hash = await snes_read(ctx, SMW_ROMHASH_START, ROMHASH_SIZE) - if game_hash is None or game_hash == bytes([0] * ROMHASH_SIZE) or game_hash[:3] != b"SMW": + rom_name = await snes_read(ctx, SMW_ROMHASH_START, ROMHASH_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMHASH_SIZE) or rom_name[:3] != b"SMW": return False - else: - ctx.game = GAME_SMW - ctx.items_handling = 0b111 # remote items - ctx.rom = game_hash + ctx.game = self.game + ctx.items_handling = 0b111 # remote items receive_option = await snes_read(ctx, SMW_RECEIVE_MSG_DATA, 0x1) send_option = await snes_read(ctx, SMW_SEND_MSG_DATA, 0x1) @@ -114,73 +108,73 @@ async def smw_rom_init(ctx: Context): ctx.receive_option = receive_option[0] ctx.send_option = send_option[0] - ctx.message_queue = [] - ctx.allow_collect = True death_link = await snes_read(ctx, SMW_DEATH_LINK_ACTIVE_ADDR, 1) if death_link: await ctx.update_death_link(bool(death_link[0] & 0b1)) - return True + + ctx.rom = rom_name + + return True -def add_message_to_queue(ctx: Context, new_message): + def add_message_to_queue(self, new_message): - if not hasattr(ctx, "message_queue"): - ctx.message_queue = [] + if not hasattr(self, "message_queue"): + self.message_queue = [] - ctx.message_queue.append(new_message) - - return + self.message_queue.append(new_message) -async def handle_message_queue(ctx: Context): + async def handle_message_queue(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + if not hasattr(self, "message_queue") or len(self.message_queue) == 0: + return + + game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) + if game_state[0] != 0x14: + return + + mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) + if mario_state[0] != 0x00: + return + + message_box = await snes_read(ctx, SMW_MESSAGE_BOX_ADDR, 0x1) + if message_box[0] != 0x00: + return + + pause_state = await snes_read(ctx, SMW_PAUSE_ADDR, 0x1) + if pause_state[0] != 0x00: + return + + current_level = await snes_read(ctx, SMW_CURRENT_LEVEL_ADDR, 0x1) + if current_level[0] in SMW_BAD_TEXT_BOX_LEVELS: + return + + boss_state = await snes_read(ctx, SMW_BOSS_STATE_ADDR, 0x1) + if boss_state[0] in SMW_BOSS_STATES: + return + + active_boss = await snes_read(ctx, SMW_ACTIVE_BOSS_ADDR, 0x1) + if active_boss[0] != 0x00: + return + + next_message = self.message_queue.pop(0) + + snes_buffered_write(ctx, SMW_MESSAGE_QUEUE_ADDR, bytes(next_message)) + snes_buffered_write(ctx, SMW_MESSAGE_BOX_ADDR, bytes([0x03])) + snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x22])) + + await snes_flush_writes(ctx) - game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) - if game_state[0] != 0x14: return - mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) - if mario_state[0] != 0x00: - return - message_box = await snes_read(ctx, SMW_MESSAGE_BOX_ADDR, 0x1) - if message_box[0] != 0x00: - return + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read - pause_state = await snes_read(ctx, SMW_PAUSE_ADDR, 0x1) - if pause_state[0] != 0x00: - return - - current_level = await snes_read(ctx, SMW_CURRENT_LEVEL_ADDR, 0x1) - if current_level[0] in SMW_BAD_TEXT_BOX_LEVELS: - return - - boss_state = await snes_read(ctx, SMW_BOSS_STATE_ADDR, 0x1) - if boss_state[0] in SMW_BOSS_STATES: - return - - active_boss = await snes_read(ctx, SMW_ACTIVE_BOSS_ADDR, 0x1) - if active_boss[0] != 0x00: - return - - if not hasattr(ctx, "message_queue") or len(ctx.message_queue) == 0: - return - - next_message = ctx.message_queue.pop(0) - - snes_buffered_write(ctx, SMW_MESSAGE_QUEUE_ADDR, bytes(next_message)) - snes_buffered_write(ctx, SMW_MESSAGE_BOX_ADDR, bytes([0x03])) - snes_buffered_write(ctx, SMW_SFX_ADDR, bytes([0x22])) - - await snes_flush_writes(ctx) - - return - - -async def smw_game_watcher(ctx: Context): - if ctx.game == GAME_SMW: - # SMW_TODO: Handle Deathlink game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) mario_state = await snes_read(ctx, SMW_MARIO_STATE_ADDR, 0x1) if game_state is None: @@ -234,7 +228,7 @@ async def smw_game_watcher(ctx: Context): snes_buffered_write(ctx, SMW_BONUS_STAR_ADDR, bytes([egg_count[0]])) await snes_flush_writes(ctx) - await handle_message_queue(ctx) + await self.handle_message_queue(ctx) new_checks = [] event_data = await snes_read(ctx, SMW_EVENT_ROM_DATA, 0x60) @@ -243,6 +237,7 @@ async def smw_game_watcher(ctx: Context): dragon_coins_active = await snes_read(ctx, SMW_DRAGON_COINS_ACTIVE_ADDR, 0x1) from worlds.smw.Rom import item_rom_data, ability_rom_data from worlds.smw.Levels import location_id_to_level_id, level_info_dict + from worlds import AutoWorldRegister for loc_name, level_data in location_id_to_level_id.items(): loc_id = AutoWorldRegister.world_types[ctx.game].location_name_to_id[loc_name] if loc_id not in ctx.locations_checked: @@ -262,7 +257,6 @@ async def smw_game_watcher(ctx: Context): bit_set = (masked_data != 0) if bit_set: - # SMW_TODO: Handle non-included checks new_checks.append(loc_id) else: event_id_value = event_id + level_data[1] @@ -275,7 +269,6 @@ async def smw_game_watcher(ctx: Context): bit_set = (masked_data != 0) if bit_set: - # SMW_TODO: Handle non-included checks new_checks.append(loc_id) verify_game_state = await snes_read(ctx, SMW_GAME_STATE_ADDR, 0x1) @@ -320,7 +313,7 @@ async def smw_game_watcher(ctx: Context): player_name = ctx.player_names[item.player] receive_message = generate_received_text(item_name, player_name) - add_message_to_queue(ctx, receive_message) + self.add_message_to_queue(receive_message) snes_buffered_write(ctx, SMW_RECV_PROGRESS_ADDR, bytes([recv_index])) if item.item in item_rom_data: @@ -372,7 +365,7 @@ async def smw_game_watcher(ctx: Context): rand_trap = random.choice(lit_trap_text_list) for message in rand_trap: - add_message_to_queue(ctx, message) + self.add_message_to_queue(message) await snes_flush_writes(ctx) diff --git a/worlds/smw/__init__.py b/worlds/smw/__init__.py index 1dd64f535f..2e9be535e9 100644 --- a/worlds/smw/__init__.py +++ b/worlds/smw/__init__.py @@ -12,6 +12,7 @@ from .Levels import full_level_list, generate_level_list, location_id_to_level_i from .Rules import set_rules from ..generic.Rules import add_rule from .Names import ItemName, LocationName +from .Client import SMWSNIClient from ..AutoWorld import WebWorld, World from .Rom import LocalRom, patch_rom, get_base_rom_path, SMWDeltaPatch diff --git a/worlds/smz3/Client.py b/worlds/smz3/Client.py new file mode 100644 index 0000000000..c942c66c71 --- /dev/null +++ b/worlds/smz3/Client.py @@ -0,0 +1,118 @@ +import logging +import asyncio +import time + +from NetUtils import ClientStatus, color +from worlds.AutoSNIClient import SNIClient +from .Rom import ROM_PLAYER_LIMIT as SMZ3_ROM_PLAYER_LIMIT + +snes_logger = logging.getLogger("SNES") + +# FXPAK Pro protocol memory mapping used by SNI +ROM_START = 0x000000 +WRAM_START = 0xF50000 +WRAM_SIZE = 0x20000 +SRAM_START = 0xE00000 + +# SMZ3 +SMZ3_ROMNAME_START = ROM_START + 0x00FFC0 +ROMNAME_SIZE = 0x15 + +SAVEDATA_START = WRAM_START + 0xF000 + +SMZ3_INGAME_MODES = {0x07, 0x09, 0x0B} +ENDGAME_MODES = {0x19, 0x1A} +SM_ENDGAME_MODES = {0x26, 0x27} +SMZ3_DEATH_MODES = {0x15, 0x17, 0x18, 0x19, 0x1A} + +SMZ3_RECV_PROGRESS_ADDR = SRAM_START + 0x4000 # 2 bytes +SMZ3_RECV_ITEM_ADDR = SAVEDATA_START + 0x4D2 # 1 byte +SMZ3_RECV_ITEM_PLAYER_ADDR = SAVEDATA_START + 0x4D3 # 1 byte + + +class SMZ3SNIClient(SNIClient): + game = "SMZ3" + + async def validate_rom(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + + rom_name = await snes_read(ctx, SMZ3_ROMNAME_START, ROMNAME_SIZE) + if rom_name is None or rom_name == bytes([0] * ROMNAME_SIZE) or rom_name[:3] != b"ZSM": + return False + + ctx.game = self.game + ctx.items_handling = 0b101 # local items and remote start inventory + + ctx.rom = rom_name + + return True + + + async def game_watcher(self, ctx): + from SNIClient import snes_buffered_write, snes_flush_writes, snes_read + if ctx.server is None or ctx.slot is None: + # not successfully connected to a multiworld server, cannot process the game sending items + return + + currentGame = await snes_read(ctx, SRAM_START + 0x33FE, 2) + if (currentGame is not None): + if (currentGame[0] != 0): + gamemode = await snes_read(ctx, WRAM_START + 0x0998, 1) + endGameModes = SM_ENDGAME_MODES + else: + gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) + endGameModes = ENDGAME_MODES + + if gamemode is not None and (gamemode[0] in endGameModes): + if not ctx.finished_game: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + return + + data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, 4) + if data is None: + return + + recv_index = data[0] | (data[1] << 8) + recv_item = data[2] | (data[3] << 8) + + while (recv_index < recv_item): + item_address = recv_index * 8 + message = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x700 + item_address, 8) + is_z3_item = ((message[5] & 0x80) != 0) + masked_part = (message[5] & 0x7F) if is_z3_item else message[5] + item_index = ((message[4] | (masked_part << 8)) >> 3) + (256 if is_z3_item else 0) + + recv_index += 1 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x680, bytes([recv_index & 0xFF, (recv_index >> 8) & 0xFF])) + + from worlds.smz3.TotalSMZ3.Location import locations_start_id + from worlds.smz3 import convertLocSMZ3IDToAPID + location_id = locations_start_id + convertLocSMZ3IDToAPID(item_index) + + ctx.locations_checked.add(location_id) + location = ctx.location_names[location_id] + snes_logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": [location_id]}]) + + data = await snes_read(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x600, 4) + if data is None: + return + + item_out_ptr = data[2] | (data[3] << 8) + + from worlds.smz3.TotalSMZ3.Item import items_start_id + if item_out_ptr < len(ctx.items_received): + item = ctx.items_received[item_out_ptr] + item_id = item.item - items_start_id + + player_id = item.player if item.player <= SMZ3_ROM_PLAYER_LIMIT else 0 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + item_out_ptr * 4, bytes([player_id & 0xFF, (player_id >> 8) & 0xFF, item_id & 0xFF, (item_id >> 8) & 0xFF])) + item_out_ptr += 1 + snes_buffered_write(ctx, SMZ3_RECV_PROGRESS_ADDR + 0x602, bytes([item_out_ptr & 0xFF, (item_out_ptr >> 8) & 0xFF])) + logging.info('Received %s from %s (%s) (%d/%d in list)' % ( + color(ctx.item_names[item.item], 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'), + ctx.location_names[item.location], item_out_ptr, len(ctx.items_received))) + + await snes_flush_writes(ctx) + diff --git a/worlds/smz3/__init__.py b/worlds/smz3/__init__.py index 753fb556ae..320d506fd2 100644 --- a/worlds/smz3/__init__.py +++ b/worlds/smz3/__init__.py @@ -17,6 +17,7 @@ from worlds.smz3.TotalSMZ3.Location import LocationType, locations_start_id, Loc from worlds.smz3.TotalSMZ3.Patch import Patch as TotalSMZ3Patch, getWord, getWordArray from worlds.smz3.TotalSMZ3.WorldState import WorldState from ..AutoWorld import World, AutoLogicRegister, WebWorld +from .Client import SMZ3SNIClient from .Rom import get_base_rom_bytes, SMZ3DeltaPatch from .ips import IPS_Patch from .Options import smz3_options diff --git a/worlds/subnautica/Items.py b/worlds/subnautica/Items.py index 4201cf3910..4a9eeabdfd 100644 --- a/worlds/subnautica/Items.py +++ b/worlds/subnautica/Items.py @@ -175,7 +175,7 @@ item_table: Dict[int, ItemDict] = { 'name': 'Thermal Plant Fragment', 'tech_type': 'ThermalPlantFragment'}, 35041: {'classification': ItemClassification.progression, - 'count': 2, + 'count': 4, 'name': 'Seaglide Fragment', 'tech_type': 'SeaglideFragment'}, 35042: {'classification': ItemClassification.progression, diff --git a/worlds/subnautica/__init__.py b/worlds/subnautica/__init__.py index 7dc23bf405..bd86dc5ce7 100644 --- a/worlds/subnautica/__init__.py +++ b/worlds/subnautica/__init__.py @@ -44,14 +44,12 @@ class SubnauticaWorld(World): data_version = 7 required_client_version = (0, 3, 5) - prefill_items: List[Item] creatures_to_scan: List[str] def generate_early(self) -> None: - self.prefill_items = [ - self.create_item("Seaglide Fragment"), - self.create_item("Seaglide Fragment") - ] + if "Seaglide Fragment" not in self.world.early_items[self.player]: + self.world.early_items[self.player].value["Seaglide Fragment"] = 2 + scan_option: Options.AggressiveScanLogic = self.world.creature_scan_logic[self.player] creature_pool = scan_option.get_pool() @@ -149,16 +147,6 @@ class SubnauticaWorld(World): ret.exits.append(Entrance(self.player, region_exit, ret)) return ret - def get_pre_fill_items(self) -> List[Item]: - return self.prefill_items - - def pre_fill(self) -> None: - reachable = self.world.get_reachable_locations(player=self.player) - self.world.random.shuffle(reachable) - items = self.prefill_items.copy() - for item in items: - reachable.pop().place_locked_item(item) - class SubnauticaLocation(Location): game: str = "Subnautica" diff --git a/worlds/zillion/__init__.py b/worlds/zillion/__init__.py index 32b84015f1..d982782840 100644 --- a/worlds/zillion/__init__.py +++ b/worlds/zillion/__init__.py @@ -304,7 +304,8 @@ class ZillionWorld(World): zz_patcher.all_fixes_and_options(zz_options) zz_patcher.set_external_item_interface(zz_options.start_char, zz_options.max_level) zz_patcher.set_multiworld_items(multi_items) - zz_patcher.set_rom_to_ram_data(self.world.player_name[self.player].replace(' ', '_').encode()) + game_id = self.world.player_name[self.player].encode() + b'\x00' + self.world.seed_name[-6:].encode() + zz_patcher.set_rom_to_ram_data(game_id) def generate_output(self, output_directory: str) -> None: """This method gets called from a threadpool, do not use world.random here. diff --git a/worlds/zillion/config.py b/worlds/zillion/config.py index e08c4f4278..ca02f9a99f 100644 --- a/worlds/zillion/config.py +++ b/worlds/zillion/config.py @@ -1 +1,4 @@ +import os + base_id = 8675309 +zillion_map = os.path.join(os.path.dirname(__file__), "empty-zillion-map-row-col-labels-281.png") diff --git a/worlds/zillion/docs/setup_en.md b/worlds/zillion/docs/setup_en.md index 43a1296748..63a0ec5a5b 100644 --- a/worlds/zillion/docs/setup_en.md +++ b/worlds/zillion/docs/setup_en.md @@ -15,7 +15,9 @@ RetroArch 1.9.x will not work, as it is older than 1.10.3. 1. Enter the RetroArch main menu screen. -2. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and install "Sega - MS/GG (SMS Plus GX)". +2. Go to Main Menu --> Online Updater --> Core Downloader. Scroll down and install one of these cores: + - "Sega - MS/GG (SMS Plus GX)" + - "Sega - MS/GG/MD/CD (Genesis Plus GX) 3. Go to Settings --> User Interface. Set "Show Advanced Settings" to ON. 4. Go to Settings --> Network. Set "Network Commands" to ON. (It is found below Request Device 16.) Leave the default Network Command Port at 55355. @@ -47,6 +49,15 @@ guide: [Basic Multiworld Setup Guide](/tutorial/Archipelago/setup/en) The [player settings page](/games/Zillion/player-settings) on the website allows you to configure your personal settings and export a config file from them. +### Advanced settings + +The [advanced settings page](/tutorial/Archipelago/advanced_settings/en) describes more options you can put in your configuration file. + - A recommended setting for Zillion is: +``` + early_items: + Scope: 1 +``` + ### Verifying your config file If you would like to validate your config file to make sure it works, you may do so on the [YAML Validator page](/mysterycheck). @@ -63,7 +74,7 @@ If you would like to validate your config file to make sure it works, you may do - Windows - Double-click on your patch file. The Zillion Client will launch automatically, and create your ROM in the location of the patch file. -6. Open the ROM in RetroArch using the core "SMS Plus GX". +6. Open the ROM in RetroArch using the core "SMS Plus GX" or "Genesis Plus GX". - For a single player game, any emulator (or a Sega Master System) can be used, but there are additional features with RetroArch and the Zillion Client. - If you press reset or restore a save state and return to the surface in the game, the Zillion Client will keep open all the doors that you have opened. @@ -80,7 +91,7 @@ If you would like to validate your config file to make sure it works, you may do - This should automatically launch the client, and will also create your ROM in the same place as your patch file. 3. Connect to the client. - Use RetroArch to open the ROM that was generated. - - Be sure to select the **SMS Plus GX** core. This core will allow external tools to read RAM data. + - Be sure to select the **SMS Plus GX** core or the **Genesis Plus GX** core. These cores will allow external tools to read RAM data. 4. Connect to the Archipelago Server. - The patch file which launched your client should have automatically connected you to the AP Server. There are a few reasons this may not happen however, including if the game is hosted on the website but was generated elsewhere. If the client window shows "Server Status: Not Connected", simply ask the host for the address of the server, and copy/paste it into the "Server" input field then press enter. - The client will attempt to reconnect to the new server address, and should momentarily show "Server Status: Connected". diff --git a/worlds/zillion/empty-zillion-map-row-col-labels-281.png b/worlds/zillion/empty-zillion-map-row-col-labels-281.png new file mode 100644 index 0000000000..3084301f7b Binary files /dev/null and b/worlds/zillion/empty-zillion-map-row-col-labels-281.png differ diff --git a/worlds/zillion/options.py b/worlds/zillion/options.py index 4e7d3b6a70..d24b5fd582 100644 --- a/worlds/zillion/options.py +++ b/worlds/zillion/options.py @@ -26,11 +26,6 @@ class ZillionContinues(SpecialRange): } -class ZillionEarlyScope(Toggle): - """ whether to make sure there is a scope available early """ - display_name = "early scope" - - class ZillionFloppyReq(Range): """ how many floppy disks are required """ range_start = 0 @@ -227,7 +222,6 @@ class ZillionRoomGen(Toggle): zillion_options: Dict[str, AssembleOptions] = { "continues": ZillionContinues, - # "early_scope": ZillionEarlyScope, # TODO: implement "floppy_req": ZillionFloppyReq, "gun_levels": ZillionGunLevels, "jump_levels": ZillionJumpLevels, @@ -371,7 +365,7 @@ def validate(world: "MultiWorld", p: int) -> "Tuple[ZzOptions, Counter[str]]": floppy_req.value, wo.continues[p].value, wo.randomize_alarms[p].value, - False, # wo.early_scope[p].value, + False, # early scope can be done with AP early_items True, # balance defense starting_cards.value, bool(room_gen.value) diff --git a/worlds/zillion/requirements.txt b/worlds/zillion/requirements.txt index 0ed98771bd..62f66899f3 100644 --- a/worlds/zillion/requirements.txt +++ b/worlds/zillion/requirements.txt @@ -1 +1 @@ -git+https://github.com/beauxq/zilliandomizer@45a45eaca4119a4d06d2c31546ad19f3abd77f63#egg=zilliandomizer==0.4.4 +git+https://github.com/beauxq/zilliandomizer@c97298ecb1bca58c3dd3376a1e1609fad53788cf#egg=zilliandomizer==0.4.5