* Object-Oriented base changes for web-ui prep

* remove debug raise

* optimize broadcast to serialize once

* Implement WebUI socket, static assets, and classes

- Still need to wrap logging functions and send output to UI
- UI commands are successfully being sent to the server

* GUI operational. Wrap logging functions, implement server address selection on GUI, automatically launch web browser when client websocket is served

* Update MultiServer status when a user disconnects / reconnects

* Implement colored item and hint checks, improve GUI readability

* Fix improper formatting on received items

* Update SNES connection status on disconnect / reconnect. Implement itemFound, prevent accidentally printing JS objects

* Minor text change for itemFound

* Fixed a very wrong comment

* Fixed client commands not working, fixed un-helpful error messages appearing in GUI

* Fix a bug causing a failure to connect to a multiworld server if a previously existing cached address was present and the client was loaded without an address passed in

* Convert WebUI to React /w Redux. WebSocket communications not yet operational.

* WebUI fully converted to React / Redux.

- Websocket communication operational
- Added a button to connect to the multiserver which appears only when a SNES is connected and a server connection is not active

* Restore some features lost in WebUI

- Restore (found) notification on hints if the item has already been obtained
- Restore (x/y) indicator on received items, which indicates the number of items the client is waiting to receive from the client in a queue

* Fix a grammatical UI big causing player names to show only an apostrophe when possessive

* Add support for multiple SNES Devices, and switching between them

* freeze support for client

* make sure flask works when frozen

* UI Improvements

- Hint messages now actually show a found status via ✔ and  emoji
- Active player name is always a different color than other players (orange for now)
- Add a toggle to show only entries relevant to the active player
- Added a WidgetArea
- Added a notes widget

* Received items now marked as relevant

* Include production build for deployment

* Notes now survive a browser close. Minimum width applied to monitor to prevent CSS issues.

* include webUi folder in setup.py

* Bugfixes for Monitor

- Fix a bug causing the monitor window to grow beyond it's intended content limit
- Reduced monitor content limit to 200 items
- Ensured each monitor entry has a unique key

* Prevent eslint from yelling at me about stupid things

* Add button to collapse sidebar, press enter on empty server input to disconnect on purpose

* WebUI is now aware of client disconnect, message log limit increased to 350, fix !missing output

* Update WebUI to v2.2.1

- Added color to WebUI for entrance-span
- Make !missing show total count at bottom of list to match /missing behavior

* Fix a bug causing clients version <= 2.2.0 to crash when anyone asks for a hint

- Also fix a bug in the WebUI causing the entrance location to always show as "somewhere"

* Update WebUI color palette (this cost me $50)

* allow text console input alongside web-ui

* remove Flask
a bit overkill for what we're doing

* remove jinja2

* Update WebUI to work with new hosting mechanism

* with flask gone, we no longer need subprocess shenanigans

* If multiple web ui clients try to run, at least present a working console

* Update MultiClient and WebUI to handle multiple clients simultaneously.

- The port on which the websocket for the WebUI is hosted is not chosen randomly from 5000 - 5999. This port is passed to the browser so it knows which MultiClient to connect to

- Removed failure condition if a web server is already running, as there is no need to run more than one web server on a single system. If an exception is thrown while attempting to launch a web server, a check is made for the port being unavailable. If the port is unavailable, it probably means the user is launching a second MultiClient. A web browser is then opened with a connection to the correct webui_socket_port.

- Add a /web command to the MultiClient to repoen the appropriate browser window and get params in case a user accidentally closes the tab

* Use proper name for WebUI

* move webui into /data with other data files

* make web ui optional
This is mostly for laptop users wanting to preserve some battery, should not be needed outside of that.

* fix direct server start

* re-add connection timer

* fix indentation

Co-authored-by: Chris <chris@legendserver.info>
This commit is contained in:
Fabian Dill
2020-06-03 21:29:43 +02:00
committed by GitHub
parent ffe67c7fa7
commit 38cbcc662f
43 changed files with 10047 additions and 237 deletions

View File

