From bc028a63cd1ff14fd0d2635d57c42a2e100e997f Mon Sep 17 00:00:00 2001 From: Fabian Dill Date: Mon, 24 May 2021 12:49:01 +0200 Subject: [PATCH] first version of a Factorio Graphical Client --- FactorioClientGUI.py | 342 +++++++++++++++++++++++++++++++++++++ factorio_client_setup.py | 138 +++++++++++++++ factorio_inno_setup_38.iss | 80 +++++++++ 3 files changed, 560 insertions(+) create mode 100644 FactorioClientGUI.py create mode 100644 factorio_client_setup.py create mode 100644 factorio_inno_setup_38.iss diff --git a/FactorioClientGUI.py b/FactorioClientGUI.py new file mode 100644 index 00000000..3c0b85fb --- /dev/null +++ b/FactorioClientGUI.py @@ -0,0 +1,342 @@ +import os +import logging +logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, filename="log.txt", filemode="w") +import json +import string +os.environ["KIVY_NO_CONSOLELOG"] = "1" +os.environ["KIVY_NO_FILELOG"] = "1" +os.environ["KIVY_NO_ARGS"] = "1" +from concurrent.futures import ThreadPoolExecutor + + +import asyncio +from queue import Queue +from CommonClient import CommonContext, server_loop, ClientCommandProcessor, logger +from MultiServer import mark_raw + +import Utils +import random +from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus + +from worlds.factorio.Technologies import lookup_id_to_name + +rcon_port = 24242 +rcon_password = ''.join(random.choice(string.ascii_letters) for x in range(32)) +save_name = "Archipelago" + + + +options = Utils.get_options() +executable = options["factorio_options"]["executable"] +bin_dir = os.path.dirname(executable) +if not os.path.isdir(bin_dir): + raise FileNotFoundError(bin_dir) +if not os.path.exists(executable): + if os.path.exists(executable + ".exe"): + executable = executable + ".exe" + else: + raise FileNotFoundError(executable) + +import sys +server_args = (save_name, "--rcon-port", rcon_port, "--rcon-password", rcon_password, *sys.argv[1:]) + +threadpool = ThreadPoolExecutor(10) + + +class FactorioCommandProcessor(ClientCommandProcessor): + @mark_raw + def _cmd_factorio(self, text: str) -> bool: + """Send the following command to the bound Factorio Server.""" + if self.ctx.rcon_client: + result = self.ctx.rcon_client.send_command(text) + if result: + self.output(result) + return True + return False + + def _cmd_connect(self, address: str = "") -> bool: + """Connect to a MultiWorld Server""" + if not self.ctx.auth: + self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.") + return super(FactorioCommandProcessor, self)._cmd_connect(address) + + +class FactorioContext(CommonContext): + command_processor = FactorioCommandProcessor + + def __init__(self, *args, **kwargs): + super(FactorioContext, self).__init__(*args, **kwargs) + self.send_index = 0 + self.rcon_client = None + self.raw_json_text_parser = RawJSONtoTextParser(self) + + async def server_auth(self, password_requested): + if password_requested and not self.password: + await super(FactorioContext, self).server_auth(password_requested) + + await self.send_msgs([{"cmd": 'Connect', + 'password': self.password, 'name': self.auth, 'version': Utils._version_tuple, + 'tags': ['AP'], + 'uuid': Utils.get_unique_identifier(), 'game': "Factorio" + }]) + + def on_print(self, args: dict): + logger.info(args["text"]) + if self.rcon_client: + cleaned_text = args['text'].replace('"', '') + self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")") + + def on_print_json(self, args: dict): + if not self.found_items and args.get("type", None) == "ItemSend" and args["receiving"] == args["sending"]: + pass # don't want info on other player's local pickups. + text = self.raw_json_text_parser(args["data"]) + logger.info(text) + if self.rcon_client: + cleaned_text = text.replace('"', '') + self.rcon_client.send_command(f"/sc game.print(\"Archipelago: {cleaned_text}\")") + +async def game_watcher(ctx: FactorioContext, bridge_file: str): + bridge_logger = logging.getLogger("FactorioWatcher") + from worlds.factorio.Technologies import lookup_id_to_name + bridge_counter = 0 + try: + while not ctx.exit_event.is_set(): + if os.path.exists(bridge_file): + bridge_logger.info("Found Factorio Bridge file.") + while not ctx.exit_event.is_set(): + with open(bridge_file) as f: + data = json.load(f) + research_data = data["research_done"] + research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} + victory = data["victory"] + ctx.auth = data["slot_name"] + ctx.seed_name = data["seed_name"] + + if not ctx.finished_game and victory: + await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}]) + ctx.finished_game = True + + if ctx.locations_checked != research_data: + bridge_logger.info(f"New researches done: " + f"{[lookup_id_to_name[rid] for rid in research_data - ctx.locations_checked]}") + ctx.locations_checked = research_data + await ctx.send_msgs([{"cmd": 'LocationChecks', "locations": tuple(research_data)}]) + await asyncio.sleep(1) + else: + bridge_counter += 1 + if bridge_counter >= 60: + bridge_logger.info( + "Did not find Factorio Bridge file, " + "waiting for mod to run, which requires the server to run, " + "which requires a player to be connected.") + bridge_counter = 0 + await asyncio.sleep(1) + except Exception as e: + logging.exception(e) + logging.error("Aborted Factorio Server Bridge") + + +def stream_factorio_output(pipe, queue): + def queuer(): + while 1: + text = pipe.readline().strip() + if text: + queue.put_nowait(text) + + from threading import Thread + + thread = Thread(target=queuer, name="Factorio Output Queue", daemon=True) + thread.start() + + +async def factorio_server_watcher(ctx: FactorioContext): + import subprocess + import factorio_rcon + factorio_server_logger = logging.getLogger("FactorioServer") + factorio_process = subprocess.Popen((executable, "--start-server", *(str(elem) for elem in server_args)), + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + stdin=subprocess.DEVNULL, + encoding="utf-8") + factorio_server_logger.info("Started Factorio Server") + factorio_queue = Queue() + stream_factorio_output(factorio_process.stdout, factorio_queue) + stream_factorio_output(factorio_process.stderr, factorio_queue) + script_folder = None + progression_watcher = None + try: + while not ctx.exit_event.is_set(): + while not factorio_queue.empty(): + msg = factorio_queue.get() + factorio_server_logger.info(msg) + if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg: + ctx.rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) + # trigger lua interface confirmation + ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')") + ctx.rcon_client.send_command("/sc game.print('Starting Archipelago Bridge')") + ctx.rcon_client.send_command("/ap-sync") + if not script_folder and "Write data path:" in msg: + script_folder = msg.split("Write data path: ", 1)[1].split("[", 1)[0].strip() + bridge_file = os.path.join(script_folder, "script-output", "ap_bridge.json") + if os.path.exists(bridge_file): + os.remove(bridge_file) + logging.info(f"Bridge File Path: {bridge_file}") + progression_watcher= asyncio.create_task( + game_watcher(ctx, bridge_file), name="FactorioProgressionWatcher") + if ctx.rcon_client: + while ctx.send_index < len(ctx.items_received): + transfer_item: NetworkItem = ctx.items_received[ctx.send_index] + item_id = transfer_item.item + player_name = ctx.player_names[transfer_item.player] + if item_id not in lookup_id_to_name: + logging.error(f"Cannot send unknown item ID: {item_id}") + else: + item_name = lookup_id_to_name[item_id] + factorio_server_logger.info(f"Sending {item_name} to Nauvis from {player_name}.") + ctx.rcon_client.send_command(f'/ap-get-technology {item_name} {player_name}') + ctx.send_index += 1 + await asyncio.sleep(1) + factorio_process.terminate() + await progression_watcher + + except Exception as e: + logging.exception(e) + logging.error("Aborted Factorio Server Bridge") + + +async def main(): + ctx = FactorioContext(None, None, True) + + ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") + factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer") + ui_app = FactorioManager(ctx) + ui_task = asyncio.create_task(ui_app.async_run(), name="UI") + + await ctx.exit_event.wait() # wait for signal to exit application + ui_app.stop() + ctx.server_address = None + ctx.snes_reconnect_address = None + # allow tasks to quit + await ui_task + await factorio_server_task + await ctx.server_task + + if ctx.server is not None and not ctx.server.socket.closed: + await ctx.server.socket.close() + if ctx.server_task is not None: + await ctx.server_task + + while ctx.input_requests > 0: # clear queue for shutdown + ctx.input_queue.put_nowait(None) + ctx.input_requests -= 1 + + +from kivy.app import App +from kivy.uix.label import Label +from kivy.uix.gridlayout import GridLayout +from kivy.uix.textinput import TextInput +from kivy.uix.recycleview import RecycleView +from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelHeader +from kivy.lang import Builder + + +class FactorioManager(App): + def __init__(self, ctx): + super(FactorioManager, self).__init__() + self.ctx = ctx + self.commandprocessor = ctx.command_processor(ctx) + + + def build(self): + self.grid = GridLayout() + self.grid.cols = 1 + self.tabs = TabbedPanel() + self.tabs.default_tab_text = "All" + self.title = "Archipelago Factorio Client" + pairs = [ + ("Client", "Archipelago"), + ("FactorioServer", "Factorio Server Log"), + ("FactorioWatcher", "Bridge File Log"), + ] + self.tabs.default_tab_content = UILog(*(logging.getLogger(logger_name) for logger_name, name in pairs)) + for logger_name, display_name in pairs: + bridge_logger = logging.getLogger(logger_name) + panel = TabbedPanelHeader(text=display_name) + self.tabs.add_widget(panel) + panel.content = UILog(bridge_logger) + self.grid.add_widget(self.tabs) + textinput = TextInput(size_hint_y=None, height=30, multiline=False) + textinput.bind(on_text_validate=self.on_message) + self.grid.add_widget(textinput) + self.commandprocessor("/help") + return self.grid + + def on_stop(self): + self.ctx.exit_event.set() + + def on_message(self, textinput: TextInput): + try: + input_text = textinput.text.strip() + textinput.text = "" + + if self.ctx.input_requests > 0: + self.ctx.input_requests -= 1 + self.ctx.input_queue.put_nowait(input_text) + elif input_text: + self.commandprocessor(input_text) + except Exception as e: + logger.exception(e) + +class LogtoUI(logging.Handler): + def __init__(self, on_log): + super(LogtoUI, self).__init__(logging.DEBUG) + self.on_log = on_log + + def handle(self, record: logging.LogRecord) -> None: + self.on_log(record) + +Builder.load_string(''' + + tab_width: 200 +: + canvas.before: + Color: + rgba: 0.2, 0.2, 0.2, 1 + Rectangle: + size: self.size + pos: self.pos + text_size: self.width, None + size_hint_y: None + height: self.texture_size[1] + font_size: dp(20) +: + viewclass: 'Row' + scroll_y: 0 + effect_cls: "ScrollEffect" + RecycleBoxLayout: + default_size: None, dp(20) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: 'vertical' + spacing: dp(3) +''') + +class UILog(RecycleView): + cols = 1 + def __init__(self, *loggers_to_handle, **kwargs): + super(UILog, self).__init__(**kwargs) + self.data = [] + for logger in loggers_to_handle: + logger.addHandler(LogtoUI(self.on_log)) + + def on_log(self, record: logging.LogRecord) -> None: + self.data.append({"text": record.getMessage()}) + + def update_text_width(self, *_): + self.message.text_size = (self.message.width * 0.9, None) + +if __name__ == '__main__': + loop = asyncio.get_event_loop() + loop.run_until_complete(main()) + loop.close() diff --git a/factorio_client_setup.py b/factorio_client_setup.py new file mode 100644 index 00000000..7342cf00 --- /dev/null +++ b/factorio_client_setup.py @@ -0,0 +1,138 @@ +import os +import shutil +import sys +import sysconfig +from pathlib import Path +import cx_Freeze + +is_64bits = sys.maxsize > 2 ** 32 + +folder = "exe.{platform}-{version}".format(platform=sysconfig.get_platform(), + version=sysconfig.get_python_version()) +buildfolder = Path("build_factorio", folder) +sbuildfolder = str(buildfolder) +libfolder = Path(buildfolder, "lib") +library = Path(libfolder, "library.zip") +print("Outputting to: " + sbuildfolder) + +icon = "icon.ico" + +if os.path.exists("X:/pw.txt"): + print("Using signtool") + with open("X:/pw.txt") as f: + pw = f.read() + signtool = r'signtool sign /f X:/_SITS_Zertifikat_.pfx /p ' + pw + r' /fd sha256 /tr http://timestamp.digicert.com/ ' +else: + signtool = None + +from hashlib import sha3_512 +import base64 + + +def _threaded_hash(filepath): + hasher = sha3_512() + hasher.update(open(filepath, "rb").read()) + return base64.b85encode(hasher.digest()).decode() + + +os.makedirs(buildfolder, exist_ok=True) + + +def manifest_creation(): + hashes = {} + manifestpath = os.path.join(buildfolder, "manifest.json") + from concurrent.futures import ThreadPoolExecutor + pool = ThreadPoolExecutor() + for dirpath, dirnames, filenames in os.walk(buildfolder): + for filename in filenames: + path = os.path.join(dirpath, filename) + hashes[os.path.relpath(path, start=buildfolder)] = pool.submit(_threaded_hash, path) + import json + from Utils import _version_tuple + manifest = {"buildtime": buildtime.isoformat(sep=" ", timespec="seconds"), + "hashes": {path: hash.result() for path, hash in hashes.items()}, + "version": _version_tuple} + json.dump(manifest, open(manifestpath, "wt"), indent=4) + print("Created Manifest") + + +scripts = {"FactorioClient.py": "ArchipelagoConsoleFactorioClient"} + +exes = [] + +for script, scriptname in scripts.items(): + exes.append(cx_Freeze.Executable( + script=script, + target_name=scriptname + ("" if sys.platform == "linux" else ".exe"), + icon=icon, + )) +exes.append(cx_Freeze.Executable( + script="FactorioClientGUI.py", + target_name="ArchipelagoGraphicalFactorioClient" + ("" if sys.platform == "linux" else ".exe"), + icon=icon, + base="Win32GUI" +)) + +import datetime + +buildtime = datetime.datetime.utcnow() + +cx_Freeze.setup( + name="Archipelago", + version=f"{buildtime.year}.{buildtime.month}.{buildtime.day}.{buildtime.hour}", + description="Archipelago", + executables=exes, + options={ + "build_exe": { + "packages": ["websockets", "kivy"], + "includes": [], + "excludes": ["numpy", "Cython", "PySide2", "PIL", + "pandas"], + "zip_include_packages": ["*"], + "zip_exclude_packages": ["kivy"], + "include_files": [], + "include_msvcr": True, + "replace_paths": [("*", "")], + "optimize": 2, + "build_exe": buildfolder + }, + }, +) + + +def installfile(path, keep_content=False): + lbuildfolder = buildfolder + print('copying', path, '->', lbuildfolder) + if path.is_dir(): + lbuildfolder /= path.name + if lbuildfolder.is_dir() and not keep_content: + shutil.rmtree(lbuildfolder) + shutil.copytree(path, lbuildfolder, dirs_exist_ok=True) + elif path.is_file(): + shutil.copy(path, lbuildfolder) + else: + print('Warning,', path, 'not found') + + +extra_data = ["LICENSE", "data", "host.yaml", "meta.yaml"] +from kivy_deps import sdl2, glew +for folder in sdl2.dep_bins+glew.dep_bins: + shutil.copytree(folder, buildfolder, dirs_exist_ok=True) +for data in extra_data: + installfile(Path(data)) + + +os.makedirs(buildfolder / "Players", exist_ok=True) +shutil.copyfile("playerSettings.yaml", buildfolder / "Players" / "weightedSettings.yaml") + +if signtool: + for exe in exes: + print(f"Signing {exe.target_name}") + os.system(signtool + os.path.join(buildfolder, exe.target_name)) + +alttpr_sprites_folder = buildfolder / "data" / "sprites" / "alttpr" +for file in os.listdir(alttpr_sprites_folder): + if file != ".gitignore": + os.remove(alttpr_sprites_folder / file) + +manifest_creation() diff --git a/factorio_inno_setup_38.iss b/factorio_inno_setup_38.iss new file mode 100644 index 00000000..fc300e5f --- /dev/null +++ b/factorio_inno_setup_38.iss @@ -0,0 +1,80 @@ +#define sourcepath "build_factorio\exe.win-amd64-3.8\" +#define MyAppName "Archipelago Factorio Client" +#define MyAppExeName "ArchipelagoGraphicalFactorioClient.exe" +#define MyAppIcon "icon.ico" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. +; Do not use the same AppId value in installers for other applications. +AppId={{D13CEBD0-F1D5-4435-A4A6-5243F934613F}} +AppName={#MyAppName} +AppVerName={#MyAppName} +DefaultDirName={commonappdata}\{#MyAppName} +DisableProgramGroupPage=yes +DefaultGroupName=Archipelago +OutputDir=setups +OutputBaseFilename=Setup {#MyAppName} +Compression=lzma2 +SolidCompression=yes +LZMANumBlockThreads=8 +ArchitecturesInstallIn64BitMode=x64 +ChangesAssociations=yes +ArchitecturesAllowed=x64 +AllowNoIcons=yes +SetupIconFile={#MyAppIcon} +UninstallDisplayIcon={app}\{#MyAppExeName} +SignTool= signtool +LicenseFile= LICENSE +WizardStyle= modern +SetupLogging=yes + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; + + +[Dirs] +NAME: "{app}"; Flags: setntfscompression; Permissions: everyone-modify users-modify authusers-modify; + +[Files] +Source: "vc_redist.x64.exe"; DestDir: {tmp}; Flags: deleteafterinstall +Source: "{#sourcepath}*"; Excludes: "*.sfc, *.log, data\sprites\alttpr"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; NOTE: Don't use "Flags: ignoreversion" on any shared system files + +[Icons] +Name: "{group}\{#MyAppName} Folder"; Filename: "{app}"; +Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; +Name: "{commondesktop}\{#MyAppName} Folder"; Filename: "{app}"; Tasks: desktopicon +Name: "{commondesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon + +[Run] +Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/passive /norestart"; Check: IsVCRedist64BitNeeded; StatusMsg: "Installing VC++ redistributable..." + +[UninstallDelete] +Type: dirifempty; Name: "{app}" + + +[Code] +// See: https://stackoverflow.com/a/51614652/2287576 +function IsVCRedist64BitNeeded(): boolean; +var + strVersion: string; +begin + if (RegQueryStringValue(HKEY_LOCAL_MACHINE, + 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64', 'Version', strVersion)) then + begin + // Is the installed version at least the packaged one ? + Log('VC Redist x64 Version : found ' + strVersion); + Result := (CompareStr(strVersion, 'v14.28.29325') < 0); + end + else + begin + // Not even an old version installed + Log('VC Redist x64 is not already installed'); + Result := True; + end; +end; + +