From 14e24037a568655e308c338c16c517246dda9f13 Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Sun, 9 Feb 2020 05:28:48 +0100 Subject: [PATCH] =?UTF-8?q?=C3=AEmplement=20optional=20hint=20system=20(de?= =?UTF-8?q?faults=20to=20off)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MultiMystery.py | 81 +++++++++++++---------------------- MultiServer.py | 110 ++++++++++++++++++++++++++++++++++++------------ Mystery.py | 9 +--- Utils.py | 8 ++++ host.yaml | 37 ++++++++++++++++ 5 files changed, 160 insertions(+), 85 deletions(-) create mode 100644 host.yaml diff --git a/MultiMystery.py b/MultiMystery.py index addcf249..ce99b28e 100644 --- a/MultiMystery.py +++ b/MultiMystery.py @@ -1,5 +1,5 @@ __author__ = "Berserker55" # you can find me on the ALTTP Randomizer Discord -__version__ = 1.5 +__version__ = 1.6 """ This script launches a Multiplayer "Multiworld" Mystery Game @@ -10,42 +10,9 @@ 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 +Configuration can be found in host.yaml """ -####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 - -#create roms as race coms -race:bool= False - -#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 -#special case: None -> use the python which was used to launch this file. -py_version:str = None -####end of config#### - import os import subprocess import sys @@ -57,31 +24,43 @@ def feedback(text:str): if __name__ == "__main__": try: - if not py_version: - py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + print(f"{__author__}'s MultiMystery Launcher V{__version__}") import ModuleUpdate ModuleUpdate.update() - print(f"{__author__}'s MultiMystery Launcher V{__version__}") - 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.") + from Utils import parse_yaml + + multi_mystery_options = parse_yaml(open("host.yaml").read())["multi_mystery_options"] + output_path = multi_mystery_options["output_path"] + enemizer_path = multi_mystery_options["enemizer_path"] + player_files_path = multi_mystery_options["player_files_path"] + race = multi_mystery_options["race"] + create_spoiler = multi_mystery_options["create_spoiler"] + zip_roms = multi_mystery_options["zip_roms"] + player_name = multi_mystery_options["player_name"] + + + py_version = f"{sys.version_info.major}.{sys.version_info.minor}" + + if not os.path.exists(enemizer_path): + feedback(f"Enemizer not found at {enemizer_path}, 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): + os.makedirs(player_files_path, exist_ok=True) + for file in os.listdir(player_files_path): if file.lower().endswith(".yaml"): player_files.append(file) print(f"Player {file[:-5]} found.") player_count = len(player_files) if player_count == 0: - feedback(f"No player files found. Please put them in a {player_files_folder} folder.") + feedback(f"No player files found. Please put them in a {player_files_path} folder.") else: print(player_count, "Players found.") player_string = "" for i,file in enumerate(player_files): - player_string += f"--p{i+1} {os.path.join(player_files_folder, file)} " + player_string += f"--p{i+1} {os.path.join(player_files_path, file)} " player_names = list(file[:-5] for file in player_files) @@ -93,8 +72,8 @@ if __name__ == "__main__": basemysterycommand = f"py -{py_version} Mystery.py" #source command = f"{basemysterycommand} --multi {len(player_files)} {player_string} " \ - f"--names {','.join(player_names)} --enemizercli {enemizer_location} " \ - f"--outputpath {outputpath}" + " --create_spoiler" if create_spoiler else "" + " --race" if race else "" + f"--names {','.join(player_names)} --enemizercli {enemizer_path} " \ + f"--outputpath {output_path}" + " --create_spoiler" if create_spoiler else "" + " --race" if race else "" print(command) import time start = time.perf_counter() @@ -116,20 +95,20 @@ if __name__ == "__main__": 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") + romfilename = os.path.join(output_path, 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") + zipname = os.path.join(output_path, 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): + for file in os.listdir(output_path): if file.endswith(".sfc") and seedname in file: - zf.write(os.path.join(outputpath, file), file) + zf.write(os.path.join(output_path, file), file) print(f"Packed {file} into zipfile {zipname}") if zip_roms == 2 and player_name.lower() not in file.lower(): os.remove(file) @@ -143,7 +122,7 @@ if __name__ == "__main__": baseservercommand = f"py -{py_version} MultiServer.py" # source #don't have a mac to test that. If you try to run compiled on mac, good luck. - subprocess.call(f"{baseservercommand} --multidata {os.path.join(outputpath, multidataname)}") + subprocess.call(f"{baseservercommand} --multidata {os.path.join(output_path, multidataname)}") except: import traceback traceback.print_exc() diff --git a/MultiServer.py b/MultiServer.py index 38f1ed88..4d0fd241 100644 --- a/MultiServer.py +++ b/MultiServer.py @@ -7,6 +7,7 @@ import re import shlex import urllib.request import zlib +import collections import ModuleUpdate ModuleUpdate.update() @@ -16,6 +17,7 @@ import aioconsole import Items import Regions +import Utils from MultiClient import ReceivedItem, get_item_name_from_id, get_location_name_from_address class Client: @@ -28,7 +30,7 @@ class Client: self.send_index = 0 class Context: - def __init__(self, host, port, password): + def __init__(self, host:str, port:int, password:str, location_check_points:int, hint_cost:int): self.data_filename = None self.save_filename = None self.disable_save = False @@ -43,6 +45,29 @@ class Context: self.countdown_timer = 0 self.clients = [] self.received_items = {} + self.location_checks = collections.defaultdict(lambda: 0) + self.hint_cost = hint_cost + self.location_check_points = location_check_points + self.hints_used = collections.defaultdict(lambda: 0) + + def get_save(self) -> dict: + return { + "rom_names": list(self.rom_names.items()), + "received_items": tuple((k, [i.__dict__ for i in v]) for k, v in self.received_items.items()), + "hints_used" : tuple((key,value) for key, value in self.hints_used.items()), + "location_checks" : tuple((key,value) for key, value in self.location_checks.items()) + } + + def set_save(self, savedata: dict): + rom_names = savedata["rom_names"] + received_items = {tuple(k): [ReceivedItem(**i) for i in v] for k, v in savedata["received_items"]} + if not all([self.rom_names[tuple(rom)] == (team, slot) for rom, (team, slot) in rom_names]): + raise Exception('Save file mismatch, will start a new game') + self.received_items = received_items + self.hints_used.update({tuple(key): value for key, value in savedata["hints_used"]}) + self.location_checks.update({tuple(key): value for key, value in savedata["location_checks"]}) + logging.info(f'Loaded save file with {sum([len(p) for p in received_items.values()])} received items ' + f'for {len(received_items)} players') async def send_msgs(websocket, msgs): if not websocket or not websocket.open or websocket.closed: @@ -174,7 +199,9 @@ def register_location_checks(ctx : Context, team, slot, locations): if recvd_item.location == location and recvd_item.player == slot: found = True break + if not found: + ctx.location_checks[team, slot] += 1 new_item = ReceivedItem(target_item, location, slot) recvd_items.append(new_item) if slot != target_player: @@ -183,15 +210,32 @@ def register_location_checks(ctx : Context, team, slot, locations): found_items = True send_new_items(ctx) - if found_items and not ctx.disable_save: + if found_items: + save(ctx) + +def save(ctx:Context): + if not ctx.disable_save: try: with open(ctx.save_filename, "wb") as f: - jsonstr = json.dumps((list(ctx.rom_names.items()), - [(k, [i.__dict__ for i in v]) for k, v in ctx.received_items.items()])) + jsonstr = json.dumps(ctx.get_save()) f.write(zlib.compress(jsonstr.encode("utf-8"))) except Exception as e: logging.exception(e) +def hint(ctx:Context, team, slot, item:str): + found = 0 + seeked_item_id = Items.item_table[item][3] + for check, result in ctx.locations.items(): + item_id, receiving_player = result + if receiving_player == slot and item_id == seeked_item_id: + location_id, finding_player = check + hint = f"[Hint]: {ctx.player_names[(team, slot)]}'s {item} can be found at " \ + f"{get_location_name_from_address(location_id)} in {ctx.player_names[team, finding_player]}'s World" + notify_team(ctx, team, hint) + found += 1 + + return found + async def process_client_cmd(ctx : Context, client : Client, cmd, args): if type(cmd) is not str: await send_msgs(client.socket, [['InvalidCmd']]) @@ -277,15 +321,37 @@ async def process_client_cmd(ctx : Context, client : Client, cmd, args): if args.startswith('!players'): notify_all(ctx, get_connected_players_string(ctx)) - if args.startswith('!forfeit'): + elif args.startswith('!forfeit'): forfeit_player(ctx, client.team, client.slot) - if args.startswith('!countdown'): + elif args.startswith('!countdown'): try: timer = int(args.split()[1]) except (IndexError, ValueError): timer = 10 asyncio.create_task(countdown(ctx, timer)) + elif args.startswith("!hint"): + points_available = ctx.location_check_points * ctx.location_checks[client.team, client.slot] - ctx.hint_cost*ctx.hints_used[client.team, client.slot] + itemname = args[6:] + if not itemname: + notify_client(client, "Use !hint {itemname}. For example !hint Lamp. " + f"A hint costs {ctx.hint_cost} points.\n" + f"You have {points_available} points.") + elif itemname in Items.item_table: + if ctx.hint_cost: can_pay = points_available // ctx.hint_cost >= 1 + else: can_pay = True + if can_pay: + found = hint(ctx, client.team, client.slot, itemname) + ctx.hints_used[client.team, client.slot] += found + if not found: + notify_client(client, "No items found, points refunded.") + else: + save(ctx) + else: + notify_client(client, f"You can't afford the hint. " + f"You have {points_available} points and need {ctx.hint_cost}") + else: + notify_client(client, f'Item "{itemname}" not found.') def set_password(ctx : Context, password): ctx.password = password logging.warning('Password set to ' + password if password is not None else 'Password disabled') @@ -339,21 +405,13 @@ async def console(ctx : Context): else: logging.warning("Unknown item: " + item) if command[0] == '/hint': - for (team,slot), name in ctx.player_names.items(): + for (team, slot), name in ctx.player_names.items(): if len(command) == 1: - print("Use /hint {Playername} {itemname}\nFor example /hint Berserker Lamp") + logging.info("Use /hint {Playername} {itemname}\nFor example /hint Berserker Lamp") elif name.lower() == command[1].lower(): 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 == slot and item_id == seeked_item_id: - location_id, finding_player = check - name_finder = ctx.player_names[team, finding_player] - hint = f"[Hint]: {name}'s {item} can be found at " \ - f"{get_location_name_from_address(location_id)} in {name_finder}'s World" - notify_team(ctx, team, hint) + hint(ctx, team, slot, item) else: logging.warning("Unknown item: " + item) if command[0][0] != '/': @@ -371,11 +429,16 @@ async def main(): parser.add_argument('--savefile', default=None) parser.add_argument('--disable_save', default=False, action='store_true') parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical']) + parser.add_argument('--location_check_points', default=1, type=int) + parser.add_argument('--hint_cost', default=1000, type=int) args = parser.parse_args() - + file_options = Utils.parse_yaml(open("host.yaml").read())["server_options"] + for key, value in file_options.items(): + if value is not None: + setattr(args, key, value) logging.basicConfig(format='[%(asctime)s] %(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO)) - ctx = Context(args.host, args.port, args.password) + ctx = Context(args.host, args.port, args.password, args.location_check_points, args.hint_cost) ctx.data_filename = args.multidata @@ -418,16 +481,11 @@ async def main(): try: with open(ctx.save_filename, 'rb') as f: jsonobj = json.loads(zlib.decompress(f.read()).decode("utf-8")) - rom_names = jsonobj[0] - received_items = {tuple(k): [ReceivedItem(**i) for i in v] for k, v in jsonobj[1]} - if not all([ctx.rom_names[tuple(rom)] == (team, slot) for rom, (team, slot) in rom_names]): - raise Exception('Save file mismatch, will start a new game') - ctx.received_items = received_items - logging.info('Loaded save file with %d received items for %d players' % (sum([len(p) for p in received_items.values()]), len(received_items))) + ctx.set_save(jsonobj) except FileNotFoundError: logging.error('No save data found, starting a new game') except Exception as e: - logging.info(e) + logging.exception(e) ctx.server = websockets.serve(functools.partial(server,ctx=ctx), ctx.host, ctx.port, ping_timeout=None, ping_interval=None) await ctx.server diff --git a/Mystery.py b/Mystery.py index 5da15a85..e7cb2704 100644 --- a/Mystery.py +++ b/Mystery.py @@ -10,18 +10,11 @@ import ModuleUpdate ModuleUpdate.update() -from yaml import load - -try: - from yaml import CLoader as Loader -except ImportError: - from yaml import Loader - +from Utils import parse_yaml from Rom import get_sprite_from_name from EntranceRandomizer import parse_arguments from Main import main as ERmain -parse_yaml = functools.partial(load, Loader=Loader) def main(): diff --git a/Utils.py b/Utils.py index 53744ffa..e04c51ad 100644 --- a/Utils.py +++ b/Utils.py @@ -127,3 +127,11 @@ def make_new_base2current(old_rom='Zelda no Densetsu - Kamigami no Triforce (Jap basemd5 = hashlib.md5() basemd5.update(new_rom_data) return "New Rom Hash: " + basemd5.hexdigest() + +from yaml import load +import functools + +try: from yaml import CLoader as Loader +except ImportError: from yaml import Loader + +parse_yaml = functools.partial(load, Loader=Loader) \ No newline at end of file diff --git a/host.yaml b/host.yaml new file mode 100644 index 00000000..f94298e9 --- /dev/null +++ b/host.yaml @@ -0,0 +1,37 @@ +#options for MultiServer +#null means nothing, for the server this means to default the value +#these overwrite command line arguments! +server_options: + host: null + port: null + password: null + multidata: null + savefile: null + disable_save: null + loglevel: null +#Client hint system +#points given to player for each acquired item + location_check_points: 1 +#point cost to receive a hint via !hint for players + hint_cost: 1000 +#options for MultiMystery.py +multi_mystery_options: +#Where to place the resulting files + output_path: "MultiMystery" +#location of your Enemizer CLI, available here: https://github.com/Bonta0/Enemizer/releases + enemizer_path: "EnemizerCLI/EnemizerCLI.Core.exe" +#folder from which the player yaml files are pulled from + player_files_path: "Players" +#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: "" # the hosts name +#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: 1 +#create a spoiler file + create_spoiler: 1 +#create roms flagged as race roms + race: 0 \ No newline at end of file