@@ -5,6 +5,11 @@ import logging
import urllib.parse
import atexit
import time
import functools
import webbrowser
import multiprocessing
import socket
from random import randrange
from Utils import get_item_name_from_id, get_location_name_from_address, ReceivedItem
@@ -19,16 +24,30 @@ import websockets
import prompt_toolkit
import typing
from prompt_toolkit.patch_stdout import patch_stdout
from NetUtils import Endpoint
import WebUiServer
import WebUiClient
import Regions
import Utils
class Context:
def __init__(self, snes_address, server_address, password, found_items):
def create_named_task(coro, *args, name=None):
if not name:
name = coro.__name__
print(name)
return asyncio.create_task(coro, *args, name=name)
class Context():
def __init__(self, snes_address, server_address, password, found_items, port: int):
self.snes_address = snes_address
self.server_address = server_address
self.ui_node = WebUiClient.WebUiClient()
self.custom_address = None
self.webui_socket_port = port
self.exit_event = asyncio.Event()
self.watcher_event = asyncio.Event()
@@ -45,7 +64,7 @@ class Context:
self.snes_write_buffer = []
self.server_task = None
self.socket = None
self.server: typing.Optional[Endpoint] = None
self.password = password
self.server_version = (0, 0, 0)
@@ -64,6 +83,24 @@ class Context:
self.finished_game = False
self.slow_mode = False
@property
def endpoints(self):
if self.server:
return [self.server]
else:
return []
async def disconnect(self):
if self.server and not self.server.socket.closed:
await self.server.socket.close()
self.ui_node.send_connection_status(self)
if self.server_task is not None:
await self.server_task
async def send_msgs(self, msgs):
if not self.server or not self.server.socket.open or self.server.socket.closed:
return
await self.server.socket.send(json.dumps(msgs))
color_codes = {'reset': 0, 'bold': 1, 'underline': 4, 'black': 30, 'red': 31, 'green': 32, 'yellow': 33, 'blue': 34,
'magenta': 35, 'cyan': 36, 'white': 37, 'black_bg': 40, 'red_bg': 41, 'green_bg': 42, 'yellow_bg': 43,
@@ -326,9 +363,10 @@ SNES_CONNECTING = 1
SNES_CONNECTED = 2
SNES_ATTACHED = 3
async def snes_connect(ctx : Context, address):
if ctx.snes_socket is not None:
logging.error('Already connected to snes')
async def snes_connect(ctx: Context, address, poll_only=False):
if ctx.snes_socket is not None and poll_only is False:
ctx.ui_node.log_error('Already connected to snes')
return
ctx.snes_state = SNES_CONNECTING
@@ -336,7 +374,7 @@ async def snes_connect(ctx : Context, address):
address = f"ws://{address}" if "://" not in address else address
logging.info("Connecting to QUsb2snes at %s ..." % address)
ctx.ui_node.log_info("Connecting to QUsb2snes at %s ..." % address)
seen_problems = set()
while ctx.snes_state == SNES_CONNECTING:
try:
@@ -346,7 +384,8 @@ async def snes_connect(ctx : Context, address):
# only tell the user about new problems, otherwise silently lay in wait for a working connection
if problem not in seen_problems:
seen_problems.add(problem)
logging.error(f"Error connecting to QUsb2snes ({problem})")
ctx.ui_node.log_error(f"Error connecting to QUsb2snes ({problem})")
if len(seen_problems) == 1:
# this is the first problem. Let's try launching QUsb2snes if it isn't already running
qusb2snes_path = Utils.get_options()["general_options"]["qusb2snes"]
@@ -356,12 +395,13 @@ async def snes_connect(ctx : Context, address):
qusb2snes_path = Utils.local_path(qusb2snes_path)
if os.path.isfile(qusb2snes_path):
logging.info(f"Attempting to start {qusb2snes_path}")
ctx.ui_node.log_info(f"Attempting to start {qusb2snes_path}")
import subprocess
subprocess.Popen(qusb2snes_path, cwd=os.path.dirname(qusb2snes_path))
else:
logging.info(
f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, please start it yourself if it is not running")
ctx.ui_node.log_info(
f"Attempt to start (Q)Usb2Snes was aborted as path {qusb2snes_path} was not found, "
f"please start it yourself if it is not running")
await asyncio.sleep(1)
else:
@@ -377,56 +417,56 @@ async def snes_connect(ctx : Context, address):
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
if not devices:
logging.info('No device found, waiting for device. Run multibridge and connect it to QUSB2SNES.')
ctx.ui_node.log_info('No SNES device found. Ensure QUsb2Snes is running and connect it to the multibridge.')
while not devices:
await asyncio.sleep(1)
await ctx.snes_socket.send(json.dumps(DeviceList_Request))
reply = json.loads(await ctx.snes_socket.recv())
devices = reply['Results'] if 'Results' in reply and len(reply['Results']) > 0 else None
logging.info("Available devices:")
for id, device in enumerate(devices):
logging.info("[%d] %s" % (id + 1, device))
ctx.ui_node.send_device_list(devices)
# Support for polling available SNES devices without attempting to attach
if poll_only:
if len(devices) > 1:
ctx.ui_node.log_info("Multiple SNES devices available. Please select a device to use.")
await snes_disconnect(ctx)
return
device = None
if len(devices) == 1:
device = devices[0]
elif ctx.ui_node.manual_snes and ctx.ui_node.manual_snes in devices:
device = ctx.ui_node.manual_snes
elif ctx.snes_reconnect_address:
if ctx.snes_attached_device[1] in devices:
device = ctx.snes_attached_device[1]
else:
device = devices[ctx.snes_attached_device[0]]
else:
while True:
logging.info("Select a device:")
choice = await console_input(ctx)
if choice is None:
raise Exception('Abort input')
if not choice.isdigit() or int(choice) < 1 or int(choice) > len(devices):
logging.warning("Invalid choice (%s)" % choice)
continue
await snes_disconnect(ctx)
return
device = devices[int(choice) - 1]
break
logging.info("Attaching to " + device)
ctx.ui_node.log_info("Attaching to " + device)
Attach_Request = {
"Opcode" : "Attach",
"Space" : "SNES",
"Operands" : [device]
"Opcode": "Attach",
"Space": "SNES",
"Operands": [device]
}
await ctx.snes_socket.send(json.dumps(Attach_Request))
ctx.snes_state = SNES_ATTACHED
ctx.snes_attached_device = (devices.index(device), device)
ctx.ui_node.send_connection_status(ctx)
if 'SD2SNES'.lower() in device.lower() or (len(device) == 4 and device[:3] == 'COM'):
logging.info("SD2SNES Detected")
ctx.ui_node.log_info("SD2SNES Detected")
ctx.is_sd2snes = True
await ctx.snes_socket.send(json.dumps({"Opcode" : "Info", "Space" : "SNES"}))
reply = json.loads(await ctx.snes_socket.recv())
if reply and 'Results' in reply:
logging.info(reply['Results'])
ctx.ui_node.log_info(reply['Results'])
else:
ctx.is_sd2snes = False
@@ -444,12 +484,19 @@ async def snes_connect(ctx : Context, address):
ctx.snes_socket = None
ctx.snes_state = SNES_DISCONNECTED
if not ctx.snes_reconnect_address:
logging.error("Error connecting to snes (%s)" % e)
ctx.ui_node.log_error("Error connecting to snes (%s)" % e)
else:
logging.error(f"Error connecting to snes, attempt again in {RECONNECT_DELAY}s")
ctx.ui_node.log_error(f"Error connecting to snes, attempt again in {RECONNECT_DELAY}s")
asyncio.create_task(snes_autoreconnect(ctx))
async def snes_disconnect(ctx: Context):
if ctx.snes_socket:
if not ctx.snes_socket.closed:
await ctx.snes_socket.close()
ctx.snes_socket = None
async def snes_autoreconnect(ctx: Context):
# unfortunately currently broken. See: https://github.com/prompt-toolkit/python-prompt-toolkit/issues/1033
# with prompt_toolkit.shortcuts.ProgressBar() as pb:
@@ -459,15 +506,16 @@ async def snes_autoreconnect(ctx: Context):
if ctx.snes_reconnect_address and ctx.snes_socket is None:
await snes_connect(ctx, ctx.snes_reconnect_address)
async def snes_recv_loop(ctx : Context):
async def snes_recv_loop(ctx: Context):
try:
async for msg in ctx.snes_socket:
ctx.snes_recv_queue.put_nowait(msg)
logging.warning("Snes disconnected")
ctx.ui_node.log_warning("Snes disconnected")
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
logging.error("Lost connection to the snes, type /snes to reconnect")
ctx.ui_node.log_error("Lost connection to the snes, type /snes to reconnect")
finally:
socket, ctx.snes_socket = ctx.snes_socket, None
if socket is not None and not socket.closed:
@@ -476,13 +524,15 @@ async def snes_recv_loop(ctx : Context):
ctx.snes_state = SNES_DISCONNECTED
ctx.snes_recv_queue = asyncio.Queue()
ctx.hud_message_queue = []
ctx.ui_node.send_connection_status(ctx)
ctx.rom = None
if ctx.snes_reconnect_address:
logging.info(f"...reconnecting in {RECONNECT_DELAY}s")
ctx.ui_node.log_info(f"...reconnecting in {RECONNECT_DELAY}s")
asyncio.create_task(snes_autoreconnect(ctx))
async def snes_read(ctx : Context, address, size):
try:
await ctx.snes_request_lock.acquire()
@@ -510,7 +560,9 @@ async def snes_read(ctx : Context, address, size):
if len(data) != size:
logging.error('Error reading %s, requested %d bytes, received %d' % (hex(address), size, len(data)))
if len(data):
logging.error(str(data))
ctx.ui_node.log_error(str(data))
ctx.ui_node.log_warning('Unable to connect to SNES Device because QUsb2Snes broke temporarily.'
'Try un-selecting and re-selecting the SNES Device.')
if ctx.snes_socket is not None and not ctx.snes_socket.closed:
await ctx.snes_socket.close()
return None
@@ -519,6 +571,7 @@ async def snes_read(ctx : Context, address, size):
finally:
ctx.snes_request_lock.release()
async def snes_write(ctx : Context, write_list):
try:
await ctx.snes_request_lock.acquire()
@@ -536,7 +589,7 @@ async def snes_write(ctx : Context, write_list):
for address, data in write_list:
if (address < WRAM_START) or ((address + len(data)) > (WRAM_START + WRAM_SIZE)):
logging.error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
ctx.ui_node.log_error("SD2SNES: Write out of range %s (%d)" % (hex(address), len(data)))
return False
for ptr, byte in enumerate(data, address + 0x7E0000 - WRAM_START):
cmd += b'\xA9' # LDA
@@ -572,12 +625,14 @@ async def snes_write(ctx : Context, write_list):
finally:
ctx.snes_request_lock.release()
def snes_buffered_write(ctx : Context, address, data):
if len(ctx.snes_write_buffer) > 0 and (ctx.snes_write_buffer[-1][0] + len(ctx.snes_write_buffer[-1][1])) == address:
ctx.snes_write_buffer[-1] = (ctx.snes_write_buffer[-1][0], ctx.snes_write_buffer[-1][1] + data)
else:
ctx.snes_write_buffer.append((address, data))
async def snes_flush_writes(ctx : Context):
if not ctx.snes_write_buffer:
return
@@ -585,14 +640,18 @@ async def snes_flush_writes(ctx : Context):
await snes_write(ctx, ctx.snes_write_buffer)
ctx.snes_write_buffer = []
async def send_msgs(websocket, msgs):
if not websocket or not websocket.open or websocket.closed:
return
await websocket.send(json.dumps(msgs))
async def server_loop(ctx : Context, address = None):
if ctx.socket is not None:
logging.error('Already connected')
async def server_loop(ctx: Context, address=None):
ctx.ui_node.send_connection_status(ctx)
cached_address = None
if ctx.server and ctx.server.socket:
ctx.ui_node.log_error('Already connected')
return
if address is None: # set through CLI or BMBP
@@ -601,40 +660,45 @@ async def server_loop(ctx : Context, address = None):
await asyncio.sleep(0.5) # wait for snes connection to succeed if possible.
rom = "".join(chr(x) for x in ctx.rom) if ctx.rom is not None else None
try:
servers = Utils.persistent_load()["servers"]
servers = cached_address = Utils.persistent_load()["servers"]
address = servers[rom] if rom is not None and rom in servers else servers["default"]
except Exception as e:
logging.debug(f"Could not find cached server address. {e}")
else:
logging.info(f'Enter multiworld server address. Press enter to connect to {address}')
text = await console_input(ctx)
if text:
address = text
while not address:
logging.info('Enter multiworld server address')
address = await console_input(ctx)
# Wait for the user to provide a multiworld server address
if not address:
logging.info('Please connect to a multiworld server.')
ctx.ui_node.poll_for_server_ip()
return
address = f"ws://{address}" if "://" not in address else address
port = urllib.parse.urlparse(address).port or 38281
logging.info('Connecting to multiworld server at %s' % address)
ctx.ui_node.log_info('Connecting to multiworld server at %s' % address)
try:
ctx.socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
logging.info('Connected')
socket = await websockets.connect(address, port=port, ping_timeout=None, ping_interval=None)
ctx.server = Endpoint(socket)
ctx.ui_node.log_info('Connected')
ctx.server_address = address
ctx.ui_node.send_connection_status(ctx)
async for data in ctx.socket:
async for data in ctx.server.socket:
for msg in json.loads(data):
cmd, args = (msg[0], msg[1]) if len(msg) > 1 else (msg, None)
await process_server_cmd(ctx, cmd, args)
logging.warning('Disconnected from multiworld server, type /connect to reconnect')
ctx.ui_node.log_warning('Disconnected from multiworld server, type /connect to reconnect')
except WebUiClient.WaitingForUiException:
pass
except ConnectionRefusedError:
logging.error('Connection refused by the multiworld server')
if cached_address:
ctx.ui_node.log_error('Unable to connect to multiworld server at cached address. '
'Please use the connect button above.')
else:
ctx.ui_node.log_error('Connection refused by the multiworld server')
except (OSError, websockets.InvalidURI):
logging.error('Failed to connect to the multiworld server')
ctx.ui_node.log_error('Failed to connect to the multiworld server')
except Exception as e:
logging.error('Lost connection to the multiworld server, type /connect to reconnect')
ctx.ui_node.log_error('Lost connection to the multiworld server, type /connect to reconnect')
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
@@ -643,12 +707,13 @@ async def server_loop(ctx : Context, address = None):
ctx.items_received = []
ctx.locations_info = {}
ctx.server_version = (0, 0, 0)
socket, ctx.socket = ctx.socket, None
if socket is not None and not socket.closed:
await socket.close()
if ctx.server and ctx.server.socket is not None:
await ctx.server.socket.close()
ctx.server = None
ctx.server_task = None
if ctx.server_address:
logging.info(f"... reconnecting in {RECONNECT_DELAY}s")
ctx.ui_node.log_info(f"... reconnecting in {RECONNECT_DELAY}s")
ctx.ui_node.send_connection_status(ctx)
asyncio.create_task(server_autoreconnect(ctx))
@@ -661,42 +726,43 @@ async def server_autoreconnect(ctx: Context):
if ctx.server_address and ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx))
async def process_server_cmd(ctx : Context, cmd, args):
async def process_server_cmd(ctx: Context, cmd, args):
if cmd == 'RoomInfo':
logging.info('--------------------------------')
logging.info('Room Information:')
logging.info('--------------------------------')
ctx.ui_node.log_info('--------------------------------')
ctx.ui_node.log_info('Room Information:')
ctx.ui_node.log_info('--------------------------------')
version = args.get("version", "unknown Bonta Protocol")
if isinstance(version, list):
ctx.server_version = tuple(version)
version = ".".join(str(item) for item in version)
else:
ctx.server_version = (0, 0, 0)
logging.info(f'Server protocol version: {version}')
ctx.ui_node.log_info(f'Server protocol version: {version}')
if "tags" in args:
logging.info("Server protocol tags: " + ", ".join(args["tags"]))
ctx.ui_node.log_info("Server protocol tags: " + ", ".join(args["tags"]))
if args['password']:
logging.info('Password required')
ctx.ui_node.log_info('Password required')
if "forfeit_mode" in args: # could also be version > 2.2.1, but going with implicit content here
logging.info("Forfeit setting: "+args["forfeit_mode"])
logging.info("Remaining setting: "+args["remaining_mode"])
logging.info(f"A !hint costs {args['hint_cost']} points and you get {args['location_check_points']} for each location checked.")
if len(args['players']) < 1:
logging.info('No player connected')
ctx.ui_node.log_info('No player connected')
else:
args['players'].sort()
current_team = -1
logging.info('Connected players:')
ctx.ui_node.log_info('Connected players:')
for team, slot, name in args['players']:
if team != current_team:
logging.info(f' Team #{team + 1}')
ctx.ui_node.log_info(f' Team #{team + 1}')
current_team = team
logging.info(' %s (Player %d)' % (name, slot))
ctx.ui_node.log_info(' %s (Player %d)' % (name, slot))
await server_auth(ctx, args['password'])
elif cmd == 'ConnectionRefused':
if 'InvalidPassword' in args:
logging.error('Invalid password')
ctx.ui_node.log_error('Invalid password')
ctx.password = None
await server_auth(ctx, True)
if 'InvalidRom' in args:
@@ -721,7 +787,7 @@ async def process_server_cmd(ctx : Context, cmd, args):
if ctx.locations_scouted:
msgs.append(['LocationScouts', list(ctx.locations_scouted)])
if msgs:
await send_msgs(ctx.socket, msgs)
await ctx.send_msgs(msgs)
elif cmd == 'ReceivedItems':
start_index, items = args
@@ -731,7 +797,7 @@ async def process_server_cmd(ctx : Context, cmd, args):
sync_msg = [['Sync']]
if ctx.locations_checked:
sync_msg.append(['LocationChecks', [Regions.location_table[loc][0] for loc in ctx.locations_checked]])
await send_msgs(ctx.socket, sync_msg)
await ctx.send_msgs(sync_msg)
if start_index == len(ctx.items_received):
for item in items:
ctx.items_received.append(ReceivedItem(*item))
@@ -742,13 +808,16 @@ async def process_server_cmd(ctx : Context, cmd, args):
if location not in ctx.locations_info:
replacements = {0xA2: 'Small Key', 0x9D: 'Big Key', 0x8D: 'Compass', 0x7D: 'Map'}
item_name = replacements.get(item, get_item_name_from_id(item))
logging.info(
ctx.ui_node.log_info(
f"Saw {color(item_name, 'red', 'bold')} at {list(Regions.location_table.keys())[location - 1]}")
ctx.locations_info[location] = (item, player)
ctx.watcher_event.set()
elif cmd == 'ItemSent':
player_sent, location, player_recvd, item = args
ctx.ui_node.notify_item_sent(ctx.player_names[player_sent], ctx.player_names[player_recvd],
get_item_name_from_id(item), get_location_name_from_address(location),
player_sent == ctx.slot, player_recvd == ctx.slot)
item = color(get_item_name_from_id(item), 'cyan' if player_sent != ctx.slot else 'green')
player_sent = color(ctx.player_names[player_sent], 'yellow' if player_sent != ctx.slot else 'magenta')
player_recvd = color(ctx.player_names[player_recvd], 'yellow' if player_recvd != ctx.slot else 'magenta')
@@ -758,65 +827,77 @@ async def process_server_cmd(ctx : Context, cmd, args):
elif cmd == 'ItemFound':
found = ReceivedItem(*args)
ctx.ui_node.notify_item_found(ctx.player_names[found.player], get_item_name_from_id(found.item),
get_location_name_from_address(found.location), found.player == ctx.slot)
item = color(get_item_name_from_id(found.item), 'cyan' if found.player != ctx.slot else 'green')
player_sent = color(ctx.player_names[found.player], 'yellow' if found.player != ctx.slot else 'magenta')
logging.info('%s found %s (%s)' % (player_sent, item, color(get_location_name_from_address(found.location),
'blue_bg', 'white')))
elif cmd == 'Missing':
if 'locations' in args:
locations = json.loads(args['locations'])
for location in locations:
ctx.ui_node.log_info(f'Missing: {location}')
ctx.ui_node.log_info(f'Found {len(locations)} missing location checks')
elif cmd == 'Hint':
hints = [Utils.Hint(*hint) for hint in args]
for hint in hints:
ctx.ui_node.send_hint(ctx.player_names[hint.finding_player], ctx.player_names[hint.receiving_player],
get_item_name_from_id(hint.item), get_location_name_from_address(hint.location),
hint.found, hint.finding_player == ctx.slot, hint.receiving_player == ctx.slot,
hint.entrance if hint.entrance else None)
item = color(get_item_name_from_id(hint.item), 'green' if hint.found else 'cyan')
player_find = color(ctx.player_names[hint.finding_player],
'yellow' if hint.finding_player != ctx.slot else 'magenta')
player_recvd = color(ctx.player_names[hint.receiving_player],
'yellow' if hint.receiving_player != ctx.slot else 'magenta')
text = f"[Hint]: {player_recvd}'s {item} is " \
f"at {color(get_location_name_from_address(hint.location), 'blue_bg', 'white')} in {player_find}'s World"
f"at {color(get_location_name_from_address(hint.location), 'blue_bg', 'white')} " \
f"in {player_find}'s World"
if hint.entrance:
text += " at " + color(hint.entrance, 'white_bg', 'black')
logging.info(text + (f". {color('(found)', 'green_bg', 'black')} " if hint.found else "."))
elif cmd == "AliasUpdate":
ctx.player_names = {p: n for p, n in args}
elif cmd == 'Print':
logging.info(args)
ctx.ui_node.log_info(args)
else:
logging.debug(f"unknown command {args}")
def get_tags(ctx: Context):
tags = ['Berserker']
if ctx.found_items:
tags.append('FoundItems')
return tags
async def server_auth(ctx: Context, password_requested):
if password_requested and not ctx.password:
logging.info('Enter the password required to join this game:')
ctx.ui_node.log_info('Enter the password required to join this game:')
ctx.password = await console_input(ctx)
if ctx.rom is None:
ctx.awaiting_rom = True
logging.info('No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
ctx.ui_node.log_info('No ROM detected, awaiting snes connection to authenticate to the multiworld server (/snes)')
return
ctx.awaiting_rom = False
ctx.auth = ctx.rom.copy()
await send_msgs(ctx.socket, [['Connect', {
await ctx.send_msgs([['Connect', {
'password': ctx.password, 'rom': ctx.auth, 'version': Utils._version_tuple, 'tags': get_tags(ctx)
}]])
async def console_input(ctx : Context):
ctx.input_requests += 1
return await ctx.input_queue.get()
async def disconnect(ctx: Context):
if ctx.socket is not None and not ctx.socket.closed:
await ctx.socket.close()
if ctx.server_task is not None:
await ctx.server_task
async def connect(ctx: Context, address=None):
await disconnect(ctx)
await ctx.disconnect()
ctx.server_task = asyncio.create_task(server_loop(ctx, address))
@@ -827,6 +908,9 @@ class ClientCommandProcessor(CommandProcessor):
def __init__(self, ctx: Context):
self.ctx = ctx
def output(self, text: str):
self.ctx.ui_node.log_info(text)
def _cmd_exit(self) -> bool:
"""Close connections and client"""
self.ctx.exit_event.set()
@@ -857,13 +941,16 @@ class ClientCommandProcessor(CommandProcessor):
def _cmd_disconnect(self) -> bool:
"""Disconnect from a MultiWorld Server"""
self.ctx.server_address = None
asyncio.create_task(disconnect(self.ctx))
asyncio.create_task(self.ctx.disconnect())
return True
def _cmd_received(self) -> bool:
"""List all received items"""
logging.info('Received items:')
self.ctx.ui_node.log_info('Received items:')
for index, item in enumerate(self.ctx.items_received, 1):
self.ctx.ui_node.notify_item_received(self.ctx.player_names[item.player], get_item_name_from_id(item.item),
get_location_name_from_address(item.location), index,
len(self.ctx.items_received))
logging.info('%s from %s (%s) (%d/%d in list)' % (
color(get_item_name_from_id(item.item), 'red', 'bold'),
color(self.ctx.player_names[item.player], 'yellow'),
@@ -890,8 +977,8 @@ class ClientCommandProcessor(CommandProcessor):
self.ctx.found_items = toggle.lower() in {"1", "true", "on"}
else:
self.ctx.found_items = not self.ctx.found_items
logging.info(f"Set showing team items to {self.ctx.found_items}")
asyncio.create_task(send_msgs(self.ctx.socket, [['UpdateTags', get_tags(self.ctx)]]))
self.ctx.ui_node.log_info(f"Set showing team items to {self.ctx.found_items}")
asyncio.create_task(self.ctx.send_msgs([['UpdateTags', get_tags(self.ctx)]]))
return True
def _cmd_slow_mode(self, toggle: str = ""):
@@ -901,10 +988,13 @@ class ClientCommandProcessor(CommandProcessor):
else:
self.ctx.slow_mode = not self.ctx.slow_mode
logging.info(f"Setting slow mode to {self.ctx.slow_mode}")
self.ctx.ui_node.log_info(f"Setting slow mode to {self.ctx.slow_mode}")
def _cmd_web(self):
webbrowser.open(f'http://localhost:5050?port={self.ctx.webui_socket_port}')
def default(self, raw: str):
asyncio.create_task(send_msgs(self.ctx.socket, [['Say', raw]]))
asyncio.create_task(self.ctx.send_msgs([['Say', raw]]))
async def console_loop(ctx: Context):
@@ -930,9 +1020,10 @@ async def console_loop(ctx: Context):
async def track_locations(ctx : Context, roomid, roomdata):
new_locations = []
def new_check(location):
ctx.locations_checked.add(location)
logging.info("New check: %s (%d/216)" % (location, len(ctx.locations_checked)))
ctx.ui_node.log_info("New check: %s (%d/216)" % (location, len(ctx.locations_checked)))
new_locations.append(Regions.location_table[location][0])
for location, (loc_roomid, loc_mask) in location_table_uw.items():
@@ -987,7 +1078,8 @@ async def track_locations(ctx : Context, roomid, roomdata):
if misc_data[offset - 0x3c6] & mask != 0 and location not in ctx.locations_checked:
new_check(location)
await send_msgs(ctx.socket, [['LocationChecks', new_locations]])
await ctx.send_msgs([['LocationChecks', new_locations]])
async def game_watcher(ctx : Context):
prev_game_timer = 0
@@ -1015,8 +1107,8 @@ async def game_watcher(ctx : Context):
await server_auth(ctx, False)
if ctx.auth and ctx.auth != ctx.rom:
logging.warning("ROM change detected, please reconnect to the multiworld server")
await disconnect(ctx)
ctx.ui_node.log_warning("ROM change detected, please reconnect to the multiworld server")
await ctx.disconnect()
gamemode = await snes_read(ctx, WRAM_START + 0x10, 1)
gameend = await snes_read(ctx, SAVEDATA_START + 0x443, 1)
@@ -1029,7 +1121,7 @@ async def game_watcher(ctx : Context):
if gameend[0]:
if not ctx.finished_game:
try:
await send_msgs(ctx.socket, [['GameFinished', '']])
await ctx.send_msgs([['GameFinished', '']])
ctx.finished_game = True
except Exception as ex:
logging.exception(ex)
@@ -1064,6 +1156,9 @@ async def game_watcher(ctx : Context):
if recv_index < len(ctx.items_received) and recv_item == 0:
item = ctx.items_received[recv_index]
ctx.ui_node.notify_item_received(ctx.player_names[item.player], get_item_name_from_id(item.item),
get_location_name_from_address(item.location), recv_index + 1,
len(ctx.items_received))
logging.info('Received %s from %s (%s) (%d/%d in list)' % (
color(get_item_name_from_id(item.item), 'red', 'bold'), color(ctx.player_names[item.player], 'yellow'),
get_location_name_from_address(item.location), recv_index + 1, len(ctx.items_received)))
@@ -1080,8 +1175,8 @@ async def game_watcher(ctx : Context):
if scout_location > 0 and scout_location not in ctx.locations_scouted:
ctx.locations_scouted.add(scout_location)
logging.info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}')
await send_msgs(ctx.socket, [['LocationScouts', [scout_location]]])
ctx.ui_node.log_info(f'Scouting item at {list(Regions.location_table.keys())[scout_location - 1]}')
await ctx.send_msgs([['LocationScouts', [scout_location]]])
await track_locations(ctx, roomid, roomdata)
@@ -1090,7 +1185,57 @@ async def run_game(romfile):
webbrowser.open(romfile)
async def websocket_server(websocket: websockets.WebSocketServerProtocol, path, ctx: Context):
endpoint = Endpoint(websocket)
ctx.ui_node.endpoints.append(endpoint)
process_command = ClientCommandProcessor(ctx)
try:
async for incoming_data in websocket:
try:
data = json.loads(incoming_data)
if ('type' not in data) or ('content' not in data):
raise Exception('Invalid data received in websocket')
elif data['type'] == 'webStatus':
if data['content'] == 'connections':
ctx.ui_node.send_connection_status(ctx)
elif data['content'] == 'devices':
await snes_disconnect(ctx)
await snes_connect(ctx, ctx.snes_address, True)
elif data['type'] == 'webConfig':
if 'serverAddress' in data['content']:
ctx.server_address = data['content']['serverAddress']
await connect(ctx, data['content']['serverAddress'])
elif 'deviceId' in data['content']:
# Allow a SNES disconnect via UI sending -1 as new device
if data['content']['deviceId'] == "-1":
ctx.ui_node.manual_snes = None
ctx.snes_reconnect_address = None
await snes_disconnect(ctx)
return
await snes_disconnect(ctx)
ctx.ui_node.manual_snes = data['content']['deviceId']
await snes_connect(ctx, ctx.snes_address)
elif data['type'] == 'webControl':
if 'disconnect' in data['content']:
await ctx.disconnect()
elif data['type'] == 'webCommand':
process_command(data['content'])
except json.JSONDecodeError:
pass
except Exception as e:
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
await ctx.ui_node.disconnect(endpoint)
async def main():
multiprocessing.freeze_support()
parser = argparse.ArgumentParser()
parser.add_argument('diff_file', default="", type=str, nargs="?",
help='Path to a Berserker Multiworld Binary Patch file')
@@ -1098,11 +1243,23 @@ async def main():
parser.add_argument('--connect', default=None, help='Address of the multiworld host.')
parser.add_argument('--password', default=None, help='Password of the multiworld host.')
parser.add_argument('--loglevel', default='info', choices=['debug', 'info', 'warning', 'error', 'critical'])
parser.add_argument('--founditems', default=False, action='store_true', help='Show items found by other players for themselves.')
parser.add_argument('--founditems', default=False, action='store_true',
help='Show items found by other players for themselves.')
parser.add_argument('--disable_web_ui', default=False, action='store_true', help="Turn off emitting a webserver for the webbrowser based user interface.")
args = parser.parse_args()
logging.basicConfig(format='%(message)s', level=getattr(logging, args.loglevel.upper(), logging.INFO))
if not args.disable_web_ui:
# Find an available port on the host system to use for hosting the websocket server
while True:
port = randrange(5000, 5999)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if not sock.connect_ex(('localhost', port)) == 0:
break
import threading
WebUiServer.start_server(
port, on_start=threading.Timer(1, webbrowser.open, (f'http://localhost:5050?port={port}',)).start)
if args.diff_file:
import Patch
logging.info("Patch file was supplied. Creating sfc rom..")
@@ -1130,26 +1287,26 @@ async def main():
logging.info("Skipping post-patch adjustment")
asyncio.create_task(run_game(romfile))
ctx = Context(args.snes, args.connect, args.password, args.founditems)
ctx = Context(args.snes, args.connect, args.password, args.founditems, port)
input_task = asyncio.create_task(console_loop(ctx), name="Input")
await snes_connect(ctx, ctx.snes_address)
if not args.disable_web_ui:
ui_socket = websockets.serve(functools.partial(websocket_server, ctx=ctx),
'localhost', port, ping_timeout=None, ping_interval=None)
await ui_socket
if ctx.server_task is None:
ctx.server_task = asyncio.create_task(server_loop(ctx), name="ServerLoop")
watcher_task = asyncio.create_task(game_watcher(ctx), name="GameWatcher")
await ctx.exit_event.wait()
ctx.server_address = None
ctx.snes_reconnect_address = None
await watcher_task
if ctx.socket is not None and not ctx.socket.closed:
await ctx.socket.close()
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