* 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
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
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

View File

@ -25,6 +25,7 @@ import Items
import Regions
import Utils
from Utils import get_item_name_from_id, get_location_name_from_address, ReceivedItem
from NetUtils import Node, Endpoint
console_names = frozenset(set(Items.item_table) | set(Regions.location_table))
@ -32,12 +33,12 @@ CLIENT_PLAYING = 0
CLIENT_GOAL = 1
class Client:
class Client(Endpoint):
version: typing.List[int] = [0, 0, 0]
tags: typing.List[str] = []
def __init__(self, socket: websockets.server.WebSocketServerProtocol, ctx: Context):
self.socket = socket
super().__init__(socket)
self.auth = False
self.name = None
self.team = None
@ -49,21 +50,15 @@ class Client:
self.ctx = weakref.ref(ctx)
ctx.client_connection_timers[self.team, self.slot] = datetime.datetime.now(datetime.timezone.utc)
async def disconnect(self):
ctx = self.ctx()
if ctx:
await on_client_disconnected(ctx, self)
ctx.clients.remove(self)
ctx.client_connection_timers[self.team, self.slot] = datetime.datetime.now(datetime.timezone.utc)
@property
def wants_item_notification(self):
return self.auth and "FoundItems" in self.tags
class Context:
class Context(Node):
def __init__(self, host: str, port: int, password: str, location_check_points: int, hint_cost: int,
item_cheat: bool, forfeit_mode: str = "disabled", remaining_mode: str = "disabled"):
super(Context, self).__init__()
self.data_filename = None
self.save_filename = None
self.disable_save = False
@ -76,7 +71,6 @@ class Context:
self.password = password
self.server = None
self.countdown_timer = 0
self.clients = []
self.received_items = {}
self.name_aliases: typing.Dict[typing.Tuple[int, int], str] = {}
self.location_checks = collections.defaultdict(set)
@ -143,57 +137,30 @@ class Context:
else:
return self.player_names[team, slot]
def notify_all(self, text):
logging.info("Notice (all): %s" % text)
self.broadcast_all([['Print', text]])
async def send_msgs(client: Client, msgs):
websocket = client.socket
if not websocket or not websocket.open or websocket.closed:
return
try:
await websocket.send(json.dumps(msgs))
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
await client.disconnect()
def notify_client(self, client: Client, text: str):
if not client.auth:
return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
asyncio.create_task(self.send_msgs(client, [['Print', text]]))
def broadcast_team(self, team, msgs):
for client in self.endpoints:
if client.auth and client.team == team:
asyncio.create_task(self.send_msgs(client, msgs))
async def send_json_msgs(client: Client, msg: str):
websocket = client.socket
if not websocket or not websocket.open or websocket.closed:
return
try:
await websocket.send(msg)
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
await client.disconnect()
def broadcast_all(self, msgs):
msgs = json.dumps(msgs)
for endpoint in self.endpoints:
if endpoint.auth:
asyncio.create_task(self.send_json_msgs(endpoint, msgs))
def broadcast_all(ctx: Context, msgs):
msgs = json.dumps(msgs)
for client in ctx.clients:
if client.auth:
asyncio.create_task(send_json_msgs(client, msgs))
def broadcast_team(ctx: Context, team, msgs):
msgs = json.dumps(msgs)
for client in ctx.clients:
if client.auth and client.team == team:
asyncio.create_task(send_json_msgs(client, msgs))
def notify_all(ctx : Context, text):
logging.info("Notice (all): %s" % text)
broadcast_all(ctx, [['Print', text]])
def notify_team(ctx: Context, team: int, text: str):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
broadcast_team(ctx, team, [['Print', text]])
def notify_client(client: Client, text: str):
if not client.auth:
return
logging.info("Notice (Player %s in team %d): %s" % (client.name, client.team + 1, text))
asyncio.create_task(send_msgs(client, [['Print', text]]))
async def disconnect(self, endpoint):
await super(Context, self).disconnect(endpoint)
await on_client_disconnected(self, endpoint)
# separated out, due to compatibilty between clients
@ -203,13 +170,13 @@ def notify_hints(ctx: Context, team: int, hints: typing.List[Utils.Hint]):
for _, text in texts:
logging.info("Notice (Team #%d): %s" % (team + 1, text))
texts = json.dumps(texts)
for client in ctx.clients:
for client in ctx.endpoints:
if client.auth and client.team == team:
if "Berserker" in client.tags and client.version >= [2, 2, 1]:
payload = cmd
else:
payload = texts
asyncio.create_task(send_json_msgs(client, payload))
asyncio.create_task(ctx.send_json_msgs(client, payload))
def update_aliases(ctx: Context, team: int, client: typing.Optional[Client] = None):
@ -217,16 +184,16 @@ def update_aliases(ctx: Context, team: int, client: typing.Optional[Client] = No
[(key[1], ctx.get_aliased_name(*key)) for key, value in ctx.player_names.items() if
key[0] == team]]])
if client is None:
for client in ctx.clients:
for client in ctx.endpoints:
if client.team == team and client.auth and client.version > [2, 0, 3]:
asyncio.create_task(send_json_msgs(client, cmd))
asyncio.create_task(ctx.send_json_msgs(client, cmd))
else:
asyncio.create_task(send_json_msgs(client, cmd))
asyncio.create_task(ctx.send_json_msgs(client, cmd))
async def server(websocket, path, ctx: Context):
client = Client(websocket, ctx)
ctx.clients.append(client)
ctx.endpoints.append(client)
try:
await on_client_connected(ctx, client)
@ -243,13 +210,14 @@ async def server(websocket, path, ctx: Context):
if not isinstance(e, websockets.WebSocketException):
logging.exception(e)
finally:
await client.disconnect()
await ctx.disconnect(client)
async def on_client_connected(ctx: Context, client: Client):
await send_msgs(client, [['RoomInfo', {
await ctx.send_msgs(client, [['RoomInfo', {
'password': ctx.password is not None,
'players': [(client.team, client.slot, ctx.name_aliases.get((client.team, client.slot), client.name)) for client
in ctx.clients if client.auth],
in ctx.endpoints if client.auth],
# tags are for additional features in the communication.
# Name them by feature or fork, as you feel is appropriate.
'tags': ['Berserker'],
@ -260,35 +228,46 @@ async def on_client_connected(ctx: Context, client: Client):
'location_check_points': ctx.location_check_points
}]])
async def on_client_disconnected(ctx: Context, client: Client):
if client.auth:
await on_client_left(ctx, client)
async def on_client_joined(ctx: Context, client: Client):
notify_all(ctx,
"%s (Team #%d) has joined the game. Client(%s, %s)." % (ctx.get_aliased_name(client.team, client.slot),
client.team + 1,
".".join(str(x) for x in client.version),
client.tags))
ctx.notify_all(
"%s (Team #%d) has joined the game. Client(%s, %s)." % (ctx.get_aliased_name(client.team, client.slot),
client.team + 1,
".".join(str(x) for x in client.version),
client.tags))
async def on_client_left(ctx: Context, client: Client):
notify_all(ctx, "%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.notify_all("%s (Team #%d) has left the game" % (ctx.get_aliased_name(client.team, client.slot), client.team + 1))
ctx.client_connection_timers[client.team, client.slot] = datetime.datetime.now(datetime.timezone.utc)
async def countdown(ctx: Context, timer):
notify_all(ctx, f'[Server]: Starting countdown of {timer}s')
ctx.notify_all(f'[Server]: Starting countdown of {timer}s')
if ctx.countdown_timer:
ctx.countdown_timer = timer # timer is already running, set it to a different time
else:
ctx.countdown_timer = timer
while ctx.countdown_timer > 0:
notify_all(ctx, f'[Server]: {ctx.countdown_timer}')
ctx.notify_all(f'[Server]: {ctx.countdown_timer}')
ctx.countdown_timer -= 1
await asyncio.sleep(1)
notify_all(ctx, f'[Server]: GO')
ctx.notify_all(f'[Server]: GO')
async def missing(ctx: Context, client: Client, locations: list):
await ctx.send_msgs(client, [['Missing', {
'locations': json.dumps(locations)
}]])
def get_players_string(ctx: Context):
auth_clients = {(c.team, c.slot) for c in ctx.clients if c.auth}
auth_clients = {(c.team, c.slot) for c in ctx.endpoints if c.auth}
player_names = sorted(ctx.player_names.keys())
current_team = -1
@ -314,19 +293,19 @@ def tuplize_received_items(items):
def send_new_items(ctx: Context):
for client in ctx.clients:
for client in ctx.endpoints:
if not client.auth:
continue
items = get_received_items(ctx, client.team, client.slot)
if len(items) > client.send_index:
asyncio.create_task(send_msgs(client, [
asyncio.create_task(ctx.send_msgs(client, [
['ReceivedItems', (client.send_index, tuplize_received_items(items)[client.send_index:])]]))
client.send_index = len(items)
def forfeit_player(ctx: Context, team: int, slot: int):
all_locations = {values[0] for values in Regions.location_table.values() if type(values[0]) is int}
notify_all(ctx, "%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
ctx.notify_all("%s (Team #%d) has forfeited" % (ctx.player_names[(team, slot)], team + 1))
register_location_checks(ctx, team, slot, all_locations)
@ -337,6 +316,7 @@ def get_remaining(ctx: Context, team: int, slot: int) -> typing.List[int]:
items.append(ctx.locations[location, slot][0]) # item ID
return sorted(items)
def register_location_checks(ctx: Context, team: int, slot: int, locations):
found_items = False
new_locations = set(locations) - ctx.location_checks[team, slot]
@ -357,17 +337,17 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations):
new_item = ReceivedItem(target_item, location, slot)
recvd_items.append(new_item)
if slot != target_player:
broadcast_team(ctx,team, [['ItemSent', (slot, location, target_player, target_item)]])
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(target_item),
ctx.player_names[(team, target_player)], get_location_name_from_address(location)))
found_items = True
ctx.broadcast_team(team, [['ItemSent', (slot, location, target_player, target_item)]])
logging.info('(Team #%d) %s sent %s to %s (%s)' % (
team + 1, ctx.player_names[(team, slot)], get_item_name_from_id(target_item),
ctx.player_names[(team, target_player)], get_location_name_from_address(location)))
found_items = True
elif target_player == slot: # local pickup, notify clients of the pickup
if location not in ctx.location_checks[team, slot]:
for client in ctx.clients:
if client.team == team and client.wants_item_notification:
asyncio.create_task(
send_msgs(client, [['ItemFound', (target_item, location, slot)]]))
for client in ctx.endpoints:
if client.team == team and client.wants_item_notification:
asyncio.create_task(
ctx.send_msgs(client, [['ItemFound', (target_item, location, slot)]]))
ctx.location_checks[team, slot] |= new_locations
send_new_items(ctx)
@ -375,6 +355,11 @@ def register_location_checks(ctx: Context, team: int, slot: int, locations):
save(ctx)
def notify_team(ctx: Context, team: int, text: str):
logging.info("Notice (Team #%d): %s" % (team + 1, text))
ctx.broadcast_team(team, [['Print', text]])
def save(ctx: Context):
if not ctx.disable_save:
try:
@ -476,7 +461,6 @@ class CommandProcessor(metaclass=CommandMeta):
method = self.commands.get(basecommand[1:].lower(), None)
if not method:
self._error_unknown_command(basecommand[1:])
return False
else:
if getattr(method, "raw_text", False): # method is requesting unprocessed text data
arg = raw.split(maxsplit=1)
@ -516,11 +500,11 @@ class CommandProcessor(metaclass=CommandMeta):
def _cmd_license(self):
"""Returns the licensing information"""
mw_license = getattr(CommandProcessor, "license", None)
if not mw_license:
license = getattr(CommandProcessor, "license", None)
if not license:
with open(Utils.local_path("LICENSE")) as f:
CommandProcessor.license = mw_license = f.read()
self.output(mw_license)
CommandProcessor.license = license = f.read()
self.output(CommandProcessor.license)
def default(self, raw: str):
self.output("Echo: " + raw)
@ -541,7 +525,7 @@ class ClientMessageProcessor(CommandProcessor):
self.client = client
def output(self, text):
notify_client(self.client, text)
self.ctx.notify_client(self.client, text)
def default(self, raw: str):
pass # default is client sending just text
@ -549,7 +533,7 @@ class ClientMessageProcessor(CommandProcessor):
def _cmd_players(self) -> bool:
"""Get information about connected and missing players"""
if len(self.ctx.player_names) < 10:
notify_all(self.ctx, get_players_string(self.ctx))
self.ctx.notify_all(get_players_string(self.ctx))
else:
self.output(get_players_string(self.ctx))
return True
@ -618,15 +602,13 @@ class ClientMessageProcessor(CommandProcessor):
def _cmd_missing(self) -> bool:
"""List all missing location checks from the server's perspective"""
buffer = "" # try not to spam small packets over network
count = 0
locations = []
for location_id, location_name in Regions.lookup_id_to_name.items(): # cheat console is -1, keep in mind
if location_id != -1 and location_id not in self.ctx.location_checks[self.client.team, self.client.slot]:
buffer += f'Missing: {location_name}\n'
count += 1
locations.append(location_name)
if buffer:
self.output(buffer + f"Found {count} missing location checks")
if len(locations) > 0:
asyncio.create_task(missing(self.ctx, self.client, locations))
else:
self.output("No missing location checks found.")
return True
@ -656,7 +638,7 @@ class ClientMessageProcessor(CommandProcessor):
if usable:
new_item = ReceivedItem(Items.item_table[item_name][3], -1, self.client.slot)
get_received_items(self.ctx, self.client.team, self.client.slot).append(new_item)
notify_all(self.ctx, 'Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, self.client.slot))
self.ctx.notify_all('Cheat console: sending "' + item_name + '" to ' + self.ctx.get_aliased_name(self.client.team, self.client.slot))
send_new_items(self.ctx)
return True
else:
@ -732,14 +714,14 @@ class ClientMessageProcessor(CommandProcessor):
async def process_client_cmd(ctx: Context, client: Client, cmd, args):
if type(cmd) is not str:
await send_msgs(client, [['InvalidCmd']])
await ctx.send_msgs(client, [['InvalidCmd']])
return
if cmd == 'Connect':
if not args or type(args) is not dict or \
'password' not in args or type(args['password']) not in [str, type(None)] or \
'rom' not in args or type(args['rom']) is not list:
await send_msgs(client, [['InvalidArguments', 'Connect']])
await ctx.send_msgs(client, [['InvalidArguments', 'Connect']])
return
errors = set()
@ -750,7 +732,7 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
errors.add('InvalidRom')
else:
team, slot = ctx.rom_names[tuple(args['rom'])]
if any([c.slot == slot and c.team == team for c in ctx.clients if c.auth]):
if any([c.slot == slot and c.team == team for c in ctx.endpoints if c.auth]):
errors.add('SlotAlreadyTaken')
else:
client.name = ctx.player_names[(team, slot)]
@ -758,7 +740,7 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
client.slot = slot
if errors:
await send_msgs(client, [['ConnectionRefused', list(errors)]])
await ctx.send_msgs(client, [['ConnectionRefused', list(errors)]])
else:
client.auth = True
client.version = args.get('version', Client.version)
@ -770,7 +752,7 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
if items:
reply.append(['ReceivedItems', (0, tuplize_received_items(items))])
client.send_index = len(items)
await send_msgs(client, reply)
await ctx.send_msgs(client, reply)
await on_client_joined(ctx, client)
if client.auth:
@ -778,22 +760,22 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
items = get_received_items(ctx, client.team, client.slot)
if items:
client.send_index = len(items)
await send_msgs(client, [['ReceivedItems', (0, tuplize_received_items(items))]])
await ctx.send_msgs(client, [['ReceivedItems', (0, tuplize_received_items(items))]])
elif cmd == 'LocationChecks':
if type(args) is not list:
await send_msgs(client, [['InvalidArguments', 'LocationChecks']])
await ctx.send_msgs(client, [['InvalidArguments', 'LocationChecks']])
return
register_location_checks(ctx, client.team, client.slot, args)
elif cmd == 'LocationScouts':
if type(args) is not list:
await send_msgs(client, [['InvalidArguments', 'LocationScouts']])
await ctx.send_msgs(client, [['InvalidArguments', 'LocationScouts']])
return
locs = []
for location in args:
if type(location) is not int or 0 >= location > len(Regions.location_table):
await send_msgs(client, [['InvalidArguments', 'LocationScouts']])
await ctx.send_msgs(client, [['InvalidArguments', 'LocationScouts']])
return
loc_name = list(Regions.location_table.keys())[location - 1]
target_item, target_player = ctx.locations[(Regions.location_table[loc_name][0], client.slot)]
@ -806,32 +788,31 @@ async def process_client_cmd(ctx: Context, client: Client, cmd, args):
locs.append([loc_name, location, target_item, target_player])
# logging.info(f"{client.name} in team {client.team+1} scouted {', '.join([l[0] for l in locs])}")
await send_msgs(client, [['LocationInfo', [l[1:] for l in locs]]])
await ctx.send_msgs(client, [['LocationInfo', [l[1:] for l in locs]]])
elif cmd == 'UpdateTags':
if not args or type(args) is not list:
await send_msgs(client, [['InvalidArguments', 'UpdateTags']])
await ctx.send_msgs(client, [['InvalidArguments', 'UpdateTags']])
return
client.tags = args
elif cmd == 'GameFinished':
if ctx.client_game_state[client.team, client.slot] != CLIENT_GOAL:
finished_msg = f'{ctx.get_aliased_name(client.team, client.slot)} (Team #{client.team + 1}) has found the triforce.'
notify_all(ctx, finished_msg)
ctx.notify_all(finished_msg)
ctx.client_game_state[client.team, client.slot] = CLIENT_GOAL
if "auto" in ctx.forfeit_mode:
forfeit_player(ctx, client.team, client.slot)
if cmd == 'Say':
if type(args) is not str or not args.isprintable():
await send_msgs(client, [['InvalidArguments', 'Say']])
await ctx.send_msgs(client, [['InvalidArguments', 'Say']])
return
notify_all(ctx, ctx.get_aliased_name(client.team, client.slot) + ': ' + args)
ctx.notify_all(ctx.get_aliased_name(client.team, client.slot) + ': ' + args)
client.messageprocessor(args)
def set_password(ctx: Context, password):
ctx.password = password
logging.warning('Password set to ' + password if password else 'Password disabled')
@ -845,12 +826,12 @@ class ServerCommandProcessor(CommandProcessor):
super(ServerCommandProcessor, self).__init__()
def default(self, raw: str):
notify_all(self.ctx, '[Server]: ' + raw)
self.ctx.notify_all('[Server]: ' + raw)
@mark_raw
def _cmd_kick(self, player_name: str) -> bool:
"""Kick specified player from the server"""
for client in self.ctx.clients:
for client in self.ctx.endpoints:
if client.auth and client.name.lower() == player_name.lower() and client.socket and not client.socket.closed:
asyncio.create_task(client.socket.close())
self.output(f"Kicked {self.ctx.get_aliased_name(client.team, client.slot)}")
@ -925,11 +906,11 @@ class ServerCommandProcessor(CommandProcessor):
item = " ".join(item_name)
item, usable, response = get_intended_text(item, Items.item_table.keys())
if usable:
for client in self.ctx.clients:
for client in self.ctx.endpoints:
if client.name == seeked_player:
new_item = ReceivedItem(Items.item_table[item][3], -1, client.slot)
get_received_items(self.ctx, client.team, client.slot).append(new_item)
notify_all(self.ctx, 'Cheat console: sending "' + item + '" to ' + self.ctx.get_aliased_name(client.team, client.slot))
self.ctx.notify_all('Cheat console: sending "' + item + '" to ' + self.ctx.get_aliased_name(client.team, client.slot))
send_new_items(self.ctx)
return True
else:

51
NetUtils.py Normal file
View File

@ -0,0 +1,51 @@
from __future__ import annotations
import asyncio
import json
import logging
import typing
import websockets
class Node:
endpoints: typing.List
def __init__(self):
self.endpoints = []
def broadcast_all(self, msgs):
msgs = json.dumps(msgs)
for endpoint in self.endpoints:
asyncio.create_task(self.send_json_msgs(endpoint, msgs))
async def send_msgs(self, endpoint: Endpoint, msgs):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
return
try:
await endpoint.socket.send(json.dumps(msgs))
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
await self.disconnect(endpoint)
async def send_json_msgs(self, endpoint: Endpoint, msg: str):
if not endpoint.socket or not endpoint.socket.open or endpoint.socket.closed:
return
try:
await endpoint.socket.send(msg)
except websockets.ConnectionClosed:
logging.exception("Exception during send_msgs")
await self.disconnect(endpoint)
async def disconnect(self, endpoint):
if endpoint in self.endpoints:
self.endpoints.remove(endpoint)
class Endpoint:
socket: websockets.WebSocketServerProtocol
def __init__(self, socket):
self.socket = socket
async def disconnect(self):
raise NotImplementedError

106
WebUiClient.py Normal file
View File

@ -0,0 +1,106 @@
import logging
from NetUtils import Node
from MultiClient import Context
import Utils
logger = logging.getLogger("WebUIRelay")
class WebUiClient(Node):
def __init__(self):
super().__init__()
self.manual_snes = None
@staticmethod
def build_message(msg_type: str, content: dict) -> dict:
return {'type': msg_type, 'content': content}
def log_info(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('info', message))
logger.info(message, *args, **kwargs)
def log_warning(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('warning', message))
logger.warning(message, *args, **kwargs)
def log_error(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('error', message))
logger.error(message, *args, **kwargs)
def log_critical(self, message, *args, **kwargs):
self.broadcast_all(self.build_message('critical', message))
logger.critical(message, *args, **kwargs)
def send_chat_message(self, message):
self.broadcast_all(self.build_message('chat', message))
def send_connection_status(self, ctx: Context):
cache = Utils.persistent_load()
cached_address = cache["servers"]["default"] if cache else None
server_address = ctx.server_address if ctx.server_address else cached_address if cached_address else None
self.broadcast_all(self.build_message('connections', {
'snesDevice': ctx.snes_attached_device[1] if ctx.snes_attached_device else None,
'snes': ctx.snes_state,
'serverAddress': server_address,
'server': 1 if ctx.server is not None and not ctx.server.socket.closed else 0,
}))
def send_device_list(self, devices):
self.broadcast_all(self.build_message('availableDevices', {
'devices': devices,
}))
def poll_for_server_ip(self):
self.broadcast_all(self.build_message('serverAddress', {}))
def notify_item_sent(self, finder, recipient, item, location, i_am_finder: bool, i_am_recipient: bool):
self.broadcast_all(self.build_message('itemSent', {
'finder': finder,
'recipient': recipient,
'item': item,
'location': location,
'iAmFinder': 1 if i_am_finder else 0,
'iAmRecipient': 1 if i_am_recipient else 0,
}))
def notify_item_found(self, finder: str, item: str, location: str, i_am_finder: bool):
self.broadcast_all(self.build_message('itemFound', {
'finder': finder,
'item': item,
'location': location,
'iAmFinder': 1 if i_am_finder else 0,
}))
def notify_item_received(self, finder: str, item: str, location: str, item_index: int, queue_length: int):
self.broadcast_all(self.build_message('itemReceived', {
'finder': finder,
'item': item,
'location': location,
'itemIndex': item_index,
'queueLength': queue_length,
}))
def send_hint(self, finder, recipient, item, location, found, i_am_finder: bool, i_am_recipient: bool,
entrance_location: str = None):
self.broadcast_all(self.build_message('hint', {
'finder': finder,
'recipient': recipient,
'item': item,
'location': location,
'found': int(found),
'iAmFinder': int(i_am_finder),
'iAmRecipient': int(i_am_recipient),
'entranceLocation': entrance_location,
}))
def send_game_state(self, ctx: Context):
self.broadcast_all(self.build_message('gameState', {
'hintCost': 0,
'checkPoints': 0,
'playerPoints': 0,
}))
class WaitingForUiException(Exception):
pass

43
WebUiServer.py Normal file
View File

@ -0,0 +1,43 @@
import http.server
import socketserver
import os
import socket
import threading
from functools import partial
import webbrowser
import Utils
webthread = None
PORT = 5050
Handler = partial(http.server.SimpleHTTPRequestHandler, directory=Utils.local_path(os.path.join("data", "web", "public")))
def start_server(socket_port: int, on_start=lambda: None):
global webthread
try:
server = socketserver.TCPServer(("", PORT), Handler)
except OSError:
# In most cases "Only one usage of each socket address (protocol/network address/port) is normally permitted"
import logging
# If the exception is caused by our desired port being unavailable, assume the web server is already running
# from another client instance
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
if sock.connect_ex(('localhost', PORT)) == 0:
logging.info("Web server is already running in another client window.")
webbrowser.open(f'http://localhost:{PORT}?port={socket_port}')
return
# If the exception is caused by something else, report on it
logging.exception("Unable to bind port for local web server. The CLI client should work in all cases.")
else:
print("serving at port", PORT)
on_start()
webthread = threading.Thread(target=server.serve_forever).start()
if __name__ == "__main__":
start_server(5090)

4
data/web/.babelrc Normal file
View File

@ -0,0 +1,4 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"plugins": ["@babel/plugin-proposal-class-properties"]
}

39
data/web/.eslintrc.js Normal file
View File

@ -0,0 +1,39 @@
module.exports = {
env: {
browser: true,
es6: true,
},
extends: [
'plugin:react/recommended',
'airbnb',
],
parser: 'babel-eslint',
globals: {
Atomics: 'readonly',
SharedArrayBuffer: 'readonly',
},
parserOptions: {
ecmaFeatures: {
jsx: true,
},
ecmaVersion: 2018,
sourceType: 'module',
},
plugins: [
'react',
],
rules: {
"react/jsx-filename-extension": 0,
"react/jsx-one-expression-per-line": 0,
"react/destructuring-assignment": 0,
"react/jsx-curly-spacing": [2, { "when": "always" }],
"react/prop-types": 0,
"react/button-has-type": 0,
"max-len": [2, { code: 120 }],
"operator-linebreak": [2, "after"],
"no-console": [2, { allow: ["error", "warn"] }],
"linebreak-style": 0,
"jsx-a11y/no-static-element-interactions": 0,
"jsx-a11y/click-events-have-key-events": 0,
},
};

2
data/web/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
node_modules
*.map

8295
data/web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

47
data/web/package.json Normal file
View File

@ -0,0 +1,47 @@
{
"name": "web-ui",
"version": "1.0.0",
"description": "",
"main": "index.jsx",
"scripts": {
"build": "webpack --mode production --config webpack.config.js",
"dev": "webpack --mode development --config webpack.dev.js --watch"
},
"author": "LegendaryLinux",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.28",
"@fortawesome/free-solid-svg-icons": "^5.13.0",
"@fortawesome/react-fontawesome": "^0.1.9",
"crypto-js": "^4.0.0",
"css-loader": "^3.5.3",
"lodash-es": "^4.17.15",
"prop-types": "^15.7.2",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-redux": "^7.2.0",
"react-router-dom": "^5.1.2",
"redux": "^4.0.5",
"redux-devtools-extension": "^2.13.8",
"sass-loader": "^8.0.2",
"style-loader": "^1.2.1",
"webpack-cli": "^3.3.11"
},
"devDependencies": {
"@babel/core": "^7.9.6",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/preset-env": "^7.9.6",
"@babel/preset-react": "^7.9.4",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0",
"eslint": "^6.8.0",
"eslint-config-airbnb": "^18.1.0",
"eslint-plugin-import": "^2.20.2",
"eslint-plugin-jsx-a11y": "^6.2.3",
"eslint-plugin-react": "^7.19.0",
"eslint-plugin-react-hooks": "^2.5.1",
"file-loader": "^6.0.0",
"node-sass": "^4.14.0",
"webpack": "^4.43.0"
}
}

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Berserker Multiworld Web GUI</title>
<script type="application/ecmascript" src="assets/index.bundle.js"></script>
</head>
<body>
<div id="app">
<!-- Populated by React/JSX -->
</div>
</body>
</html>

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

View File

@ -0,0 +1,10 @@
import React from 'react';
import '../../../styles/HeaderBar/components/HeaderBar.scss';
const HeaderBar = () => (
<div id="header-bar">
Multiworld WebUI
</div>
);
export default HeaderBar;

View File

@ -0,0 +1,8 @@
const APPEND_MESSAGE = 'APPEND_MESSAGE';
const appendMessage = (content) => ({
type: APPEND_MESSAGE,
content,
});
export default appendMessage;

View File

@ -0,0 +1,8 @@
const SET_MONITOR_FONT_SIZE = 'SET_MONITOR_FONT_SIZE';
const setMonitorFontSize = (fontSize) => ({
type: SET_MONITOR_FONT_SIZE,
fontSize,
});
export default setMonitorFontSize;

View File

@ -0,0 +1,8 @@
const SET_SHOW_RELEVANT = 'SET_SHOW_RELEVANT';
const setShowRelevant = (showRelevant) => ({
type: SET_SHOW_RELEVANT,
showRelevant,
});
export default setShowRelevant;

View File

@ -0,0 +1,36 @@
import _assign from 'lodash-es/assign';
const initialState = {
fontSize: 18,
showRelevantOnly: false,
messageLog: [],
};
const appendToLog = (log, item) => {
const trimmedLog = log.slice(-349);
trimmedLog.push(item);
return trimmedLog;
};
const monitorReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_MONITOR_FONT_SIZE':
return _assign({}, state, {
fontSize: action.fontSize,
});
case 'SET_SHOW_RELEVANT':
return _assign({}, state, {
showRelevantOnly: action.showRelevant,
});
case 'APPEND_MESSAGE':
return _assign({}, state, {
messageLog: appendToLog(state.messageLog, action.content),
});
default:
return state;
}
};
export default monitorReducer;

View File

@ -0,0 +1,13 @@
import React from 'react';
import '../../../styles/Monitor/components/Monitor.scss';
import MonitorControls from '../containers/MonitorControls';
import MonitorWindow from '../containers/MonitorWindow';
const Monitor = () => (
<div id="monitor">
<MonitorControls />
<MonitorWindow />
</div>
);
export default Monitor;

View File

@ -0,0 +1,187 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import _forEach from 'lodash-es/forEach';
import WebSocketUtils from '../../global/WebSocketUtils';
import '../../../styles/Monitor/containers/MonitorControls.scss';
// Redux actions
import setMonitorFontSize from '../Redux/actions/setMonitorFontSize';
import setShowRelevant from '../Redux/actions/setShowRelevant';
const mapReduxStateToProps = (reduxState) => ({
fontSize: reduxState.monitor.fontSize,
webSocket: reduxState.webUI.webSocket,
availableDevices: reduxState.webUI.availableDevices,
snesDevice: reduxState.gameState.connections.snesDevice,
snesConnected: reduxState.gameState.connections.snesConnected,
serverAddress: reduxState.gameState.connections.serverAddress,
serverConnected: reduxState.gameState.connections.serverConnected,
});
const mapDispatchToProps = (dispatch) => ({
updateFontSize: (fontSize) => {
dispatch(setMonitorFontSize(fontSize));
},
doToggleRelevance: (showRelevantOnly) => {
dispatch(setShowRelevant(showRelevantOnly));
},
});
class MonitorControls extends Component {
constructor(props) {
super(props);
this.state = {
deviceId: null,
serverAddress: this.props.serverAddress,
};
}
componentDidMount() {
setTimeout(() => {
if (this.props.webSocket) {
// Poll for available devices
this.pollSnesDevices();
setTimeout(() => {
if (this.props.availableDevices.length === 1) {
this.setState({ deviceId: this.props.availableDevices[0] }, () => {
if (!this.props.snesConnected) {
this.connectToSnes();
}
});
}
}, 500);
}
}, 500);
}
increaseTextSize = () => {
if (this.props.fontSize >= 25) return;
this.props.updateFontSize(this.props.fontSize + 1);
};
decreaseTextSize = () => {
if (this.props.fontSize <= 10) return;
this.props.updateFontSize(this.props.fontSize - 1);
};
generateSnesOptions = () => {
const options = [];
// No available devices, show waiting for devices
if (this.props.availableDevices.length === 0) {
options.push(<option key="0" value="-1">Waiting for devices...</option>);
return options;
}
// More than one available device, list all options
options.push(<option key="-1" value="-1">Select a device</option>);
_forEach(this.props.availableDevices, (device) => {
options.push(<option key={ device } value={ device }>{device}</option>);
});
return options;
}
updateDeviceId = (event) => this.setState({ deviceId: event.target.value }, this.connectToSnes);
pollSnesDevices = () => {
if (!this.props.webSocket) { return; }
this.props.webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'devices'));
}
connectToSnes = () => {
if (!this.props.webSocket) { return; }
this.props.webSocket.send(WebSocketUtils.formatSocketData('webConfig', { deviceId: this.state.deviceId }));
}
updateServerAddress = (event) => this.setState({ serverAddress: event.target.value ? event.target.value : null });
connectToServer = (event) => {
if (event.key !== 'Enter') { return; }
// If the user presses enter on an empty textbox, disconnect from the server
if (!event.target.value) {
this.props.webSocket.send(WebSocketUtils.formatSocketData('webControl', 'disconnect'));
return;
}
this.props.webSocket.send(
WebSocketUtils.formatSocketData('webConfig', { serverAddress: this.state.serverAddress }),
);
}
toggleRelevance = (event) => {
this.props.doToggleRelevance(event.target.checked);
};
render() {
return (
<div id="monitor-controls">
<div id="connection-status">
<div id="snes-connection">
<table>
<tbody>
<tr>
<td>SNES Device:</td>
<td>
<select
onChange={ this.updateDeviceId }
disabled={ this.props.availableDevices.length === 0 }
value={ this.state.deviceId }
>
{this.generateSnesOptions()}
</select>
</td>
</tr>
<tr>
<td>Status:</td>
<td>
<span className={ this.props.snesConnected ? 'connected' : 'not-connected' }>
{this.props.snesConnected ? 'Connected' : 'Not Connected'}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div id="server-connection">
<table>
<tbody>
<tr>
<td>Server:</td>
<td>
<input
defaultValue={ this.props.serverAddress }
onKeyUp={ this.updateServerAddress }
onKeyDown={ this.connectToServer }
/>
</td>
</tr>
<tr>
<td>Status:</td>
<td>
<span className={ this.props.serverConnected ? 'connected' : 'not-connected' }>
{this.props.serverConnected ? 'Connected' : 'Not Connected'}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="accessibility">
<div>
Text Size:
<button disabled={ this.props.fontSize <= 10 } onClick={ this.decreaseTextSize }>-</button>
{ this.props.fontSize }
<button disabled={ this.props.fontSize >= 25 } onClick={ this.increaseTextSize }>+</button>
</div>
<div>
Only show my items <input type="checkbox" onChange={ this.toggleRelevance } />
</div>
</div>
</div>
);
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(MonitorControls);

View File

@ -0,0 +1,96 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import md5 from 'crypto-js/md5';
import WebSocketUtils from '../../global/WebSocketUtils';
import '../../../styles/Monitor/containers/MonitorWindow.scss';
// Redux actions
import appendMessage from '../Redux/actions/appendMessage';
const mapReduxStateToProps = (reduxState) => ({
fontSize: reduxState.monitor.fontSize,
webSocket: reduxState.webUI.webSocket,
messageLog: reduxState.monitor.messageLog,
showRelevantOnly: reduxState.monitor.showRelevantOnly,
});
const mapDispatchToProps = (dispatch) => ({
doAppendMessage: (message) => dispatch(appendMessage(
<div
key={ `${md5(message)}${Math.floor((Math.random() * 1000000))}` }
className="user-command relevant"
>
{message}
</div>,
)),
});
class MonitorWindow extends Component {
constructor(props) {
super(props);
this.monitorRef = React.createRef();
this.commandRef = React.createRef();
this.commandInputRef = React.createRef();
}
componentDidMount() {
// Adjust the monitor height to match user's viewport
this.adjustMonitorHeight();
// Resize the monitor as the user adjusts the window size
window.addEventListener('resize', this.adjustMonitorHeight);
}
componentDidUpdate() {
this.monitorRef.current.style.fontSize = `${this.props.fontSize}px`;
this.adjustMonitorHeight();
}
componentWillUnmount() {
// If one day we have different components occupying the main viewport, let us not attempt to
// perform actions on an unmounted component
window.removeEventListener('resize', this.adjustMonitorHeight);
}
adjustMonitorHeight = () => {
const monitorDimensions = this.monitorRef.current.getBoundingClientRect();
const commandDimensions = this.commandRef.current.getBoundingClientRect();
// Set monitor height
const newMonitorHeight = window.innerHeight - monitorDimensions.top - commandDimensions.height - 30;
this.monitorRef.current.style.height = `${newMonitorHeight}px`;
this.scrollToBottom();
};
scrollToBottom = () => {
this.monitorRef.current.scrollTo(0, this.monitorRef.current.scrollHeight);
};
sendCommand = (event) => {
// If the user didn't press enter, or the command is empty, do nothing
if (event.key !== 'Enter' || !event.target.value) return;
this.props.doAppendMessage(event.target.value);
this.scrollToBottom();
this.props.webSocket.send(WebSocketUtils.formatSocketData('webCommand', event.target.value));
this.commandInputRef.current.value = '';
};
render() {
return (
<div id="monitor-window-wrapper">
<div
id="monitor-window"
ref={ this.monitorRef }
className={ `${this.props.showRelevantOnly ? 'relevant-only' : null}` }
>
{ this.props.messageLog }
</div>
<div id="command-wrapper" ref={ this.commandRef }>
Command: <input onKeyDown={ this.sendCommand } ref={ this.commandInputRef } />
</div>
</div>
);
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(MonitorWindow);

View File

@ -0,0 +1,8 @@
const SET_AVAILABLE_DEVICES = 'SET_AVAILABLE_DEVICES';
const setAvailableDevices = (devices) => ({
type: SET_AVAILABLE_DEVICES,
devices,
});
export default setAvailableDevices;

View File

@ -0,0 +1,8 @@
const SET_WEBSOCKET = 'SET_WEBSOCKET';
const setWebSocket = (webSocket) => ({
type: SET_WEBSOCKET,
webSocket,
});
export default setWebSocket;

View File

@ -0,0 +1,25 @@
import _assign from 'lodash-es/assign';
const initialState = {
webSocket: null,
availableDevices: [],
};
const webUIReducer = (state = initialState, action) => {
switch (action.type) {
case 'SET_WEBSOCKET':
return _assign({}, state, {
webSocket: action.webSocket,
});
case 'SET_AVAILABLE_DEVICES':
return _assign({}, state, {
availableDevices: action.devices,
});
default:
return state;
}
};
export default webUIReducer;

View File

@ -0,0 +1,97 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import HeaderBar from '../../HeaderBar/components/HeaderBar';
import Monitor from '../../Monitor/components/Monitor';
import WidgetArea from '../../WidgetArea/containers/WidgetArea';
import '../../../styles/WebUI/containers/WebUI.scss';
// Redux actions
import setWebSocket from '../Redux/actions/setWebSocket';
import WebSocketUtils from '../../global/WebSocketUtils';
import updateGameState from '../../global/Redux/actions/updateGameState';
const mapReduxStateToProps = (reduxState) => ({
connections: reduxState.gameState.connections,
});
const mapDispatchToProps = (dispatch) => ({
doSetWebSocket: (webSocket) => dispatch(setWebSocket(webSocket)),
handleIncomingMessage: (message) => dispatch(WebSocketUtils.handleIncomingMessage(message)),
doUpdateGameState: (gameState) => dispatch(updateGameState(gameState)),
});
class WebUI extends Component {
constructor(props) {
super(props);
this.webSocket = null;
this.webUiRef = React.createRef();
}
componentDidMount() {
this.webSocketConnect();
}
webSocketConnect = () => {
const getParams = new URLSearchParams(document.location.search.substring(1));
const port = getParams.get('port');
if (!port) { throw new Error('Unable to determine socket port from GET parameters'); }
const webSocketAddress = `ws://localhost:${port}`;
try {
this.props.webSocket.close();
this.props.doSetWebSocket(null);
} catch (error) {
// Ignore errors caused by attempting to close an invalid WebSocket object
}
const webSocket = new WebSocket(webSocketAddress);
webSocket.onerror = () => {
this.props.doUpdateGameState({
connections: {
snesDevice: this.props.connections.snesDevice,
snesConnected: false,
serverAddress: this.props.connections.serverAddress,
serverConnected: false,
},
});
setTimeout(this.webSocketConnect, 5000);
};
webSocket.onclose = () => {
// If the WebSocket connection is closed for some reason, attempt to reconnect
this.props.doUpdateGameState({
connections: {
snesDevice: this.props.connections.snesDevice,
snesConnected: false,
serverAddress: this.props.connections.serverAddress,
serverConnected: false,
},
});
setTimeout(this.webSocketConnect, 5000);
};
// Dispatch a custom event when websocket messages are received
webSocket.onmessage = (message) => {
this.props.handleIncomingMessage(message);
};
// Store the webSocket object in the Redux store so other components can access it
webSocket.onopen = () => {
this.props.doSetWebSocket(webSocket);
webSocket.send(WebSocketUtils.formatSocketData('webStatus', 'connections'));
};
};
render() {
return (
<div id="web-ui" ref={ this.webUiRef }>
<HeaderBar />
<div id="content-middle">
<Monitor />
<WidgetArea />
</div>
</div>
);
}
}
export default connect(mapReduxStateToProps, mapDispatchToProps)(WebUI);

View File

@ -0,0 +1,49 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import '../../../styles/WidgetArea/containers/WidgetArea.scss';
class WidgetArea extends Component {
constructor(props) {
super(props);
this.state = {
collapsed: false,
};
}
saveNotes = (event) => {
localStorage.setItem('notes', event.target.value);
};
// eslint-disable-next-line react/no-access-state-in-setstate
toggleCollapse = () => this.setState({ collapsed: !this.state.collapsed });
render() {
return (
<div id="widget-area" className={ `${this.state.collapsed ? 'collapsed' : null}` }>
{
this.state.collapsed ? (
<div id="widget-button-row">
<button className="collapse-button" onClick={ this.toggleCollapse }></button>
</div>
) : null
}
{
this.state.collapsed ? null : (
<div id="widget-area-contents">
<div id="notes">
<div id="notes-title">
<div>Notes:</div>
<button className="collapse-button" onClick={ this.toggleCollapse }></button>
</div>
<textarea defaultValue={ localStorage.getItem('notes') } onKeyUp={ this.saveNotes } />
</div>
More tools Coming Soon
</div>
)
}
</div>
);
}
}
export default connect()(WidgetArea);

View File

@ -0,0 +1,69 @@
import React from 'react';
import md5 from 'crypto-js/md5';
const finderSpan = (finder, possessive = false, ownItem = false) => (
<span className={ `finder-span ${ownItem ? 'mine' : null}` }>{finder}{possessive ? "'s" : null}</span>
);
const recipientSpan = (recipient, possessive = false, ownItem = false) => (
<span className={ `recipient-span ${ownItem ? 'mine' : null}` }>{recipient}{possessive ? "'s" : null}</span>
);
const itemSpan = (item) => <span className="item-span">{item}</span>;
const locationSpan = (location) => <span className="location-span">{location}</span>;
const entranceSpan = (entrance) => <span className="entrance-span">{entrance}</span>;
class MonitorTools {
/** Convert plaintext into a React-friendly div */
static createTextDiv = (text) => (
<div key={ `${md5(text)}${Math.floor((Math.random() * 1000000))}` }>
{text}
</div>
);
/** Sent an item to another player */
static sentItem = (finder, recipient, item, location, iAmFinder = false, iAmRecipient = false) => (
<div
key={ `${md5(finder + recipient + item + location)}${Math.floor((Math.random() * 1000000))}` }
className={ (iAmFinder || iAmRecipient) ? 'relevant' : null }
>
{finderSpan(finder, false, iAmFinder)} found {recipientSpan(recipient, true, iAmRecipient)}&nbsp;
{itemSpan(item)} at {locationSpan(location)}
</div>
)
/** Received item from another player */
static receivedItem = (finder, item, location, itemIndex, queueLength) => (
<div
key={ `${md5(finder + item + location)}${Math.floor((Math.random() * 1000000))}` }
className="relevant"
>
({itemIndex}/{queueLength}) {finderSpan(finder, false)} found your&nbsp;
{itemSpan(item)} at {locationSpan(location)}
</div>
)
/** Player found their own item (local or remote player) */
static foundItem = (finder, item, location, iAmFinder = false) => (
<div
key={ `${md5(finder + item + location)}${Math.floor((Math.random() * 1000000))}` }
className={ iAmFinder ? 'relevant' : null }
>
{finderSpan(finder, false, iAmFinder)} found their own {itemSpan(item)} at {locationSpan(location)}
</div>
)
/** Hint message */
static hintMessage = (finder, recipient, item, location, found, iAmFinder = false, iAmRecipient = false,
entranceLocation = null) => (
<div
key={ `${md5(finder + recipient + item + location)}${Math.floor((Math.random() * 1000000))}` }
className={ (iAmFinder || iAmRecipient) ? 'relevant' : null }
>
{recipientSpan(recipient, true, iAmRecipient)} {itemSpan(item)} can be found in&nbsp;
{finderSpan(finder, true, iAmFinder)} world at {locationSpan(location)}
{ entranceLocation ? [', which is at ', entranceSpan(entranceLocation)] : null }&nbsp;
({found ? '✔' : '❌'})
</div>
)
}
export default MonitorTools;

View File

@ -0,0 +1,8 @@
const UPDATE_GAME_STATE = 'UPDATE_GAME_STATE';
const updateGameState = (gameState) => ({
type: UPDATE_GAME_STATE,
gameState,
});
export default updateGameState;

View File

@ -0,0 +1,27 @@
import _assign from 'lodash-es/assign';
const initialState = {
connections: {
snesDevice: '',
snesConnected: false,
serverAddress: null,
serverConnected: false,
},
hints: {
hintCost: null,
checkPoints: null,
playerPoints: 0,
},
};
const gameStateReducer = (state = initialState, action) => {
switch (action.type) {
case 'UPDATE_GAME_STATE':
return _assign({}, state, action.gameState);
default:
return state;
}
};
export default gameStateReducer;

View File

@ -0,0 +1,89 @@
import MonitorTools from './MonitorTools';
// Redux actions
import appendMessage from '../Monitor/Redux/actions/appendMessage';
import updateGameState from './Redux/actions/updateGameState';
import setAvailableDevices from '../WebUI/Redux/actions/setAvailableDevices';
class WebSocketUtils {
static formatSocketData = (eventType, content) => JSON.stringify({
type: eventType,
content,
});
/**
* Handle incoming websocket data and return appropriate data for dispatch
* @param message
* @returns Object
*/
static handleIncomingMessage = (message) => {
try {
const data = JSON.parse(message.data);
switch (data.type) {
// Client sent snes and server connection statuses
case 'connections':
return updateGameState({
connections: {
snesDevice: data.content.snesDevice ? data.content.snesDevice : '',
snesConnected: parseInt(data.content.snes, 10) === 3,
serverAddress: data.content.serverAddress ? data.content.serverAddress.replace(/^.*\/\//, '') : null,
serverConnected: parseInt(data.content.server, 10) === 1,
},
});
case 'availableDevices':
return setAvailableDevices(data.content.devices);
// Client unable to automatically connect to multiworld server
case 'serverAddress':
return appendMessage(MonitorTools.createTextDiv(
'Unable to automatically connect to multiworld server. Please enter an address manually.',
));
case 'itemSent':
return appendMessage(MonitorTools.sentItem(data.content.finder, data.content.recipient,
data.content.item, data.content.location, parseInt(data.content.iAmFinder, 10) === 1,
parseInt(data.content.iAmRecipient, 10) === 1));
case 'itemReceived':
return appendMessage(MonitorTools.receivedItem(data.content.finder, data.content.item,
data.content.location, data.content.itemIndex, data.content.queueLength));
case 'itemFound':
return appendMessage(MonitorTools.foundItem(data.content.finder, data.content.item, data.content.location,
parseInt(data.content.iAmFinder, 10) === 1));
case 'hint':
return appendMessage(MonitorTools.hintMessage(data.content.finder, data.content.recipient,
data.content.item, data.content.location, parseInt(data.content.found, 10) === 1,
parseInt(data.content.iAmFinder, 10) === 1, parseInt(data.content.iAmRecipient, 10) === 1,
data.content.entranceLocation));
// The client prints several types of messages to the console
case 'critical':
case 'error':
case 'warning':
case 'info':
case 'chat':
return appendMessage(MonitorTools.createTextDiv(
(typeof (data.content) === 'string') ? data.content : JSON.stringify(data.content),
));
default:
console.warn(`Unknown message type received: ${data.type}`);
console.warn(data.content);
return { type: 'NO_OP' };
}
} catch (error) {
console.error(message);
console.error(error);
// The returned value from this function will be dispatched to Redux. If an error occurs,
// Redux and the SPA in general should live on. Dispatching something with the correct format
// but that matches no known Redux action will cause the state to update to itself, which is
// treated as a no-op.
return { type: 'NO_OP' };
}
};
}
export default WebSocketUtils;

28
data/web/src/js/index.js Normal file
View File

@ -0,0 +1,28 @@
import React from 'react';
import ReactDom from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, combineReducers } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension/developmentOnly';
import WebUI from './WebUI/containers/WebUI';
import '../styles/index.scss';
// Redux reducers
import webUI from './WebUI/Redux/reducers/webUIReducer';
import gameState from './global/Redux/reducers/gameStateReducer';
import monitor from './Monitor/Redux/reducers/monitorReducer';
const store = createStore(combineReducers({
webUI,
gameState,
monitor,
}), composeWithDevTools());
const App = () => (
<Provider store={ store }>
<WebUI />
</Provider>
);
window.onload = () => {
ReactDom.render(<App />, document.getElementById('app'));
};

View File

@ -0,0 +1,4 @@
#header-bar{
font-size: 3.4em;
min-width: 1036px;
}

View File

@ -0,0 +1,4 @@
#monitor{
flex-grow: 1;
min-width: 800px;
}

View File

@ -0,0 +1,45 @@
#monitor-controls{
display: flex;
flex-direction: row;
justify-content: space-between;
height: 48px;
margin-top: 0.5em;
padding-bottom: 0.5em;
#connection-status{
display: flex;
flex-direction: row;
#snes-connection, #server-connection{
margin-right: 1em;
table{
td{
padding-right: 0.5em;
}
}
}
.connected{
color: #008000;
}
.not-connected{
color: #ff0000;
}
button {
border-radius: 3px;
}
}
#accessibility{
display: flex;
flex-direction: column;
height: 48px;
button{
border-radius: 4px;
margin: 0.5em;
}
}
}

