diff --git a/HintedMultiServer.py b/HintedMultiServer.py new file mode 100644 index 00000000..7933dfa3 --- /dev/null +++ b/HintedMultiServer.py @@ -0,0 +1,421 @@ +import aioconsole +import argparse +import asyncio +import functools +import json +import logging +import re +import urllib.request +import websockets +import zlib +import datetime + +import Items +import Regions +from MultiClient import ReceivedItem, get_item_name_from_id, get_location_name_from_address + +class Client: + def __init__(self, socket): + self.socket = socket + self.auth = False + self.name = None + self.team = None + self.slot = None + self.send_index = 0 + self.hints_used = 0 + +class Context: + def __init__(self, host, port, password): + self.data_filename = None + self.save_filename = None + self.disable_save = False + self.players = 0 + self.rom_names = {} + self.locations = {} + self.host = host + self.port = port + self.password = password + self.server = None + self.clients = [] + self.received_items = {} + self.starttime = datetime.datetime.now() + +def get_room_info(ctx : Context): + return { + 'password': ctx.password is not None, + 'slots': ctx.players, + 'players': [(client.name, client.team, client.slot) for client in ctx.clients if client.auth] + } + +def same_name(lhs, rhs): + return lhs.lower() == rhs.lower() + +def same_team(lhs, rhs): + return (type(lhs) is type(rhs)) and ((not lhs and not rhs) or (lhs.lower() == rhs.lower())) + +async def send_msgs(websocket, msgs): + if not websocket or not websocket.open or websocket.closed: + return + try: + await websocket.send(json.dumps(msgs)) + except websockets.ConnectionClosed: + pass + +def broadcast_all(ctx : Context, msgs): + for client in ctx.clients: + if client.auth: + asyncio.create_task(send_msgs(client.socket, msgs)) + +def broadcast_team(ctx : Context, team, msgs): + for client in ctx.clients: + if client.auth and same_team(client.team, team): + asyncio.create_task(send_msgs(client.socket, msgs)) + +def notify_all(ctx : Context, text): + print("Notice (all): %s" % text) + broadcast_all(ctx, [['Print', text]]) + +def notify_team(ctx : Context, team : str, text : str): + print("Team notice (%s): %s" % ("Default" if not team else team, text)) + broadcast_team(ctx, team, [['Print', text]]) + +def notify_client(client : Client, text : str): + if not client.auth: + return + print("Player notice (%s): %s" % (client.name, text)) + asyncio.create_task(send_msgs(client.socket, [['Print', text]])) + +async def server(websocket, path, ctx : Context): + client = Client(websocket) + ctx.clients.append(client) + + try: + await on_client_connected(ctx, client) + async for data in websocket: + for msg in json.loads(data): + if len(msg) == 1: + cmd = msg + args = None + else: + cmd = msg[0] + args = msg[1] + await process_client_cmd(ctx, client, cmd, args) + except Exception as e: + if not isinstance(e, websockets.WebSocketException): + logging.exception(e) + finally: + await on_client_disconnected(ctx, client) + ctx.clients.remove(client) + +async def on_client_connected(ctx : Context, client : Client): + await send_msgs(client.socket, [['RoomInfo', get_room_info(ctx)]]) + +async def on_client_disconnected(ctx : Context, client : Client): + if client.auth: + await on_client_left(ctx, client) + +async def on_client_joined(ctx : Context, client : Client): + notify_all(ctx, "%s has joined the game as player %d for %s" % (client.name, client.slot, "the default team" if not client.team else "team %s" % client.team)) + +async def on_client_left(ctx : Context, client : Client): + notify_all(ctx, "%s (Player %d, %s) has left the game" % (client.name, client.slot, "Default team" if not client.team else "Team %s" % client.team)) + +def get_connected_players_string(ctx : Context): + auth_clients = [c for c in ctx.clients if c.auth] + if not auth_clients: + return 'No player connected' + + auth_clients.sort(key=lambda c: ('' if not c.team else c.team.lower(), c.slot)) + current_team = 0 + text = '' + for c in auth_clients: + if c.team != current_team: + text += '::' + ('default team' if not c.team else c.team) + ':: ' + current_team = c.team + text += '%d:%s ' % (c.slot, c.name) + return 'Connected players: ' + text[:-1] + +def get_player_name_in_team(ctx : Context, team, slot): + for client in ctx.clients: + if client.auth and same_team(team, client.team) and client.slot == slot: + return client.name + return "Player %d" % slot + +def get_client_from_name(ctx : Context, name): + for client in ctx.clients: + if client.auth and same_name(name, client.name): + return client + return None + +def get_received_items(ctx : Context, team, player): + for (c_team, c_id), items in ctx.received_items.items(): + if c_id == player and same_team(c_team, team): + return items + ctx.received_items[(team, player)] = [] + return ctx.received_items[(team, player)] + +def tuplize_received_items(items): + return [(item.item, item.location, item.player_id, item.player_name) for item in items] + +def send_new_items(ctx : Context): + for client in ctx.clients: + if not client.auth: + continue + items = get_received_items(ctx, client.team, client.slot) + if len(items) > client.send_index: + asyncio.create_task(send_msgs(client.socket, [['ReceivedItems', (client.send_index, tuplize_received_items(items)[client.send_index:])]])) + client.send_index = len(items) + +def forfeit_player(ctx : Context, team, slot, name): + all_locations = [values[0] for values in Regions.location_table.values() if type(values[0]) is int] + notify_all(ctx, "%s (Player %d) in team %s has forfeited" % (name, slot, team if team else 'default')) + register_location_checks(ctx, name, team, slot, all_locations) + +def register_location_checks(ctx : Context, name, team, slot, locations): + found_items = False + for location in locations: + if (location, slot) in ctx.locations: + target_item, target_player = ctx.locations[(location, slot)] + if target_player != slot: + found = False + recvd_items = get_received_items(ctx, team, target_player) + for recvd_item in recvd_items: + if recvd_item.location == location and recvd_item.player_id == slot: + found = True + break + if not found: + new_item = ReceivedItem(target_item, location, slot, name) + recvd_items.append(new_item) + target_player_name = get_player_name_in_team(ctx, team, target_player) + broadcast_team(ctx, team, [['ItemSent', (name, target_player_name, target_item, location)]]) + print('(%s) %s sent %s to %s (%s)' % (team if team else 'Team', name, get_item_name_from_id(target_item), target_player_name, get_location_name_from_address(location))) + found_items = True + send_new_items(ctx) + + if found_items and not ctx.disable_save: + try: + with open(ctx.save_filename, "wb") as f: + jsonstr = json.dumps((ctx.players, + [(k, v) for k, v in ctx.rom_names.items()], + [(k, [i.__dict__ for i in v]) for k, v in ctx.received_items.items()])) + f.write(zlib.compress(jsonstr.encode("utf-8"))) + except Exception as e: + logging.exception(e) + +async def process_client_cmd(ctx : Context, client : Client, cmd, args): + if type(cmd) is not str: + await send_msgs(client.socket, [['InvalidCmd']]) + return + + if cmd == 'Connect': + if not args or type(args) is not dict or \ + 'password' not in args or type(args['password']) not in [str, type(None)] or \ + 'name' not in args or type(args['name']) is not str or \ + 'team' not in args or type(args['team']) not in [str, type(None)] or \ + 'slot' not in args or type(args['slot']) not in [int, type(None)]: + await send_msgs(client.socket, [['InvalidArguments', 'Connect']]) + return + + errors = set() + if ctx.password is not None and ('password' not in args or args['password'] != ctx.password): + errors.add('InvalidPassword') + + if 'name' not in args or not args['name'] or not re.match(r'\w{1,10}', args['name']): + errors.add('InvalidName') + elif any([same_name(c.name, args['name']) for c in ctx.clients if c.auth]): + errors.add('NameAlreadyTaken') + else: + client.name = args['name'] + + if 'team' in args and args['team'] is not None and not re.match(r'\w{1,15}', args['team']): + errors.add('InvalidTeam') + else: + client.team = args['team'] if 'team' in args else None + + if 'slot' in args and any([c.slot == args['slot'] for c in ctx.clients if c.auth and same_team(c.team, client.team)]): + errors.add('SlotAlreadyTaken') + elif 'slot' not in args or not args['slot']: + for slot in range(1, ctx.players + 1): + if slot not in [c.slot for c in ctx.clients if c.auth and same_team(c.team, client.team)]: + client.slot = slot + break + elif slot == ctx.players: + errors.add('SlotAlreadyTaken') + elif args['slot'] not in range(1, ctx.players + 1): + errors.add('InvalidSlot') + else: + client.slot = args['slot'] + + if errors: + client.name = None + client.team = None + client.slot = None + await send_msgs(client.socket, [['ConnectionRefused', list(errors)]]) + else: + client.auth = True + reply = [['Connected', ctx.rom_names[client.slot]]] + items = get_received_items(ctx, client.team, client.slot) + if items: + reply.append(['ReceivedItems', (0, tuplize_received_items(items))]) + client.send_index = len(items) + await send_msgs(client.socket, reply) + await on_client_joined(ctx, client) + + if not client.auth: + return + + if cmd == 'Sync': + items = get_received_items(ctx, client.team, client.slot) + if items: + client.send_index = len(items) + await send_msgs(client.socket, ['ReceivedItems', (0, tuplize_received_items(items))]) + + if cmd == 'LocationChecks': + if type(args) is not list: + await send_msgs(client.socket, [['InvalidArguments', 'LocationChecks']]) + return + register_location_checks(ctx, client.name, client.team, client.slot, args) + + if cmd == 'Say': + if type(args) is not str or not args.isprintable(): + await send_msgs(client.socket, [['InvalidArguments', 'Say']]) + return + + notify_all(ctx, client.name + ': ' + args) + + if args[:8] == '!players': + notify_all(ctx, get_connected_players_string(ctx)) + elif args[:8] == '!forfeit': + forfeit_player(ctx, client.team, client.slot, client.name) + elif args.startswith("!hint"): + pass +def set_password(ctx : Context, password): + ctx.password = password + print('Password set to ' + password if password is not None else 'Password disabled') + +async def console(ctx : Context): + while True: + input = await aioconsole.ainput() + + command = input.split() + if not command: + continue + + if command[0] == '/exit': + ctx.server.ws_server.close() + break + try: + if command[0] == '/players': + print(get_connected_players_string(ctx)) + elif command[0] == '/password': + set_password(ctx, command[1] if len(command) > 1 else None) + elif command[0] == '/kick' and len(command) > 1: + client = get_client_from_name(ctx, command[1]) + if client and client.socket and not client.socket.closed: + await client.socket.close() + + elif command[0] == '/forfeitslot' and len(command) == 3 and command[2].isdigit(): + team = command[1] if command[1] != 'default' else None + slot = int(command[2]) + name = get_player_name_in_team(ctx, team, slot) + forfeit_player(ctx, team, slot, name) + elif command[0] == '/forfeitplayer' and len(command) > 1: + client = get_client_from_name(ctx, command[1]) + if client: + forfeit_player(ctx, client.team, client.slot, client.name) + elif command[0] == '/senditem' and len(command) > 2: + [(player, item)] = re.findall(r'\S* (\S*) (.*)', input) + if item in Items.item_table: + client = get_client_from_name(ctx, player) + if client: + new_item = ReceivedItem(Items.item_table[item][3], "cheat console", 0, "server") + get_received_items(ctx, client.team, client.slot).append(new_item) + notify_all(ctx, 'Cheat console: sending "' + item + '" to ' + client.name) + send_new_items(ctx) + else: + print("Unknown item: " + item) + elif command[0] == '/hint': + if len(command) > 2: + player_number = int(command[1]) + item = " ".join(command[2:]) + if item in Items.item_table: + seeked_item_id = Items.item_table[item][3] + for check, result in ctx.locations.items(): + item_id, receiving_player = result + if receiving_player == player_number and item_id == seeked_item_id: + location_id, finding_player = check + notify_all(ctx, f"[Hint]: P{player_number}'s {item} can be found in {get_location_name_from_address(location_id)} in " + f"P{finding_player}'s World") + + else: + print("Unknown item: " + item) + else: + print("Use /hint {playernumber} {itemname}\nFor example /hint 1 Lamp") + elif command[0][0] != '/': + notify_all(ctx, '[Server]: ' + input) + except Exception as e: + import traceback + traceback.print_exc() +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--host', default=None) + parser.add_argument('--port', default=38281, type=int) + parser.add_argument('--password', default=None) + parser.add_argument('--multidata', default=None) + parser.add_argument('--savefile', default=None) + parser.add_argument('--disable_save', default=False, action='store_true') + parser.add_argument('--hint_timer', default=-1) + args = parser.parse_args() + + ctx = Context(args.host, args.port, args.password) + + ctx.data_filename = args.multidata + + try: + if not ctx.data_filename: + import tkinter + import tkinter.filedialog + root = tkinter.Tk() + root.withdraw() + ctx.data_filename = tkinter.filedialog.askopenfilename(filetypes=(("Multiworld data","*multidata"),)) + + with open(ctx.data_filename, 'rb') as f: + jsonobj = json.loads(zlib.decompress(f.read()).decode("utf-8")) + ctx.players = jsonobj[0] + ctx.rom_names = {k: v for k, v in jsonobj[1]} + ctx.locations = {tuple(k): tuple(v) for k, v in jsonobj[2]} + except Exception as e: + print('Failed to read multiworld data (%s)' % e) + return + + ip = urllib.request.urlopen('https://v4.ident.me').read().decode('utf8') if not ctx.host else ctx.host + print('Hosting game of %d players (%s) at %s:%d' % (ctx.players, 'No password' if not ctx.password else 'Password: %s' % ctx.password, ip, ctx.port)) + + ctx.disable_save = args.disable_save + if not ctx.disable_save: + if not ctx.save_filename: + ctx.save_filename = (ctx.data_filename[:-9] if ctx.data_filename[-9:] == 'multidata' else (ctx.data_filename + '_')) + 'multisave' + try: + with open(ctx.save_filename, 'rb') as f: + jsonobj = json.loads(zlib.decompress(f.read()).decode("utf-8")) + players = jsonobj[0] + rom_names = {k: v for k, v in jsonobj[1]} + received_items = {tuple(k): [ReceivedItem(**i) for i in v] for k, v in jsonobj[2]} + if players != ctx.players or rom_names != ctx.rom_names: + raise Exception('Save file mismatch, will start a new game') + ctx.received_items = received_items + print('Loaded save file with %d received items for %d players' % (sum([len(p) for p in received_items.values()]), len(received_items))) + except FileNotFoundError: + print('No save data found, starting a new game') + except Exception as e: + print(e) + + ctx.server = websockets.serve(functools.partial(server,ctx=ctx), ctx.host, ctx.port, ping_timeout=None, ping_interval=None) + await ctx.server + await console(ctx) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) + loop.run_until_complete(asyncio.gather(*asyncio.Task.all_tasks())) + loop.close() diff --git a/MultiMystery.py b/MultiMystery.py new file mode 100644 index 00000000..76eb81b5 --- /dev/null +++ b/MultiMystery.py @@ -0,0 +1,118 @@ +__author__ = "Berserker55" # you can find me on the ALTTP Randomizer Discord +__version__ = 1.4 + +""" +This script launches a Multiplayer "Multiworld" Mystery Game + +.yaml files for all participating players should be placed in a /Players folder. +For every player a mystery game is rolled and a ROM created. +After generation the server is automatically launched. +It is still up to the host to forward the correct port (38281 by default) and distribute the roms to the players. +Regular Mystery has to work for this first, such as a ALTTP Base ROM and Enemizer Setup. +A guide can be found here: https://docs.google.com/document/d/19FoqUkuyStMqhOq8uGiocskMo1KMjOW4nEeG81xrKoI/edit +This script itself should be placed within the Bonta Multiworld folder, that you download in step 1 +""" + +####config#### +#location of your Enemizer CLI, available here: https://github.com/Bonta0/Enemizer/releases +enemizer_location:str = "EnemizerCLI/EnemizerCLI.Core.exe" + +#Where to place the resulting files +outputpath:str = "MultiMystery" + +#automatically launches {player_name}.yaml's ROM file using the OS's default program once generation completes. (likely your emulator) +#does nothing if the name is not found +#example: player_name = "Berserker" +player_name:str = "" + +#Zip the resulting roms +#0 -> Don't +#1 -> Create a zip +#2 -> Create a zip and delete the ROMs that will be in it, except the hosts (requires player_name to be set correctly) +zip_roms:int = 1 + +#create a spoiler file +create_spoiler:bool = True + +#folder from which the player yaml files are pulled from +player_files_folder:str = "Players" + +#Version of python to use for Bonta Multiworld. Probably leave this as is, if you don't know what this does. +#can be tagged for bitness, for example "3.8-32" would be latest installed 3.8 on 32 bits +py_version:str = "3.7" +####end of config#### + +import os +import subprocess +import sys + +def feedback(text:str): + print(text) + input("Press Enter to ignore and probably crash.") + + +if __name__ == "__main__": + if not os.path.exists(enemizer_location): + feedback(f"Enemizer not found at {enemizer_location}, please adjust the path in MultiMystery.py's config or put Enemizer in the default location.") + if not os.path.exists("Zelda no Densetsu - Kamigami no Triforce (Japan).sfc"): + feedback("Base rom is expected as Zelda no Densetsu - Kamigami no Triforce (Japan).sfc in the Multiworld root folder please place/rename it there.") + player_files = [] + os.makedirs(player_files_folder, exist_ok=True) + for file in os.listdir(player_files_folder): + if file.lower().endswith(".yaml"): + player_files.append(file) + print(f"Player {file[:-5]} found.") + if len(player_files) == 0: + feedback(f"No player files found. Please put them in a {player_files_folder} folder.") + player_string = "" + + for i,file in enumerate(player_files): + player_string += f"--p{i+1} {os.path.join(player_files_folder, file)} " + + player_names = list(file[:-5] for file in player_files) + + command = f"py -{py_version} Mystery.py --multi {len(player_files)} {player_string} " \ + f"--names {','.join(player_names)} --enemizercli {enemizer_location} " \ + f"--outputpath {outputpath}" + " --create_spoiler" if create_spoiler else "" + print(command) + import time + start = time.perf_counter() + text = subprocess.check_output(command, shell=True).decode() + print(f"Took {time.perf_counter()-start:3} seconds to generate seed.") + seedname = "" + + for segment in text.split(): + if segment.startswith("M"): + seedname = segment + break + + multidataname = f"ER_{seedname}_multidata" + + romfilename = "" + if player_name: + try: + index = player_names.index(player_name) + except IndexError: + print(f"Could not find Player {player_name}") + else: + romfilename = os.path.join(outputpath, f"ER_{seedname}_P{index+1}_{player_name}.sfc") + import webbrowser + if os.path.exists(romfilename): + print(f"Launching ROM file {romfilename}") + webbrowser.open(romfilename) + + if zip_roms: + zipname = os.path.join(outputpath, f"ER_{seedname}.zip") + print(f"Creating zipfile {zipname}") + import zipfile + with zipfile.ZipFile(zipname, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zf: + for file in os.listdir(outputpath): + if file.endswith(".sfc") and seedname in file: + zf.write(os.path.join(outputpath, file), file) + print(f"Packed {file} into zipfile {zipname}") + if zip_roms == 2 and player_name.lower() not in file.lower(): + os.remove(file) + print(f"Removed file {file} that is now present in the zipfile") + + serverfile = "HintedMultiServer.py" if os.path.exists("HintedMultiServer.py") else "MultiServer.py" + subprocess.call(f"py -{py_version} {serverfile} --multidata {os.path.join(outputpath, multidataname)}") diff --git a/Mystery.py b/Mystery.py index 0c5c848c..0da5ce26 100644 --- a/Mystery.py +++ b/Mystery.py @@ -65,14 +65,17 @@ def main(): path = getattr(args, f'p{player}') if path: if path not in weights_cache: - weights_cache[path] = get_weights(path) + try: + weights_cache[path] = get_weights(path) + except: + raise ValueError(f"File {path} is destroyed. Please fix your yaml.") print(f"P{player} Weights: {path} >> {weights_cache[path]['description']}") erargs = parse_arguments(['--multi', str(args.multi)]) erargs.seed = seed erargs.names = args.names erargs.create_spoiler = args.create_spoiler - erargs.race = True + erargs.race = False erargs.outputname = seedname erargs.outputpath = args.outputpath @@ -86,10 +89,13 @@ def main(): for player in range(1, args.multi + 1): path = getattr(args, f'p{player}') if getattr(args, f'p{player}') else args.weights if path: - settings = settings_cache[path] if settings_cache[path] else roll_settings(weights_cache[path]) - for k, v in vars(settings).items(): - if v is not None: - getattr(erargs, k)[player] = v + try: + settings = settings_cache[path] if settings_cache[path] else roll_settings(weights_cache[path]) + for k, v in vars(settings).items(): + if v is not None: + getattr(erargs, k)[player] = v + except: + raise ValueError(f"File {path} is destroyed. Please fix your yaml.") else: raise RuntimeError(f'No weights specified for player {player}')