WebUI (#100)
* 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:
parent
ffe67c7fa7
commit
38cbcc662f
383
MultiClient.py
383
MultiClient.py
|
@ -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
|
||||
|
||||
|
|
225
MultiServer.py
225
MultiServer.py
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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)
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||
"plugins": ["@babel/plugin-proposal-class-properties"]
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -0,0 +1,2 @@
|
|||
node_modules
|
||||
*.map
|
File diff suppressed because it is too large
Load Diff
|
@ -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
|
@ -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 |
|
@ -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;
|
|
@ -0,0 +1,8 @@
|
|||
const APPEND_MESSAGE = 'APPEND_MESSAGE';
|
||||
|
||||
const appendMessage = (content) => ({
|
||||
type: APPEND_MESSAGE,
|
||||
content,
|
||||
});
|
||||
|
||||
export default appendMessage;
|
|
@ -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;
|
|
@ -0,0 +1,8 @@
|
|||
const SET_SHOW_RELEVANT = 'SET_SHOW_RELEVANT';
|
||||
|
||||
const setShowRelevant = (showRelevant) => ({
|
||||
type: SET_SHOW_RELEVANT,
|
||||
showRelevant,
|
||||
});
|
||||
|
||||
export default setShowRelevant;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -0,0 +1,8 @@
|
|||
const SET_AVAILABLE_DEVICES = 'SET_AVAILABLE_DEVICES';
|
||||
|
||||
const setAvailableDevices = (devices) => ({
|
||||
type: SET_AVAILABLE_DEVICES,
|
||||
devices,
|
||||
});
|
||||
|
||||
export default setAvailableDevices;
|
|
@ -0,0 +1,8 @@
|
|||
const SET_WEBSOCKET = 'SET_WEBSOCKET';
|
||||
|
||||
const setWebSocket = (webSocket) => ({
|
||||
type: SET_WEBSOCKET,
|
||||
webSocket,
|
||||
});
|
||||
|
||||
export default setWebSocket;
|
|
@ -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;
|
|
@ -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);
|
|
@ -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);
|
|
@ -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)}
|
||||
{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
|
||||
{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
|
||||
{finderSpan(finder, true, iAmFinder)} world at {locationSpan(location)}
|
||||
{ entranceLocation ? [', which is at ', entranceSpan(entranceLocation)] : null }
|
||||
({found ? '✔' : '❌'})
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MonitorTools;
|
|
@ -0,0 +1,8 @@
|
|||
const UPDATE_GAME_STATE = 'UPDATE_GAME_STATE';
|
||||
|
||||
const updateGameState = (gameState) => ({
|
||||
type: UPDATE_GAME_STATE,
|
||||
gameState,
|
||||
});
|
||||
|
||||
export default updateGameState;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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'));
|
||||
};
|
|
@ -0,0 +1,4 @@
|
|||
#header-bar{
|
||||
font-size: 3.4em;
|
||||
min-width: 1036px;
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
#monitor{
|
||||
flex-grow: 1;
|
||||
min-width: 800px;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
#web-ui{
|
||||
width: calc(100% - 1.5em);
|
||||
padding: 0.75em;
|
||||
|
||||
#content-middle{
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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',
|
||||
},
|
||||
};
|
|
@ -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',
|
||||
};
|
6
setup.py
6
setup.py
|
@ -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": [],
|
||||
|
|
Loading…
Reference in New Issue