View File

@ -0,0 +1,48 @@
#monitor-window-wrapper{
#monitor-window{
display: flex;
flex-direction: column;
justify-content: flex-start;
background-color: #414042;
color: #dce7df;
overflow-y: auto;
margin-bottom: 10px;
div{
width: calc(100% - 14px);
padding: 7px;
border-bottom: 1px solid #000000;
&.user-command{
color: #ffffff;
background-color: #575757;
}
}
&.relevant-only{
div:not(.relevant){
visibility: collapse;
}
}
.item-span{ color: #67e9ff; }
.location-span{ color: #f5e63c; }
.entrance-span{ color: #73ae38 }
.finder-span{ color: #f96cb8; }
.recipient-span{ color: #9b8aff; }
.mine{ color: #ffa500; }
}
#command-wrapper{
display: flex;
flex-direction: row;
justify-content: flex-start;
height: 25px;
line-height: 25px;
input{
margin-left: 0.5em;
flex-grow: 1;
}
}
}

View File

@ -0,0 +1,9 @@
#web-ui{
width: calc(100% - 1.5em);
padding: 0.75em;
#content-middle{
display: flex;
flex-direction: row;
}
}

View File

@ -0,0 +1,39 @@
#widget-area{
margin-left: 0.5em;
margin-right: 0.5em;
padding: 0.25em;
border: 2px solid #6a6a6a;
&:not(.collapsed){
width: calc(20% - 1.5em - 4px);
}
#widget-button-row{
width: 100%;
text-align: right;
.collapse-button{
width: 35px;
}
}
#widget-area-contents{
display: flex;
flex-direction: column;
#notes{
display: flex;
flex-direction: column;
#notes-title{
display: flex;
flex-direction: row;
justify-content: space-between;
}
textarea{
height: 10em;
}
}
}
}

