from __future__ import annotations import os import logging import json import string import copy import sys import subprocess import factorio_rcon import colorama import asyncio from queue import Queue from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger from MultiServer import mark_raw import Utils import random from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart from worlds.factorio.Technologies import lookup_id_to_name os.makedirs("logs", exist_ok=True) # Log to file in gui case if getattr(sys, "frozen", False) and not "--nogui" in sys.argv: logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO, filename=os.path.join("logs", "FactorioClient.txt"), filemode="w") else: logging.basicConfig(format='[%(name)s]: %(message)s', level=logging.INFO) logging.getLogger().addHandler(logging.FileHandler(os.path.join("logs", "FactorioClient.txt"), "w")) gui_enabled = Utils.is_frozen() or "--nogui" not in sys.argv def get_kivy_app(): os.environ["KIVY_NO_CONSOLELOG"] = "1" os.environ["KIVY_NO_FILELOG"] = "1" os.environ["KIVY_NO_ARGS"] = "1" from kivy.app import App from kivy.base import ExceptionHandler, ExceptionManager, Config from kivy.uix.gridlayout import GridLayout from kivy.uix.textinput import TextInput from kivy.uix.recycleview import RecycleView from kivy.uix.tabbedpanel import TabbedPanel, TabbedPanelItem 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) self.icon = r"data/icon.png" 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 Data 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 = TabbedPanelItem(text=display_name) panel.content = UILog(bridge_logger) self.tabs.add_widget(panel) 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) 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()}) class E(ExceptionHandler): def handle_exception(self, inst): logger.exception(inst) return ExceptionManager.RAISE ExceptionManager.add_handler(E()) Config.set("input", "mouse", "mouse,disable_multitouch") Builder.load_file(Utils.local_path("data", "client.kv")) return FactorioManager class FactorioCommandProcessor(ClientCommandProcessor): ctx: FactorioContext @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_resync(self): """Manually trigger a resync.""" self.ctx.awaiting_bridge = True class FactorioContext(CommonContext): command_processor = FactorioCommandProcessor game = "Factorio" def __init__(self, server_address, password): super(FactorioContext, self).__init__(server_address, password) self.send_index = 0 self.rcon_client = None self.awaiting_bridge = False self.raw_json_text_parser = RawJSONtoTextParser(self) self.factorio_json_text_parser = FactorioJSONtoTextParser(self) async def server_auth(self, password_requested): if password_requested and not self.password: await super(FactorioContext, self).server_auth(password_requested) if not self.auth: if self.rcon_client: get_info(self, self.rcon_client) # retrieve current auth code else: raise Exception("Cannot connect to a server with unknown own identity, " "bridge to Factorio first.") 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"/ap-print [font=default-large-bold]Archipelago:[/font] " f"{cleaned_text}") def on_print_json(self, args: dict): text = self.raw_json_text_parser(copy.deepcopy(args["data"])) logger.info(text) if self.rcon_client: text = self.factorio_json_text_parser(args["data"]) cleaned_text = text.replace('"', '') self.rcon_client.send_command(f"/ap-print [font=default-large-bold]Archipelago:[/font] " f"{cleaned_text}") @property def savegame_name(self) -> str: return f"AP_{self.seed_name}_{self.auth}.zip" async def game_watcher(ctx: FactorioContext): bridge_logger = logging.getLogger("FactorioWatcher") from worlds.factorio.Technologies import lookup_id_to_name try: while not ctx.exit_event.is_set(): if ctx.awaiting_bridge and ctx.rcon_client: ctx.awaiting_bridge = False data = json.loads(ctx.rcon_client.send_command("/ap-sync")) if data["slot_name"] != ctx.auth: logger.warning(f"Connected World is not the expected one {data['slot_name']} != {ctx.auth}") elif data["seed_name"] != ctx.seed_name: logger.warning( f"Connected Multiworld is not the expected one {data['seed_name']} != {ctx.seed_name}") else: data = data["info"] research_data = data["research_done"] research_data = {int(tech_name.split("-")[1]) for tech_name in research_data} victory = data["victory"] 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) except Exception as e: logging.exception(e) logging.error("Aborted Factorio Server Bridge") def stream_factorio_output(pipe, queue, process): def queuer(): while process.poll() is None: 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() return thread async def factorio_server_watcher(ctx: FactorioContext): savegame_name = os.path.abspath(ctx.savegame_name) if not os.path.exists(savegame_name): logger.info(f"Creating savegame {savegame_name}") subprocess.run(( executable, "--create", savegame_name, "--preset", "archipelago" )) factorio_process = subprocess.Popen((executable, "--start-server", ctx.savegame_name, *(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, factorio_process) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) try: while not ctx.exit_event.is_set(): if factorio_process.poll(): factorio_server_logger.info("Factorio server has exited.") ctx.exit_event.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) if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg: ctx.awaiting_bridge = True 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}\t{ctx.send_index}\t{player_name}') ctx.send_index += 1 await asyncio.sleep(0.1) except Exception as e: logging.exception(e) logging.error("Aborted Factorio Server Bridge") ctx.rcon_client = None ctx.exit_event.set() finally: factorio_process.terminate() factorio_process.wait(5) def get_info(ctx, rcon_client): info = json.loads(rcon_client.send_command("/ap-rcon-info")) ctx.auth = info["slot_name"] ctx.seed_name = info["seed_name"] async def factorio_spinup_server(ctx: FactorioContext): savegame_name = os.path.abspath("Archipelago.zip") if not os.path.exists(savegame_name): logger.info(f"Creating savegame {savegame_name}") subprocess.run(( executable, "--create", savegame_name )) factorio_process = subprocess.Popen( (executable, "--start-server", savegame_name, *(str(elem) for elem in server_args)), stderr=subprocess.PIPE, stdout=subprocess.PIPE, stdin=subprocess.DEVNULL, encoding="utf-8") factorio_server_logger.info("Started Information Exchange Factorio Server") factorio_queue = Queue() stream_factorio_output(factorio_process.stdout, factorio_queue, factorio_process) stream_factorio_output(factorio_process.stderr, factorio_queue, factorio_process) rcon_client = None try: while not ctx.auth: while not factorio_queue.empty(): msg = factorio_queue.get() factorio_server_logger.info(msg) if not rcon_client and "Starting RCON interface at IP ADDR:" in msg: rcon_client = factorio_rcon.RCONClient("localhost", rcon_port, rcon_password) get_info(ctx, rcon_client) await asyncio.sleep(0.01) except Exception as e: logging.exception(e) logging.error("Aborted Factorio Server Bridge") ctx.exit_event.set() else: logger.info(f"Got World Information from AP Mod for seed {ctx.seed_name} in slot {ctx.auth}") finally: factorio_process.terminate() factorio_process.wait(5) async def main(args): ctx = FactorioContext(args.connect, args.password) ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop") if gui_enabled: input_task = None ui_app = get_kivy_app()(ctx) ui_task = asyncio.create_task(ui_app.async_run(), name="UI") else: input_task = asyncio.create_task(console_loop(ctx), name="Input") ui_task = None factorio_server_task = asyncio.create_task(factorio_spinup_server(ctx), name="FactorioSpinupServer") await factorio_server_task factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer") progression_watcher = asyncio.create_task( game_watcher(ctx), name="FactorioProgressionWatcher") await ctx.exit_event.wait() ctx.server_address = None await progression_watcher await factorio_server_task if ctx.server 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: ctx.input_queue.put_nowait(None) ctx.input_requests -= 1 if ui_task: await ui_task if input_task: input_task.cancel() class FactorioJSONtoTextParser(JSONtoTextParser): def _handle_color(self, node: JSONMessagePart): colors = node["color"].split(";") for color in colors: if color in {"red", "green", "blue", "orange", "yellow", "pink", "purple", "white", "black", "gray", "brown", "cyan", "acid"}: node["text"] = f"[color={color}]{node['text']}[/color]" return self._handle_text(node) elif color == "magenta": node["text"] = f"[color=pink]{node['text']}[/color]" return self._handle_text(node) return self._handle_text(node) if __name__ == '__main__': import argparse parser = argparse.ArgumentParser(description="Optional arguments to FactorioClient follow. " "Remaining arguments get passed into bound Factorio instance." "Refer to factorio --help for those.") parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio') parser.add_argument('--rcon-password', help='Password to authenticate with RCON.') parser.add_argument('--connect', default=None, help='Address of the multiworld host.') parser.add_argument('--password', default=None, help='Password of the multiworld host.') if not Utils.is_frozen(): # Frozen state has no cmd window in the first place parser.add_argument('--nogui', default=False, action='store_true', help="Turns off Client GUI.") args, rest = parser.parse_known_args() colorama.init() rcon_port = args.rcon_port rcon_password = args.rcon_password if args.rcon_password else ''.join(random.choice(string.ascii_letters) for x in range(32)) factorio_server_logger = logging.getLogger("FactorioServer") options = Utils.get_options() executable = options["factorio_options"]["executable"] bin_dir = os.path.dirname(executable) if not os.path.exists(bin_dir): raise FileNotFoundError(f"Path {bin_dir} does not exist or could not be accessed.") if not os.path.isdir(bin_dir): raise FileNotFoundError(f"Path {bin_dir} is not a directory.") if not os.path.exists(executable): if os.path.exists(executable + ".exe"): executable = executable + ".exe" else: raise FileNotFoundError(f"Path {executable} is not an executable file.") server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *rest) loop = asyncio.get_event_loop() loop.run_until_complete(main(args)) loop.close() colorama.deinit()