Archipelago/FactorioClient.py

443 lines
18 KiB
Python
Raw Normal View History

2021-06-06 15:50:48 +00:00
from __future__ import annotations
2021-04-01 09:40:58 +00:00
import os
import logging
import json
import string
2021-04-13 12:49:32 +00:00
import copy
2021-06-06 15:50:48 +00:00
import sys
import subprocess
import factorio_rcon
2021-04-01 09:40:58 +00:00
import colorama
import asyncio
2021-05-13 19:57:11 +00:00
from queue import Queue
2021-04-13 12:49:32 +00:00
from CommonClient import CommonContext, server_loop, console_loop, ClientCommandProcessor, logger
2021-04-01 09:40:58 +00:00
from MultiServer import mark_raw
import Utils
import random
from NetUtils import RawJSONtoTextParser, NetworkItem, ClientStatus, JSONtoTextParser, JSONMessagePart
2021-04-01 09:40:58 +00:00
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
if gui_enabled:
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)
def on_address(self, text: str):
print(text)
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"))
2021-04-01 09:40:58 +00:00
2021-05-09 15:26:53 +00:00
2021-04-01 09:40:58 +00:00
class FactorioCommandProcessor(ClientCommandProcessor):
2021-06-06 15:50:48 +00:00
ctx: FactorioContext
2021-04-01 09:40:58 +00:00
@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
2021-05-09 15:26:53 +00:00
def _cmd_connect(self, address: str = "") -> bool:
"""Connect to a MultiWorld Server"""
2021-05-09 15:26:53 +00:00
if not self.ctx.auth:
if self.ctx.rcon_client:
get_info(self.ctx, self.ctx.rcon_client) # retrieve current auth code
else:
self.output("Cannot connect to a server with unknown own identity, bridge to Factorio first.")
return super(FactorioCommandProcessor, self)._cmd_connect(address)
2021-04-01 09:40:58 +00:00
class FactorioContext(CommonContext):
command_processor = FactorioCommandProcessor
game = "Factorio"
2021-04-01 09:40:58 +00:00
def __init__(self, server_address, password):
super(FactorioContext, self).__init__(server_address, password)
2021-04-01 09:40:58 +00:00
self.send_index = 0
self.rcon_client = None
self.awaiting_bridge = False
2021-04-13 12:49:32 +00:00
self.raw_json_text_parser = RawJSONtoTextParser(self)
self.factorio_json_text_parser = FactorioJSONtoTextParser(self)
2021-04-01 09:40:58 +00:00
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',
2021-06-18 20:15:54 +00:00
'password': self.password, 'name': self.auth, 'version': Utils.version_tuple,
2021-04-01 09:40:58 +00:00
'tags': ['AP'],
'uuid': Utils.get_unique_identifier(), 'game': "Factorio"
}])
2021-04-13 12:49:32 +00:00
def on_print(self, args: dict):
logger.info(args["text"])
if self.rcon_client:
2021-04-13 18:09:26 +00:00
cleaned_text = args['text'].replace('"', '')
2021-06-06 15:50:48 +00:00
self.rcon_client.send_command(f"/sc game.print(\"[font=default-large-bold]Archipelago:[/font] "
f"{cleaned_text}\")")
2021-04-13 12:49:32 +00:00
def on_print_json(self, args: dict):
text = self.raw_json_text_parser(copy.deepcopy(args["data"]))
logger.info(text)
2021-04-13 12:49:32 +00:00
if self.rcon_client:
text = self.factorio_json_text_parser(args["data"])
cleaned_text = text.replace('"', '')
2021-06-06 15:50:48 +00:00
self.rcon_client.send_command(f"/sc game.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"
2021-04-01 09:40:58 +00:00
async def game_watcher(ctx: FactorioContext):
bridge_logger = logging.getLogger("FactorioWatcher")
2021-04-03 23:19:54 +00:00
from worlds.factorio.Technologies import lookup_id_to_name
2021-04-01 09:40:58 +00:00
try:
while not ctx.exit_event.is_set():
2021-07-02 18:52:06 +00:00
if ctx.awaiting_bridge and ctx.rcon_client:
ctx.awaiting_bridge = False
2021-07-02 18:52:06 +00:00
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}")
2021-07-02 18:52:06 +00:00
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"]
2021-07-02 18:52:06 +00:00
if not ctx.finished_game and victory:
await ctx.send_msgs([{"cmd": "StatusUpdate", "status": ClientStatus.CLIENT_GOAL}])
ctx.finished_game = True
2021-07-02 18:52:06 +00:00
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)
2021-04-01 09:40:58 +00:00
except Exception as e:
logging.exception(e)
logging.error("Aborted Factorio Server Bridge")
2021-05-09 15:26:53 +00:00
def stream_factorio_output(pipe, queue, process):
2021-04-01 09:40:58 +00:00
def queuer():
while process.poll() is None:
2021-04-01 09:40:58 +00:00
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
2021-04-01 09:40:58 +00:00
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)),
2021-04-01 09:40:58 +00:00
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)
2021-04-01 09:40:58 +00:00
try:
while not ctx.exit_event.is_set():
if factorio_process.poll():
factorio_server_logger.info("Factorio server has exited.")
ctx.exit_event.set()
2021-04-01 09:40:58 +00:00
while not factorio_queue.empty():
msg = factorio_queue.get()
factorio_server_logger.info(msg)
2021-05-18 18:45:56 +00:00
if not ctx.rcon_client and "Starting RCON interface at IP ADDR:" in msg:
2021-04-01 09:40:58 +00:00
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')")
2021-07-02 18:52:06 +00:00
if not ctx.awaiting_bridge and "Archipelago Bridge Data available for game tick " in msg:
ctx.awaiting_bridge = True
2021-04-01 09:40:58 +00:00
if ctx.rcon_client:
while ctx.send_index < len(ctx.items_received):
2021-04-13 12:49:32 +00:00
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:
2021-04-05 13:37:15 +00:00
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}')
2021-04-01 09:40:58 +00:00
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()
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:
2021-07-02 18:52:06 +00:00
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)
2021-04-01 09:40:58 +00:00
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}")
2021-04-01 09:40:58 +00:00
finally:
factorio_process.terminate()
2021-04-01 09:40:58 +00:00
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 = FactorioManager(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
2021-04-01 09:40:58 +00:00
factorio_server_task = asyncio.create_task(factorio_server_watcher(ctx), name="FactorioServer")
progression_watcher = asyncio.create_task(
game_watcher(ctx), name="FactorioProgressionWatcher")
2021-04-01 09:40:58 +00:00
await ctx.exit_event.wait()
ctx.server_address = None
await progression_watcher
await factorio_server_task
2021-04-01 09:40:58 +00:00
if ctx.server and not ctx.server.socket.closed:
2021-04-01 09:40:58 +00:00
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()
2021-04-01 09:40:58 +00:00
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"}:
2021-06-06 21:44:04 +00:00
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)
2021-06-06 15:50:48 +00:00
return self._handle_text(node)
2021-06-06 20:49:37 +00:00
if __name__ == '__main__':
import argparse
parser = argparse.ArgumentParser()
parser.add_argument('--rcon-port', default='24242', type=int, help='Port to use to communicate with Factorio')
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.")
parser.add_argument('factorio_server_args', nargs='*', help="All remaining arguments get passed "
"into the Factorio server startup.")
args = parser.parse_args()
2021-06-06 20:49:37 +00:00
colorama.init()
rcon_port = args.rcon_port
rcon_password = ''.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)
2021-07-20 19:19:53 +00:00
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):
2021-07-20 19:19:53 +00:00
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:
2021-07-20 19:19:53 +00:00
raise FileNotFoundError(f"Path {executable} is not an executable file.")
server_args = ("--rcon-port", rcon_port, "--rcon-password", rcon_password, *args.factorio_server_args)
2021-06-06 20:49:37 +00:00
loop = asyncio.get_event_loop()
loop.run_until_complete(main(args))
2021-06-06 20:49:37 +00:00
loop.close()
colorama.deinit()