View File

@ -0,0 +1,12 @@
@font-face{
font-family: HyliaSerif;
src: local('HyliaSerif'), url('../assets/HyliaSerif.otf')
}
body {
background-color: #131313;
color: #eae703;
font-family: HyliaSerif, serif;
letter-spacing: 2px;
margin: 0;
}

View File

@ -0,0 +1,45 @@
const path = require('path');
module.exports = {
entry: {
index: './src/js/index.js',
},
module: {
rules: [
{
test: /\.(js|jsx|es6)$/,
loader: 'babel-loader',
query: {
compact: true,
minified: true,
},
},
{
test: /\.((css)|(s[a|c]ss))$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'sass-loader' },
],
},
{
test: /\.(otf)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/',
publicPath: 'assets/fonts/',
},
},
],
},
],
},
output: {
path: `${path.resolve(__dirname)}/public/assets`,
publicPath: '/',
filename: '[name].bundle.js',
},
};

46
data/web/webpack.dev.js Normal file
View File

@ -0,0 +1,46 @@
const path = require('path');
module.exports = {
entry: {
index: './src/js/index.js',
},
module: {
rules: [
{
test: /\.(js|jsx|es6)$/,
loader: 'babel-loader',
query: {
compact: false,
minified: false,
},
},
{
test: /\.((css)|(s[a|c]ss))$/,
use: [
{ loader: 'style-loader' },
{ loader: 'css-loader' },
{ loader: 'sass-loader' },
],
},
{
test: /\.(otf)$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/',
publicPath: 'assets/fonts/',
},
},
],
},
],
},
output: {
path: `${path.resolve(__dirname)}/public/assets`,
publicPath: '/',
filename: '[name].bundle.js',
},
devtool: 'source-map',
};

View File

@ -50,6 +50,7 @@ def manifest_creation():
json.dump(manifest, open(manifestpath, "wt"), indent=4)
print("Created Manifest")
scripts = {"MultiClient.py" : "BerserkerMultiClient",
"MultiMystery.py" : "BerserkerMultiMystery",
"MultiServer.py" : "BerserkerMultiServer",
@ -62,8 +63,8 @@ for script, scriptname in scripts.items():
exes.append(cx_Freeze.Executable(
script=script,
targetName=scriptname + ("" if sys.platform == "linux" else ".exe"),
icon=icon)
)
icon=icon,
))
import datetime
@ -77,6 +78,7 @@ cx_Freeze.setup(
executables=exes,
options={
"build_exe": {
"includes" : [],
"zip_include_packages": ["*"],
"zip_exclude_packages": [],
"include_files": [],