From a528ed5e9e8349ffad6b9c4be3c058a602138702 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 7 Mar 2021 22:05:07 +0100 Subject: [PATCH] Client now uses location_id consistently internally, instead of switching on a dime all the time And some smaller changes --- BaseClasses.py | 5 +- Main.py | 5 +- MultiClient.py | 150 +++++++++++++++++++++++------------------- MultiServer.py | 42 ++++++------ NetUtils.py | 2 +- worlds/alttp/Shops.py | 4 +- worlds/alttp/Text.py | 1 - 7 files changed, 112 insertions(+), 97 deletions(-) diff --git a/BaseClasses.py b/BaseClasses.py index bf684b1f..d8cc5cdc 100644 --- a/BaseClasses.py +++ b/BaseClasses.py @@ -395,7 +395,8 @@ class MultiWorld(): while prog_locations: sphere = [] - # build up spheres of collection radius. Everything in each sphere is independent from each other in dependencies and only depends on lower spheres + # build up spheres of collection radius. + # Everything in each sphere is independent from each other in dependencies and only depends on lower spheres for location in prog_locations: if location.can_reach(state): sphere.append(location) @@ -433,8 +434,6 @@ class MultiWorld(): state.collect(location.item, True, location) locations -= sphere - - def fulfills_accessibility(self, state: Optional[CollectionState] = None): """Check if accessibility rules are fulfilled with current or supplied state.""" if not state: diff --git a/Main.py b/Main.py index d480696c..d68abde5 100644 --- a/Main.py +++ b/Main.py @@ -147,6 +147,9 @@ def main(args, seed=None): logger.info('') + for player in world.alttp_player_ids: + world.difficulty_requirements[player] = difficulties[world.difficulty[player]] + for player in world.player_ids: for tok in filter(None, args.startinventory[player].split(',')): item = ItemFactory(tok.strip(), player) @@ -186,8 +189,6 @@ def main(args, seed=None): hk_create_regions(world, player) for player in world.alttp_player_ids: - world.difficulty_requirements[player] = difficulties[world.difficulty[player]] - if world.open_pyramid[player] == 'goal': world.open_pyramid[player] = world.goal[player] in {'crystals', 'ganontriforcehunt', 'localganontriforcehunt', 'ganonpedestal'} elif world.open_pyramid[player] == 'auto': diff --git a/MultiClient.py b/MultiClient.py index 763e1014..f6603c99 100644 --- a/MultiClient.py +++ b/MultiClient.py @@ -9,13 +9,12 @@ import socket import os import subprocess import base64 -import re import shutil from json import loads, dumps from random import randrange -from Utils import get_item_name_from_id, get_location_name_from_address +from Utils import get_item_name_from_id exit_func = atexit.register(input, "Press enter to close.") @@ -54,6 +53,7 @@ class Context(): # WebUI Stuff self.ui_node = WebUI.WebUiClient() logger.addHandler(self.ui_node) + self.ready = False self.custom_address = None self.webui_socket_port: typing.Optional[int] = port self.hint_cost = 0 @@ -70,7 +70,7 @@ class Context(): self.input_requests = 0 self.snes_socket = None - self.snes_state = SNES_DISCONNECTED + self.snes_state = SNESState.SNES_DISCONNECTED self.snes_attached_device = None self.snes_reconnect_address = None self.snes_recv_queue = asyncio.Queue() @@ -87,12 +87,12 @@ class Context(): self.slot = None self.player_names: typing.Dict[int: str] = {} self.locations_recognized = set() - # these should probably track IDs where possible - self.locations_checked:typing.Set[str] = set() - self.locations_scouted:typing.Set[str] = set() + + self.locations_checked:typing.Set[int] = set() + self.locations_scouted:typing.Set[int] = set() self.items_received = [] - self.items_missing = [] - self.items_checked = None + self.missing_locations: typing.List[int] = [] + self.checked_locations: typing.List[int] = [] self.locations_info = {} self.awaiting_rom = False self.rom = None @@ -414,6 +414,9 @@ location_table_uw = {"Blind's Hideout - Top": (0x11d, 0x10), 'Ganons Tower - Mini Helmasaur Key Drop': (0x3d, 0x400), 'Ganons Tower - Pre-Moldorm Chest': (0x3d, 0x40), 'Ganons Tower - Validation Chest': (0x4d, 0x10)} + +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, @@ -427,6 +430,9 @@ location_table_npc = {'Mushroom': 0x1000, '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, @@ -439,15 +445,21 @@ location_table_ow = {'Flute Spot': 0x2a, '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)} -SNES_DISCONNECTED = 0 -SNES_CONNECTING = 1 -SNES_CONNECTED = 2 -SNES_ATTACHED = 3 +location_table_misc_id = {Regions.lookup_name_to_id[name] : data for name, data in location_table_misc.items()} + +class SNESState(enum.IntEnum): + SNES_DISCONNECTED = 0 + SNES_CONNECTING = 1 + SNES_CONNECTED = 2 + SNES_ATTACHED = 3 def launch_qusb2snes(ctx: Context): @@ -518,15 +530,15 @@ 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: + if ctx.snes_socket is not None and ctx.snes_state == SNESState.SNES_CONNECTED: logger.error('Already connected to snes') return recv_task = None - ctx.snes_state = SNES_CONNECTING + ctx.snes_state = SNESState.SNES_CONNECTING socket = await _snes_connect(ctx, address) ctx.snes_socket = socket - ctx.snes_state = SNES_CONNECTED + ctx.snes_state = SNESState.SNES_CONNECTED try: devices = await get_snes_devices(ctx) @@ -552,7 +564,7 @@ async def snes_connect(ctx: Context, address): "Operands": [device] } await ctx.snes_socket.send(dumps(Attach_Request)) - ctx.snes_state = SNES_ATTACHED + ctx.snes_state = SNESState.SNES_ATTACHED ctx.snes_attached_device = (devices.index(device), device) ctx.ui_node.send_connection_status(ctx) @@ -579,7 +591,7 @@ async def snes_connect(ctx: Context, address): if not ctx.snes_socket.closed: await ctx.snes_socket.close() ctx.snes_socket = None - ctx.snes_state = SNES_DISCONNECTED + ctx.snes_state = SNESState.SNES_DISCONNECTED if not ctx.snes_reconnect_address: logger.error("Error connecting to snes (%s)" % e) else: @@ -620,7 +632,7 @@ async def snes_recv_loop(ctx: Context): if socket is not None and not socket.closed: await socket.close() - ctx.snes_state = SNES_DISCONNECTED + ctx.snes_state = SNESState.SNES_DISCONNECTED ctx.snes_recv_queue = asyncio.Queue() ctx.hud_message_queue = [] ctx.ui_node.send_connection_status(ctx) @@ -636,7 +648,7 @@ async def snes_read(ctx: Context, address, size): try: await ctx.snes_request_lock.acquire() - if ctx.snes_state != 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 = { @@ -675,7 +687,8 @@ async def snes_write(ctx: Context, write_list): try: await ctx.snes_request_lock.acquire() - if ctx.snes_state != 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 False PutAddress_Request = {"Opcode": "PutAddress", "Operands": [], 'Space': 'SNES'} @@ -897,7 +910,7 @@ async def process_server_cmd(ctx: Context, args: dict): msgs = [] if ctx.locations_checked: msgs.append({"cmd": "LocationChecks", - "locations": [Regions.lookup_name_to_id[loc] for loc in ctx.locations_checked]}) + "locations": list(ctx.locations_checked)}) if ctx.locations_scouted: msgs.append({"cmd": "LocationScouts", "locations": list(ctx.locations_scouted)}) @@ -910,8 +923,8 @@ async def process_server_cmd(ctx: Context, args: dict): # This list is used to only send to the server what is reported as ACTUALLY Missing. # This also serves to allow an easy visual of what locations were already checked previously # when /missing is used for the client side view of what is missing. - ctx.items_missing = args["missing_checks"] - ctx.items_checked = args["items_checked"] + ctx.missing_locations = args["missing_locations"] + ctx.checked_locations = args["checked_locations"] elif cmd == 'ReceivedItems': start_index = args["index"] @@ -922,7 +935,7 @@ async def process_server_cmd(ctx: Context, args: dict): sync_msg = [{'cmd': 'Sync'}] if ctx.locations_checked: sync_msg.append({"cmd": "LocationChecks", - "locations": [Regions.lookup_name_to_id[loc] for loc in ctx.locations_checked]}) + "locations": list(ctx.locations_checked)}) await ctx.send_msgs(sync_msg) if start_index == len(ctx.items_received): for item in args['items']: @@ -1055,11 +1068,11 @@ class ClientCommandProcessor(CommandProcessor): for location, location_id in Regions.lookup_name_to_id.items(): if location_id < 0: continue - if location not in self.ctx.locations_checked: - if location in self.ctx.items_missing: + if location_id not in self.ctx.locations_checked: + if location_id in self.ctx.missing_locations: self.output('Missing: ' + location) count += 1 - elif self.ctx.items_checked is None or location in self.ctx.items_checked: + elif location_id in self.ctx.checked_locations: self.output('Checked: ' + location) count += 1 checked_count += 1 @@ -1078,7 +1091,7 @@ class ClientCommandProcessor(CommandProcessor): else: self.ctx.slow_mode = not self.ctx.slow_mode - logger.info(f"Setting slow mode to {self.ctx.slow_mode}") + self.output(f"Setting slow mode to {self.ctx.slow_mode}") def _cmd_web(self): if self.ctx.webui_socket_port: @@ -1086,6 +1099,16 @@ class ClientCommandProcessor(CommandProcessor): else: self.output("Web UI was never started.") + def _cmd_ready(self): + self.ctx.ready = not self.ctx.ready + if self.ctx.ready: + state = CLientStatus.CLIENT_READY + self.output("Readied up.") + else: + state = CLientStatus.CLIENT_CONNECTED + self.output("Unreadied.") + asyncio.create_task(self.ctx.send_msgs([{"cmd": "StatusUpdate", "status": state}])) + def default(self, raw: str): asyncio.create_task(self.ctx.send_msgs([{"cmd": "Say", "text": raw}])) @@ -1114,38 +1137,29 @@ async def console_loop(ctx: Context): async def track_locations(ctx: Context, roomid, roomdata): new_locations = [] - def new_check(location): - new_locations.append(Regions.lookup_name_to_id.get(location, Shops.shop_table_by_location.get(location, -1))) - ctx.locations_checked.add(location) - - check = None - if ctx.items_checked is None: - check = f'New Check: {location} ({len(ctx.locations_checked)}/{len(Regions.lookup_name_to_id)})' - else: - items_total = len(ctx.items_missing) + len(ctx.items_checked) - if location in ctx.items_missing or location in ctx.items_checked: - ctx.locations_recognized.add(location) - check = f'New Check: {location} ({len(ctx.locations_recognized)}/{items_total})' - - if check: - logger.info(check) + def new_check(location_id): + new_locations.append(location_id) + ctx.locations_checked.add(location_id) + location = ctx.location_name_getter(location_id) + logger.info(f'New Check: {location} ({len(ctx.locations_checked)}/{len(ctx.missing_locations) + len(ctx.checked_locations)})') ctx.ui_node.send_location_check(ctx, location) + try: if roomid in location_shop_ids: misc_data = await snes_read(ctx, SHOP_ADDR, (len(location_shop_order) * 3) + 5) for cnt, b in enumerate(misc_data): - my_check = Shops.shop_table_by_location_id[Shops.SHOP_ID_START + cnt] - if int(b) > 0 and my_check not in ctx.locations_checked: - new_check(my_check) + if int(b) and (Shops.SHOP_ID_START + cnt) not in ctx.locations_checked: + new_check(Shops.SHOP_ID_START + cnt) except Exception as e: logger.info(f"Exception: {e}") - for location, (loc_roomid, loc_mask) in location_table_uw.items(): + for location_id, (loc_roomid, loc_mask) in location_table_uw_id.items(): try: - if location not in ctx.locations_checked and loc_roomid == roomid and ( + + if location_id not in ctx.locations_checked and loc_roomid == roomid and ( roomdata << 4) & loc_mask != 0: - new_check(location) + new_check(location_id) except Exception as e: logger.exception(f"Exception: {e}") @@ -1153,48 +1167,51 @@ async def track_locations(ctx: Context, roomid, roomdata): ow_end = uw_end = 0 uw_unchecked = {} for location, (roomid, mask) in location_table_uw.items(): - if location not in ctx.locations_checked: - uw_unchecked[location] = (roomid, mask) + 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 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, (roomid, mask) in uw_unchecked.items(): + 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) + new_check(location_id) ow_begin = 0x82 ow_unchecked = {} - for location, screenid in location_table_ow.items(): - if location not in ctx.locations_checked: - ow_unchecked[location] = screenid + 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 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, screenid in ow_unchecked.items(): + for location_id, screenid in ow_unchecked.items(): if ow_data[screenid - ow_begin] & 0x40 != 0: - new_check(location) + new_check(location_id) - if not all(location in ctx.locations_checked for location in location_table_npc.keys()): + 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 = npc_data[0] | (npc_data[1] << 8) - for location, mask in location_table_npc.items(): - if npc_value & mask != 0 and location not in ctx.locations_checked: - new_check(location) + 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 not all(location in ctx.locations_checked for location in location_table_misc.keys()): + 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: - for location, (offset, mask) in location_table_misc.items(): + for location_id, (offset, mask) in location_table_misc_id.items(): assert (0x3c6 <= offset <= 0x3c9) - if misc_data[offset - 0x3c6] & mask != 0 and location not in ctx.locations_checked: - new_check(location) + if misc_data[offset - 0x3c6] & mask != 0 and location_id not in ctx.locations_checked: + new_check(location_id) if new_locations: @@ -1292,7 +1309,6 @@ async def game_watcher(ctx: Context): if scout_location > 0 and scout_location not in ctx.locations_scouted: ctx.locations_scouted.add(scout_location) - logger.info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}') await ctx.send_msgs([{"cmd": "LocationScouts", "locations": [scout_location]}]) await track_locations(ctx, roomid, roomdata) diff --git a/MultiServer.py b/MultiServer.py index 10d61dd0..6a0160fa 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -240,7 +240,7 @@ class Context(Node): return d def set_save(self, savedata: dict): - rom_names = savedata["rom_names"] # convert from TrackerList to List in case of ponyorm + received_items = {tuple(k): [NetworkItem(*i) for i in v] for k, v in savedata["received_items"]} self.received_items = received_items @@ -367,6 +367,7 @@ async def on_client_disconnected(ctx: Context, client: Client): async def on_client_joined(ctx: Context, client: Client): + update_client_status(ctx, client, CLientStatus.CLIENT_CONNECTED) version_str = '.'.join(str(x) for x in client.version) ctx.notify_all( f"{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has joined the game. " @@ -375,10 +376,9 @@ async def on_client_joined(ctx: Context, client: Client): ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) async def on_client_left(ctx: Context, client: Client): + update_client_status(ctx, client, CLientStatus.CLIENT_UNKNOWN) ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1)) ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc) - if ctx.commandprocessor.client == Client: - ctx.commandprocessor.client = None async def countdown(ctx: Context, timer): @@ -418,7 +418,7 @@ def get_received_items(ctx: Context, team: int, player: int) -> typing.List[Netw def tuplize_received_items(items): - return [(item.item, item.location, item.player) for item in items] + return [NetworkItem(item.item, item.location, item.player) for item in items] def send_new_items(ctx: Context): @@ -791,7 +791,7 @@ class ClientMessageProcessor(CommonCommandProcessor): locations = get_missing_checks(self.ctx, self.client) if locations: - texts = [f'Missing: {location}\n' for location in locations] + texts = [f'Missing: {get_item_name_from_id(location)}\n' for location in locations] texts.append(f"Found {len(locations)} missing location checks") self.ctx.notify_client_multiple(self.client, texts) else: @@ -914,15 +914,15 @@ class ClientMessageProcessor(CommonCommandProcessor): return False -def get_checked_checks(ctx: Context, client: Client) -> list: - return [Regions.lookup_id_to_name.get(location_id, f'Unknown Location ID: {location_id}') for +def get_checked_checks(ctx: Context, client: Client) -> typing.List[int]: + return [location_id for location_id, slot in ctx.locations if slot == client.slot and location_id in ctx.location_checks[client.team, client.slot]] -def get_missing_checks(ctx: Context, client: Client) -> list: - return [Regions.lookup_id_to_name.get(location_id, f'Unknown Location ID: {location_id}') for +def get_missing_checks(ctx: Context, client: Client) -> typing.List[int]: + return [location_id for location_id, slot in ctx.locations if slot == client.slot and location_id not in ctx.location_checks[client.team, client.slot]] @@ -1000,8 +1000,8 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): "cmd": "Connected", "team": client.team, "slot": client.slot, "players": ctx.get_players_package(), - "missing_checks": get_missing_checks(ctx, client), - "items_checked": get_checked_checks(ctx, client)}] + "missing_locations": get_missing_checks(ctx, client), + "checked_locations": get_checked_checks(ctx, client)}] items = get_received_items(ctx, client.team, client.slot) if items: reply.append({"cmd": 'ReceivedItems', "index": 0, "items": tuplize_received_items(items)}) @@ -1044,15 +1044,7 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): await ctx.send_msgs(client, [{'cmd': 'LocationInfo', 'locations': locs}]) elif cmd == 'StatusUpdate': - current = ctx.client_game_state[client.team, client.slot] - if current != CLientStatus.CLIENT_GOAL: # can't undo goal completion - if args["status"] == CLientStatus.CLIENT_GOAL: - finished_msg = f'{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has completed their goal.' - ctx.notify_all(finished_msg) - if "auto" in ctx.forfeit_mode: - forfeit_player(ctx, client.team, client.slot) - - ctx.client_game_state[client.team, client.slot] = args["status"] + update_client_status(ctx, client, args["status"]) if cmd == 'Say': if "text" not in args or type(args["text"]) is not str or not args["text"].isprintable(): @@ -1061,6 +1053,16 @@ async def process_client_cmd(ctx: Context, client: Client, args: dict): client.messageprocessor(args["text"]) +def update_client_status(ctx: Context, client: Client, new_status: CLientStatus): + current = ctx.client_game_state[client.team, client.slot] + if current != CLientStatus.CLIENT_GOAL: # can't undo goal completion + if new_status == CLientStatus.CLIENT_GOAL: + finished_msg = f'{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has completed their goal.' + ctx.notify_all(finished_msg) + if "auto" in ctx.forfeit_mode: + forfeit_player(ctx, client.team, client.slot) + + ctx.client_game_state[client.team, client.slot] = new_status class ServerCommandProcessor(CommonCommandProcessor): def __init__(self, ctx: Context): diff --git a/NetUtils.py b/NetUtils.py index 8288c00a..aa822896 100644 --- a/NetUtils.py +++ b/NetUtils.py @@ -21,7 +21,7 @@ class JSONMessagePart(typing.TypedDict, total=False): class CLientStatus(enum.IntEnum): CLIENT_UNKNOWN = 0 - # CLIENT_CONNECTED = 5 maybe? + CLIENT_CONNECTED = 5 CLIENT_READY = 10 CLIENT_PLAYING = 20 CLIENT_GOAL = 30 diff --git a/worlds/alttp/Shops.py b/worlds/alttp/Shops.py index 4c6e6871..d7cb96d5 100644 --- a/worlds/alttp/Shops.py +++ b/worlds/alttp/Shops.py @@ -164,9 +164,7 @@ def ShopSlotFill(world): blacklist_word in item_name for blacklist_word in blacklist_words)} blacklist_words.add("Bee") - locations_per_sphere = list(list(sphere).sort(key=lambda location: location.name) for sphere in world.get_spheres()) - - + locations_per_sphere = list(sorted(sphere, key=lambda location: location.name) for sphere in world.get_spheres()) # currently special care needs to be taken so that Shop.region.locations.item is identical to Shop.inventory # Potentially create Locations as needed and make inventory the only source, to prevent divergence diff --git a/worlds/alttp/Text.py b/worlds/alttp/Text.py index 0f314f4b..49a62823 100644 --- a/worlds/alttp/Text.py +++ b/worlds/alttp/Text.py @@ -281,7 +281,6 @@ junk_texts = [ "{C:GREEN}\nThere’s always\nmoney in the\nBanana Stand>", "{C:GREEN}\n \nJust walk away\n >", "{C:GREEN}\neverybody is\nlooking for\nsomething >", - "{C:GREEN}\nCandy Is Dandy\nBut liquor\nIs quicker. >", "{C:GREEN}\nSpring Ball\nare behind\nRidley >", "{C:GREEN}\nThe gnome asks\nyou to guess\nhis name. >", "{C:GREEN}\nI heard beans\non toast is a\ngreat meal. >",