diff --git a/BaseClasses.py b/BaseClasses.py index 576eabdd..69db4888 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -1429,16 +1429,16 @@ class PlandoItem(NamedTuple): location: str world: Union[bool, str] = False # False -> own world, True -> not own world from_pool: bool = True # if item should be removed from item pool - force: Union[bool, str] = 'silent' # False -> warns if item not successfully placed. True -> errors out on failure to place item. + force: str = 'silent' # false -> warns if item not successfully placed. true -> errors out on failure to place item. def warn(self, warning: str): - if str(self.force).lower() in ['true', 'fail', 'failure', 'none', 'false', 'warn', 'warning']: + if self.force in ['true', 'fail', 'failure', 'none', 'false', 'warn', 'warning']: logging.warning(f'{warning}') else: logging.debug(f'{warning}') def failed(self, warning: str, exception=Exception): - if str(self.force).lower() in ['true', 'fail', 'failure']: + if self.force in ['true', 'fail', 'failure']: raise exception(warning) else: self.warn(warning) diff --git a/Fill.py b/Fill.py index f0961059..6eba02c5 100644 --- a/Fill.py +++ b/Fill.py @@ -257,6 +257,11 @@ def balance_multiworld_progression(world): reachable_locations_count = {player: 0 for player in range(1, world.players + 1)} + def event_key(location): + return location.event and ( + world.keyshuffle[location.item.player] or not location.item.smallkey) and ( + world.bigkeyshuffle[location.item.player] or not location.item.bigkey) + def get_sphere_locations(sphere_state, locations): sphere_state.sweep_for_events(key_only=True, locations=locations) return [loc for loc in locations if sphere_state.can_reach(loc)] @@ -279,9 +284,7 @@ def balance_multiworld_progression(world): candidate_items = [] while True: for location in balancing_sphere: - if location.event and ( - world.keyshuffle[location.item.player] or not location.item.smallkey) and ( - world.bigkeyshuffle[location.item.player] or not location.item.bigkey): + if event_key(location): balancing_state.collect(location.item, True, location) if location.item.player in balancing_players and not location.locked: candidate_items.append(location) @@ -342,8 +345,7 @@ def balance_multiworld_progression(world): sphere_locations.append(location) for location in sphere_locations: - if location.event and (world.keyshuffle[location.item.player] or not location.item.smallkey) and ( - world.bigkeyshuffle[location.item.player] or not location.item.bigkey): + if event_key(location): state.collect(location.item, True, location) checked_locations.extend(sphere_locations) diff --git a/MultiClient.py b/MultiClient.py index c8ffedde..e4ceb133 100644 --- a/MultiClient.py +++ b/MultiClient.py @@ -36,6 +36,10 @@ import WebUI from worlds.alttp import Regions import Utils +# logging note: +# logging.* gets send to only the text console, logger.* gets send to the WebUI as well, if it's initialized. +logger = logging.getLogger("Client") + def create_named_task(coro, *args, name=None): if not name: @@ -53,6 +57,7 @@ class Context(): # WebUI Stuff self.ui_node = WebUI.WebUiClient() + logger.addHandler(self.ui_node) self.custom_address = None self.webui_socket_port: typing.Optional[int] = port self.hint_cost = 0 @@ -119,6 +124,7 @@ class Context(): return await self.server.socket.send(dumps(msgs)) + color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34, 'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43, 'blue_bg': 44, 'purple_bg': 45, 'cyan_bg': 46, 'white_bg': 47} @@ -423,11 +429,11 @@ def launch_qusb2snes(ctx: Context): qusb2snes_path = Utils.local_path(qusb2snes_path) if os.path.isfile(qusb2snes_path): - ctx.ui_node.log_info(f"Attempting to start {qusb2snes_path}") + logger.info(f"Attempting to start {qusb2snes_path}") import subprocess subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path)) else: - ctx.ui_node.log_info( + logger.info( f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, " f"please start it yourself if it is not running") @@ -435,7 +441,7 @@ def launch_qusb2snes(ctx: Context): async def _snes_connect(ctx: Context, address: str): address = f"ws://{address}" if "://" not in address else address - ctx.ui_node.log_info("Connecting to QUsb2snes at %s ..." % address) + logger.info("Connecting to QUsb2snes at %s ..." % address) seen_problems = set() succesful = False while not succesful: @@ -447,7 +453,7 @@ async def _snes_connect(ctx: Context, address: str): # only tell the user about new problems, otherwise silently lay in wait for a working connection if problem not in seen_problems: seen_problems.add(problem) - ctx.ui_node.log_error(f"Error connecting to QUsb2snes ({problem})") + logger.error(f"Error connecting to QUsb2snes ({problem})") if len(seen_problems) == 1: # this is the first problem. Let's try launching QUsb2snes if it isn't already running @@ -470,7 +476,7 @@ async def get_snes_devices(ctx: Context): devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None if not devices: - ctx.ui_node.log_info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.') + logger.info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.') while not devices: await asyncio.sleep(1) await socket.send(dumps(DeviceList_Request)) @@ -485,7 +491,7 @@ async def get_snes_devices(ctx: Context): async def snes_connect(ctx: Context, address): global SNES_RECONNECT_DELAY if ctx.snes_socket is not None and ctx.snes_state == SNES_CONNECTED: - ctx.ui_node.log_error('Already connected to snes') + logger.error('Already connected to snes') return recv_task = None @@ -511,7 +517,7 @@ async def snes_connect(ctx: Context, address): return - ctx.ui_node.log_info("Attaching to " + device) + logger.info("Attaching to " + device) Attach_Request = { "Opcode": "Attach", @@ -524,12 +530,12 @@ async def snes_connect(ctx: Context, address): ctx.ui_node.send_connection_status(ctx) if 'sd2snes' in device.lower() or 'COM' in device: - ctx.ui_node.log_info("SD2SNES/FXPAK Detected") + logger.info("SD2SNES/FXPAK Detected") ctx.is_sd2snes = True await ctx.snes_socket.send(dumps({"Opcode" : "Info", "Space" : "SNES"})) reply = loads(await ctx.snes_socket.recv()) if reply and 'Results' in reply: - ctx.ui_node.log_info(reply['Results']) + logger.info(reply['Results']) else: ctx.is_sd2snes = False @@ -548,9 +554,9 @@ async def snes_connect(ctx: Context, address): ctx.snes_socket = None ctx.snes_state = SNES_DISCONNECTED if not ctx.snes_reconnect_address: - ctx.ui_node.log_error("Error connecting to snes (%s)" % e) + logger.error("Error connecting to snes (%s)" % e) else: - ctx.ui_node.log_error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s") + logger.error(f"Error connecting to snes, attempt again in {SNES_RECONNECT_DELAY}s") asyncio.create_task(snes_autoreconnect(ctx)) SNES_RECONNECT_DELAY *= 2 @@ -577,11 +583,11 @@ async def snes_recv_loop(ctx: Context): try: async for msg in ctx.snes_socket: ctx.snes_recv_queue.put_nowait(msg) - ctx.ui_node.log_warning("Snes disconnected") + logger.warning("Snes disconnected") except Exception as e: if not isinstance(e, websockets.WebSocketException): - logging.exception(e) - ctx.ui_node.log_error("Lost connection to the snes, type /snes to reconnect") + logger.exception(e) + logger.error("Lost connection to the snes, type /snes to reconnect") finally: socket, ctx.snes_socket = ctx.snes_socket, None if socket is not None and not socket.closed: @@ -595,7 +601,7 @@ async def snes_recv_loop(ctx: Context): ctx.rom = None if ctx.snes_reconnect_address: - ctx.ui_node.log_info(f"...reconnecting in {SNES_RECONNECT_DELAY}s") + logger.info(f"...reconnecting in {SNES_RECONNECT_DELAY}s") asyncio.create_task(snes_autoreconnect(ctx)) @@ -624,10 +630,10 @@ async def snes_read(ctx : Context, address, size): break if len(data) != size: - logging.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data))) + logger.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data))) if len(data): - ctx.ui_node.log_error(str(data)) - ctx.ui_node.log_warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.' + logger.error(str(data)) + logger.warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.' 'Try un-selecting and re-selecting the SNES Device.') if ctx.snes_socket is not None and not ctx.snes_socket.closed: await ctx.snes_socket.close() @@ -652,7 +658,7 @@ async def snes_write(ctx : Context, write_list): for address, data in write_list: if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)): - ctx.ui_node.log_error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data))) + logger.error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data))) return False for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START): cmd += b'\xA9' # LDA @@ -669,7 +675,7 @@ async def snes_write(ctx : Context, write_list): await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(cmd) else: - logging.warning(f"Could not send data to SNES: {cmd}") + logger.warning(f"Could not send data to SNES: {cmd}") except websockets.ConnectionClosed: return False else: @@ -682,7 +688,7 @@ async def snes_write(ctx : Context, write_list): await ctx.snes_socket.send(dumps(PutAddress_Request)) await ctx.snes_socket.send(data) else: - logging.warning(f"Could not send data to SNES: {data}") + logger.warning(f"Could not send data to SNES: {data}") except websockets.ConnectionClosed: return False @@ -719,7 +725,7 @@ async def server_loop(ctx: Context, address=None): ctx.ui_node.send_connection_status(ctx) cached_address = None if ctx.server and ctx.server.socket: - ctx.ui_node.log_error('Already connected') + logger.error('Already connected') return if address is None: # set through CLI or APBP @@ -735,18 +741,18 @@ async def server_loop(ctx: Context, address=None): # Wait for the user to provide a multiworld server address if not address: - logging.info('Please connect to a multiworld server.') + logger.info('Please connect to a multiworld server.') ctx.ui_node.poll_for_server_ip() return address = f"ws://{address}" if "://" not in address else address port = urllib.parse.urlparse(address).port or 38281 - ctx.ui_node.log_info('Connecting to multiworld server at %s' % address) + logger.info('Connecting to multiworld server at %s' % address) try: socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None) ctx.server = Endpoint(socket) - ctx.ui_node.log_info('Connected') + logger.info('Connected') ctx.server_address = address ctx.ui_node.send_connection_status(ctx) SERVER_RECONNECT_DELAY = START_RECONNECT_DELAY @@ -754,21 +760,21 @@ async def server_loop(ctx: Context, address=None): for msg in loads(data): cmd, args = (msg[0], msg[1]) if len(msg) > 1 else (msg, None) await process_server_cmd(ctx, cmd, args) - ctx.ui_node.log_warning('Disconnected from multiworld server, type /connect to reconnect') + logger.warning('Disconnected from multiworld server, type /connect to reconnect') except WebUI.WaitingForUiException: pass except ConnectionRefusedError: if cached_address: - ctx.ui_node.log_error('Unable to connect to multiworld server at cached address. ' + logger.error('Unable to connect to multiworld server at cached address. ' 'Please use the connect button above.') else: - ctx.ui_node.log_error('Connection refused by the multiworld server') + logger.error('Connection refused by the multiworld server') except (OSError, websockets.InvalidURI): - ctx.ui_node.log_error('Failed to connect to the multiworld server') + logger.error('Failed to connect to the multiworld server') except Exception as e: - ctx.ui_node.log_error('Lost connection to the multiworld server, type /connect to reconnect') + logger.error('Lost connection to the multiworld server, type /connect to reconnect') if not isinstance(e, websockets.WebSocketException): - logging.exception(e) + logger.exception(e) finally: ctx.awaiting_rom = False ctx.auth = None @@ -780,7 +786,7 @@ async def server_loop(ctx: Context, address=None): ctx.server = None ctx.server_task = None if ctx.server_address: - ctx.ui_node.log_info(f"... reconnecting in {SERVER_RECONNECT_DELAY}s") + logger.info(f"... reconnecting in {SERVER_RECONNECT_DELAY}s") ctx.ui_node.send_connection_status(ctx) asyncio.create_task(server_autoreconnect(ctx)) SERVER_RECONNECT_DELAY *= 2 @@ -797,18 +803,18 @@ async def server_autoreconnect(ctx: Context): async def process_server_cmd(ctx: Context, cmd, args): if cmd == 'RoomInfo': - ctx.ui_node.log_info('--------------------------------') - ctx.ui_node.log_info('Room Information:') - ctx.ui_node.log_info('--------------------------------') + logger.info('--------------------------------') + logger.info('Room Information:') + logger.info('--------------------------------') version = args["version"] ctx.server_version = tuple(version) version = ".".join(str(item) for item in version) - ctx.ui_node.log_info(f'Server protocol version: {version}') + logger.info(f'Server protocol version: {version}') if "tags" in args: - ctx.ui_node.log_info("Server protocol tags: " + ", ".join(args["tags"])) + logger.info("Server protocol tags: " + ", ".join(args["tags"])) if args['password']: - ctx.ui_node.log_info('Password required') + logger.info('Password required') if "forfeit_mode" in args: # could also be version > 2.2.1, but going with implicit content here logging.info(f"Forfeit setting: {args['forfeit_mode']}") logging.info(f"Remaining setting: {args['remaining_mode']}") @@ -820,16 +826,16 @@ async def process_server_cmd(ctx: Context, cmd, args): ctx.remaining_mode = args['remaining_mode'] ctx.ui_node.send_game_info(ctx) if len(args['players']) < 1: - ctx.ui_node.log_info('No player connected') + logger.info('No player connected') else: args['players'].sort() current_team = -1 - ctx.ui_node.log_info('Connected players:') + logger.info('Connected players:') for team, slot, name in args['players']: if team != current_team: - ctx.ui_node.log_info(f' Team #{team + 1}') + logger.info(f' Team #{team + 1}') current_team = team - ctx.ui_node.log_info(' %s (Player %d)' % (name, slot)) + logger.info(' %s (Player %d)' % (name, slot)) await server_auth(ctx, args['password']) elif cmd == 'ConnectionRefused': @@ -845,7 +851,7 @@ async def process_server_cmd(ctx: Context, cmd, args): raise Exception('Server reported your client version as incompatible') #last to check, recoverable problem elif 'InvalidPassword' in args: - ctx.ui_node.log_error('Invalid password') + logger.error('Invalid password') ctx.password = None await server_auth(ctx, True) else: @@ -855,7 +861,7 @@ async def process_server_cmd(ctx: Context, cmd, args): elif cmd == 'Connected': if ctx.send_unsafe: ctx.send_unsafe = False - ctx.ui_node.log_info(f'Turning off sending of ALL location checks not declared as missing. If you want it on, please use /send_unsafe true') + logger.info(f'Turning off sending of ALL location checks not declared as missing. If you want it on, please use /send_unsafe true') Utils.persistent_store("servers", ctx.rom, ctx.server_address) ctx.team, ctx.slot = args[0] ctx.player_names = {p: n for p, n in args[1]} @@ -894,7 +900,7 @@ async def process_server_cmd(ctx: Context, cmd, args): if location not in ctx.locations_info: replacements = {0xA2: 'Small Key', 0x9D: 'Big Key', 0x8D: 'Compass', 0x7D: 'Map'} item_name = replacements.get(item, get_item_name_from_id(item)) - ctx.ui_node.log_info( + logger.info( f"Saw {color(item_name, 'red', 'bold')} at {list(Regions.location_table.keys())[location - 1]}") ctx.locations_info[location] = (item, player) ctx.watcher_event.set() @@ -945,13 +951,13 @@ async def process_server_cmd(ctx: Context, cmd, args): ctx.player_names = {p: n for p, n in args} elif cmd == 'Print': - ctx.ui_node.log_info(args) + logger.info(args) elif cmd == 'HintPointUpdate': ctx.hint_points = args[0] else: - logging.debug(f"unknown command {args}") + logger.debug(f"unknown command {args}") def get_tags(ctx: Context): @@ -963,11 +969,11 @@ def get_tags(ctx: Context): async def server_auth(ctx: Context, password_requested): if password_requested and not ctx.password: - ctx.ui_node.log_info('Enter the password required to join this game:') + logger.info('Enter the password required to join this game:') ctx.password = await console_input(ctx) if ctx.rom is None: ctx.awaiting_rom = True - ctx.ui_node.log_info( + logger.info( 'No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)') return ctx.awaiting_rom = False @@ -997,7 +1003,7 @@ class ClientCommandProcessor(CommandProcessor): self.ctx = ctx def output(self, text: str): - self.ctx.ui_node.log_info(text) + logger.info(text) def _cmd_exit(self) -> bool: """Close connections and client""" @@ -1034,7 +1040,7 @@ class ClientCommandProcessor(CommandProcessor): def _cmd_received(self) -> bool: """List all received items""" - self.ctx.ui_node.log_info('Received items:') + logger.info('Received items:') for index, item in enumerate(self.ctx.items_received, 1): self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], get_item_name_from_id(item.item), get_location_name_from_address(item.location), index, @@ -1086,7 +1092,7 @@ class ClientCommandProcessor(CommandProcessor): self.ctx.found_items = toggle.lower() in {"1", "true", "on"} else: self.ctx.found_items = not self.ctx.found_items - self.ctx.ui_node.log_info(f"Set showing team items to {self.ctx.found_items}") + logger.info(f"Set showing team items to {self.ctx.found_items}") asyncio.create_task(self.ctx.send_msgs([['UpdateTags', get_tags(self.ctx)]])) return True @@ -1097,7 +1103,7 @@ class ClientCommandProcessor(CommandProcessor): else: self.ctx.slow_mode = not self.ctx.slow_mode - self.ctx.ui_node.log_info(f"Setting slow mode to {self.ctx.slow_mode}") + logger.info(f"Setting slow mode to {self.ctx.slow_mode}") def _cmd_web(self): if self.ctx.webui_socket_port: @@ -1109,9 +1115,9 @@ class ClientCommandProcessor(CommandProcessor): """Force sending of locations the server did not specify was actually missing. WARNING: This may brick online trackers. Turned off on reconnect.""" if toggle: self.ctx.send_unsafe = toggle.lower() in {"1", "true", "on"} - self.ctx.ui_node.log_info(f'Turning {("on" if self.ctx.send_unsafe else "off")} the option to send ALL location checks to the multiserver.') + logger.info(f'Turning {("on" if self.ctx.send_unsafe else "off")} the option to send ALL location checks to the multiserver.') else: - self.ctx.ui_node.log_info("You must specify /send_unsafe true explicitly.") + logger.info("You must specify /send_unsafe true explicitly.") self.ctx.send_unsafe = False def default(self, raw: str): @@ -1144,7 +1150,7 @@ async def track_locations(ctx : Context, roomid, roomdata): def new_check(location): ctx.unsafe_locations_checked.add(location) - ctx.ui_node.log_info("New check: %s (%d/216)" % (location, len(ctx.unsafe_locations_checked))) + logging.info("New check: %s (%d/216)" % (location, len(ctx.unsafe_locations_checked))) ctx.ui_node.send_location_check(ctx, location) for location, (loc_roomid, loc_mask) in location_table_uw.items(): @@ -1152,7 +1158,7 @@ async def track_locations(ctx : Context, roomid, roomdata): if location not in ctx.unsafe_locations_checked and loc_roomid == roomid and (roomdata << 4) & loc_mask != 0: new_check(location) except Exception as e: - ctx.ui_node.log_info(f"Exception: {e}") + logger.exception(f"Exception: {e}") uw_begin = 0x129 uw_end = 0 @@ -1215,7 +1221,7 @@ async def send_finished_game(ctx: Context): await ctx.send_msgs([['GameFinished', '']]) ctx.finished_game = True except Exception as ex: - logging.exception(ex) + logger.exception(ex) async def game_watcher(ctx : Context): @@ -1245,7 +1251,7 @@ async def game_watcher(ctx : Context): await server_auth(ctx, False) if ctx.auth and ctx.auth != ctx.rom: - ctx.ui_node.log_warning("ROM change detected, please reconnect to the multiworld server") + logger.warning("ROM change detected, please reconnect to the multiworld server") await ctx.disconnect() gamemode = await snes_read(ctx, WRAM_START + 0x10, 1) @@ -1309,7 +1315,7 @@ async def game_watcher(ctx : Context): if scout_location > 0 and scout_location not in ctx.locations_scouted: ctx.locations_scouted.add(scout_location) - ctx.ui_node.log_info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}') + logger.info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}') await ctx.send_msgs([['LocationScouts', [scout_location]]]) await track_locations(ctx, roomid, roomdata) diff --git a/Mystery.py b/Mystery.py index 53daffc7..1e6c83ac 100644 --- a/Mystery.py +++ b/Mystery.py @@ -560,7 +560,7 @@ def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses" ret.plando_items = [] if "items" in plando_options: - default_placement = PlandoItem(item="", location="") + def add_plando_item(item: str, location: str): if item not in item_table: raise Exception(f"Could not plando item {item} as the item was not recognized") @@ -571,9 +571,9 @@ def roll_settings(weights, plando_options: typing.Set[str] = frozenset(("bosses" options = weights.get("plando_items", []) for placement in options: if roll_percentage(get_choice("percentage", placement, 100)): - from_pool = get_choice("from_pool", placement, default_placement.from_pool) - location_world = get_choice("world", placement, default_placement.world) - force = get_choice("force", placement, default_placement.force) + from_pool = get_choice("from_pool", placement, PlandoItem._field_defaults["from_pool"]) + location_world = get_choice("world", placement, PlandoItem._field_defaults["world"]) + force = str(get_choice("force", placement, PlandoItem._field_defaults["force"])).lower() if "items" in placement and "locations" in placement: items = placement["items"] locations = placement["locations"] diff --git a/NetUtils.py b/NetUtils.py index 5d996795..f348c23f 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -13,6 +13,7 @@ class Node: def __init__(self): self.endpoints = [] + super(Node, self).__init__() def broadcast_all(self, msgs): msgs = self.dumper(msgs) diff --git a/WebHostLib/static/assets/tutorial/tutorials.json b/WebHostLib/static/assets/tutorial/tutorials.json index 6b91343a..a1d659fa 100644 --- a/WebHostLib/static/assets/tutorial/tutorials.json +++ b/WebHostLib/static/assets/tutorial/tutorials.json @@ -69,6 +69,20 @@ ] } ] + }, + { + "name": "Plando Tutorial", + "description": "A guide to creating Multiworld Plandos", + "files": [ + { + "language": "English", + "filename": "zelda3/plando_en.md", + "link": "zelda3/plando/en", + "authors": [ + "Berserker" + ] + } + ] } ] } diff --git a/WebHostLib/static/assets/tutorial/zelda3/plando_en.md b/WebHostLib/static/assets/tutorial/zelda3/plando_en.md index 08cff395..7eeac95f 100644 --- a/WebHostLib/static/assets/tutorial/zelda3/plando_en.md +++ b/WebHostLib/static/assets/tutorial/zelda3/plando_en.md @@ -18,17 +18,17 @@ it defaults to vanilla - Instructions are separated by a semicolon - Available Instructions: - - Direct Placement: - - Example: "Eastern Palace-Trinexx" - - Takes a particular Arena and particular boss, then places that boss into that arena - - Ganons Tower has 3 placements, "Ganons Tower Top", "Ganons Tower Middle" and "Ganons Tower Bottom" - - Boss Placement: - - Example: "Trinexx" - - Takes a particular boss and places that boss in any remaining slots in which this boss can function. - - In this example, it would fill Desert Palace, but not Tower of Hera. - - Boss Shuffle: - - Example: "simple" - - Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as a last instruction. + - Direct Placement: + - Example: "Eastern Palace-Trinexx" + - Takes a particular Arena and particular boss, then places that boss into that arena + - Ganons Tower has 3 placements, "Ganons Tower Top", "Ganons Tower Middle" and "Ganons Tower Bottom" + - Boss Placement: + - Example: "Trinexx" + - Takes a particular boss and places that boss in any remaining slots in which this boss can function. + - In this example, it would fill Desert Palace, but not Tower of Hera. + - Boss Shuffle: + - Example: "simple" + - Runs a particular boss shuffle mode to finish construction instead of vanilla placement, typically used as a last instruction. - [Available Bosses](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L135) - [Available Arenas](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Bosses.py#L186) @@ -47,20 +47,83 @@ boss_shuffle: 4. A Trinexx -> Kholdstare Singularity that prevents ice Trinexx in GT +### Items +- This module is disabled by default. +- Has the options from_pool, world, percentage, force and either item and location or items and locations +- All of these options support subweights +- percentage is the percentage chance for this block to trigger + - is a number in the range [0, 100], can be omitted entirely for 100% +- from_pool denotes if the item should be taken from the item pool, or be an additional item entirely. + - can be true or false, defaults to true when omitted +- world is the target world to place the item + - ignored if only one world is generated + - can be a number, to target that slot in the multiworld + - can be a name, to target that player's world + - can be true, to target any other player's world + - can be false, to target own world and is the default + - can be null, to target a random world +- force is either "silent", "true" or "false". + - "true" means the item has to be placed, or the generator aborts with an exception. + - "false" means the generator logs a warning if the placement can't be done. + - "silent" means that this entry is entirely ignored if the placement fails and is the default. +- Single Placement + - place a single item at a single location + - item denotes the Item to place + - location denotes the Location to place it into +- Multi Placement + - place multiple items into multiple locations, until either list is exhausted. + - items denotes the items to use, can be given a number to have multiple of that item + - locations lists the possible locations those items can be placed in + - placements are picked randomly, not sorted in any way +- [Available Items](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Items.py#L26) +- [Available Locations](https://github.com/Berserker66/MultiWorld-Utilities/blob/3b5ba161dea223b96e9b1fc890e03469d9c6eb59/Regions.py#L418) -### Text +#### Examples +```yaml +plando_items: + - item: + Lamp: 1 + Fire Rod: 1 + location: Link's House + from_pool: true + world: true + percentage: 50 + - items: + Progressive Sword: 4 + Progressive Bow: 1 + Progressive Bow (Alt): 1 + locations: + - Desert Palace - Big Chest + - Eastern Palace - Big Chest + - Tower of Hera - Big Chest + - Swamp Palace - Big Chest + - Thieves' Town - Big Chest + - Skull Woods - Big Chest + - Ice Palace - Big Chest + - Misery Mire - Big Chest + - Turtle Rock - Big Chest + - Palace of Darkness - Big Chest + world: false +``` + +The first example has a 50% chance to occur, which if it does places either the Lamp or Fire Rod in one's own +Link's House and removes the picked item from the item pool. + +The second example always triggers and places the Swords and Bows into one's own Big Chests + +### Texts - This module is disabled by default. - Has the options "text", "at" and "percentage" - percentage is the percentage chance for this text to be placed, can be omitted entirely for 100% -- text is the text to be placed. - - can be weighted. - - \n is a newline. - - @ is the entered player's name. - - Warning: Text Mapper does not support full unicode. - - [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756) +- text is the text to be placed. + - can be weighted. + - \n is a newline. + - @ is the entered player's name. + - Warning: Text Mapper does not support full unicode. + - [Alphabet](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L756) - at is the location within the game to attach the text to. - - can be weighted. - - [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498) + - can be weighted. + - [List of targets](https://github.com/Berserker66/MultiWorld-Utilities/blob/65fa39df95c90c9b66141aee8b16b7e560d00819/Text.py#L1498) #### Example ```yaml @@ -75,3 +138,32 @@ plando_texts: This has a 50% chance to trigger at all, if it does, it throws a coin between "uncle_leaving_text" and "uncle_dying_sewer", then places the text "This is a plando.\nYou've been warned." at that location. + +### Connections +- This module is disabled by default. +- Has the options "percentage", "entrance", "exit" and "direction". +- All options support subweights +- percentage is the percentage chance for this to be connected, can be omitted entirely for 100% +- Any Door has 4 total directions, as a door can be unlinked like in insanity ER +- entrance is the overworld door +- exit is the underworld exit +- direction can be "both", "entrance" or "exit" +- doors can be found in [this file](https://github.com/Berserker66/MultiWorld-Utilities/blob/main/EntranceShuffle.py) + + +#### Example +```yaml +plando_connections: + - entrance: Links House + exit: Hyrule Castle Exit (West) + direction: both + - entrance: Hyrule Castle Entrance (West) + exit: Links House Exit + direction: both +``` + +The first block connects the overworld entrance that normally leads to Link's House +to put you into the HC West Wing instead, exiting from within there will put you at the Overworld exiting Link's House. + +Without the second block, you'd still exit from within Link's House to outside Link's House and the left side +Balcony Entrance would still lead into HC West Wing \ No newline at end of file diff --git a/WebUI.py b/WebUI.py index e0743c7c..a28a68a0 100644 --- a/WebUI.py +++ b/WebUI.py @@ -1,6 +1,7 @@ import http.server import logging import json +import typing import socket import socketserver import threading @@ -12,36 +13,20 @@ from NetUtils import Node from MultiClient import Context import Utils -logger = logging.getLogger("WebUIRelay") - -class WebUiClient(Node): +class WebUiClient(Node, logging.Handler): loader = staticmethod(json.loads) dumper = staticmethod(json.dumps) - def __init__(self): - super().__init__() + super(WebUiClient, self).__init__() self.manual_snes = None @staticmethod - def build_message(msg_type: str, content: dict) -> dict: + def build_message(msg_type: str, content: typing.Union[str, dict]) -> dict: return {'type': msg_type, 'content': content} - def log_info(self, message, *args, **kwargs): - self.broadcast_all(self.build_message('info', message)) - logger.info(message, *args, **kwargs) - - def log_warning(self, message, *args, **kwargs): - self.broadcast_all(self.build_message('warning', message)) - logger.warning(message, *args, **kwargs) - - def log_error(self, message, *args, **kwargs): - self.broadcast_all(self.build_message('error', message)) - logger.error(message, *args, **kwargs) - - def log_critical(self, message, *args, **kwargs): - self.broadcast_all(self.build_message('critical', message)) - logger.critical(message, *args, **kwargs) + def emit(self, record: logging.LogRecord) -> None: + self.broadcast_all({"type": record.levelname.lower(), "content": str(record.msg)}) def send_chat_message(self, message): self.broadcast_all(self.build_message('chat', message)) diff --git a/requirements.txt b/requirements.txt index 0588e3ba..3aec7ffb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ colorama>=0.4.4 websockets>=8.1 -PyYAML>=5.3.1 +PyYAML>=5.4 fuzzywuzzy>=0.18.0 bsdiff4>=1.2.0 -prompt_toolkit>=3.0.8 +prompt_toolkit>=3.0.10 appdirs>=1.4.4 maseya-z3pr>=1.0.0rc1 xxtea>=2.0.0.post0 diff --git a/worlds/alttp/EntranceShuffle.py b/worlds/alttp/EntranceShuffle.py index 2c85d4bb..c78a90ab 100644 --- a/worlds/alttp/EntranceShuffle.py +++ b/worlds/alttp/EntranceShuffle.py @@ -2251,7 +2251,10 @@ def plando_connect(world, player: int): if world.plando_connections[player]: for connection in world.plando_connections[player]: func = lookup[connection.direction] - func(world, connection.entrance, connection.exit, player) + try: + func(world, connection.entrance, connection.exit, player) + except Exception as e: + raise Exception(f"Could not connect using {connection}") from e LW_Dungeon_Entrances = ['Desert Palace Entrance (South)', diff --git a/worlds/alttp/Main.py b/worlds/alttp/Main.py index 286b8feb..9abbb871 100644 --- a/worlds/alttp/Main.py +++ b/worlds/alttp/Main.py @@ -452,6 +452,7 @@ def copy_world(world): ret.timer = world.timer.copy() ret.shufflepots = world.shufflepots.copy() ret.shuffle_prizes = world.shuffle_prizes.copy() + ret.shop_shuffle = world.shop_shuffle.copy() ret.dark_room_logic = world.dark_room_logic.copy() ret.restrict_dungeon_item_on_boss = world.restrict_dungeon_item_on_boss.copy()