From acaa9a90eb52e9c8b4e78abd0b940419e640c302 Mon Sep 17 00:00:00 2001 From: Eric Froemling Date: Sun, 3 May 2020 18:23:38 -0700 Subject: [PATCH] Added 'clientlist' and 'kick' server commands --- .efrocachemap | 24 +++--- .idea/dictionaries/ericf.xml | 2 + assets/src/ba_data/python/ba/_servermode.py | 90 +++++++++++++++++---- assets/src/server/ballisticacore_server.py | 54 ++++++++++--- docs/ba_module.md | 24 +++++- tools/bacommon/servermanager.py | 18 +++++ tools/efro/dataclassutils.py | 2 +- 7 files changed, 174 insertions(+), 40 deletions(-) diff --git a/.efrocachemap b/.efrocachemap index f0ac2e09..a1db3ef1 100644 --- a/.efrocachemap +++ b/.efrocachemap @@ -4132,16 +4132,16 @@ "assets/build/windows/x64/python.exe": "https://files.ballistica.net/cache/ba1/25/a7/dc87c1be41605eb6fefd0145144c", "assets/build/windows/x64/python37.dll": "https://files.ballistica.net/cache/ba1/b9/e4/d912f56e42e9991bcbb4c804cfcb", "assets/build/windows/x64/pythonw.exe": "https://files.ballistica.net/cache/ba1/6c/bb/b6f52c306aa4e88061510e96cefe", - "build/prefab/linux-server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/fe/a7/f1a153619272b4882dc0d1fcf228", - "build/prefab/linux-server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/bd/08/3a948bec6a0553109361ab201b7f", - "build/prefab/linux/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/69/4e/34a3913539ae323f32c6029aee61", - "build/prefab/linux/release/ballisticacore": "https://files.ballistica.net/cache/ba1/c7/2c/3b0f126c249753469779ac7abd99", - "build/prefab/mac-server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/9a/f5/252523aff43024c6ced461abcc04", - "build/prefab/mac-server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/12/f5/35f0e8d8259c37389fe2d60becbc", - "build/prefab/mac/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/ca/ad/c4e22ab2e7de1356f47aa4f8dc07", - "build/prefab/mac/release/ballisticacore": "https://files.ballistica.net/cache/ba1/02/dc/bea2f3a3a862b9e587043f6bd73f", - "build/prefab/windows-server/debug/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/d7/df/80ceb60dc041e2b9a69f0a3da9ea", - "build/prefab/windows-server/release/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/b1/86/dddc66545c917e3a028fc6a6caea", - "build/prefab/windows/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/a2/69/b183a77cd9e8a91ef550c4e7bcec", - "build/prefab/windows/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/a0/17/d7aca9e4c6e604993328cd389356" + "build/prefab/linux-server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/a2/5b/8598da1884bbe060f495d0273d7d", + "build/prefab/linux-server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/83/95/5e30fd47e6befa8f9a370a52be21", + "build/prefab/linux/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/60/3a/1edfbfc7adcbda3f6734f9282756", + "build/prefab/linux/release/ballisticacore": "https://files.ballistica.net/cache/ba1/34/a1/f7d0bf6b709a757418825a1cc24f", + "build/prefab/mac-server/debug/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/59/e9/8a29cf17f4434f783fab73afc349", + "build/prefab/mac-server/release/dist/ballisticacore_headless": "https://files.ballistica.net/cache/ba1/71/9d/02c9e2ba7c91941991552228c66c", + "build/prefab/mac/debug/ballisticacore": "https://files.ballistica.net/cache/ba1/9b/35/42e8f879ac5b07ba2d8caee897dc", + "build/prefab/mac/release/ballisticacore": "https://files.ballistica.net/cache/ba1/3f/3e/20eb6ac1043ac0417c2b7728f7bd", + "build/prefab/windows-server/debug/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/b9/75/6b1523ec9b0d510870b4695a6613", + "build/prefab/windows-server/release/dist/ballisticacore_headless.exe": "https://files.ballistica.net/cache/ba1/7d/04/b882bbfd0197248bca49016356c1", + "build/prefab/windows/debug/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/13/50/1fb612a251431229703317bd81e5", + "build/prefab/windows/release/BallisticaCore.exe": "https://files.ballistica.net/cache/ba1/ec/3c/ce84db1040251fb5a11b0c7dfd7b" } \ No newline at end of file diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml index 3886ff9b..d394f514 100644 --- a/.idea/dictionaries/ericf.xml +++ b/.idea/dictionaries/ericf.xml @@ -296,6 +296,7 @@ cleancheck cleanlist clientid + clientlist clionbin clioncode clionroot @@ -1956,6 +1957,7 @@ vartype vcxproj venv + versioning versionpredicate vert verts diff --git a/assets/src/ba_data/python/ba/_servermode.py b/assets/src/ba_data/python/ba/_servermode.py index 51801326..b5dd44eb 100644 --- a/assets/src/ba_data/python/ba/_servermode.py +++ b/assets/src/ba_data/python/ba/_servermode.py @@ -30,7 +30,9 @@ from ba._enums import TimeType from ba._freeforallsession import FreeForAllSession from ba._dualteamsession import DualTeamSession from bacommon.servermanager import (ServerCommand, StartServerModeCommand, - ShutdownCommand, ShutdownReason) + ShutdownCommand, ShutdownReason, + BroadcastCommand, ClientListCommand, + KickCommand) import _ba if TYPE_CHECKING: @@ -56,6 +58,22 @@ def _cmd(command_data: bytes) -> None: immediate=command.immediate) return + if isinstance(command, BroadcastCommand): + assert _ba.app.server is not None + _ba.app.server.broadcast_message(command.message) + return + + if isinstance(command, ClientListCommand): + assert _ba.app.server is not None + _ba.app.server.print_client_list() + return + + if isinstance(command, KickCommand): + assert _ba.app.server is not None + _ba.app.server.kick(client_id=command.client_id, + ban_time=command.ban_time) + return + print(f'{Clr.SRED}ERROR: server process' f' got unknown command: {type(command)}{Clr.RST}') @@ -93,6 +111,48 @@ class ServerController: timetype=TimeType.REAL, repeat=True) + def broadcast_message(self, message: str) -> None: + """Broadcast a message to all connected clients.""" + # FIXME: Should add a proper call for this, which would allow + # us to use Lstr values and colors and whatnot. + _ba.chat_message(message) + + def print_client_list(self) -> None: + """Print info about all connected clients.""" + import json + roster = _ba.get_game_roster() + title1 = 'Client ID' + title2 = 'Account Name' + title3 = 'Players' + col1 = 10 + col2 = 16 + out = (f'{Clr.BLD}' + f'{title1:<{col1}} {title2:<{col2}} {title3}' + f'{Clr.RST}') + for client in roster: + if client['client_id'] == -1: + continue + spec = json.loads(client['specString']) + name = spec['n'] + players = ', '.join(n['name'] for n in client['players']) + clientid = client['client_id'] + out += f'\n{clientid:<{col1}} {name:<{col2}} {players}' + print(out) + + def kick(self, client_id: int, ban_time: Optional[int]) -> None: + """Kick the provided client id. + + ban_time is provided in seconds. + If ban_time is None, ban duration will be determined automatically. + Pass 0 or a negative number for no ban time. + """ + + # FIXME: this case should be handled under the hood. + if ban_time is None: + ban_time = 300 + + _ba.disconnect_client(client_id=client_id, ban_time=ban_time) + def shutdown(self, reason: ShutdownReason, immediate: bool) -> None: """Set the app to quit either now or at the next clean opportunity.""" self._shutdown_reason = reason @@ -124,19 +184,15 @@ class ServerController: self._executing_shutdown = True timestrval = time.strftime('%c') if self._shutdown_reason is ShutdownReason.RESTARTING: - # FIXME: Should add a server-screen-message call. - # (so we could send this an an Lstr) - _ba.chat_message( + self.broadcast_message( Lstr(resource='internal.serverRestartingText').evaluate()) print(f'{Clr.SBLU}Exiting for server-restart' f' at {timestrval}{Clr.RST}') else: - # FIXME: Should add a server-screen-message call. - # (so we could send this an an Lstr) + self.broadcast_message( + Lstr(resource='internal.serverShuttingDownText').evaluate()) print(f'{Clr.SBLU}Exiting for server-shutdown' f' at {timestrval}{Clr.RST}') - _ba.chat_message( - Lstr(resource='internal.serverShuttingDownText').evaluate()) with _ba.Context('ui'): _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL) @@ -153,18 +209,22 @@ class ServerController: ) def _access_check_response(self, data: Optional[Dict[str, Any]]) -> None: - gameport = _ba.get_game_port() + # gameport = _ba.get_game_port() if data is None: print('error on UDP port access check (internet down?)') else: + addr = data['address'] + port = data['port'] if data['accessible']: - print(f'{Clr.SBLU}UDP port {gameport} access check successful.' - f' Your server appears to be joinable from the' - f' internet.{Clr.RST}') + print(f'{Clr.SBLU}Master server access check of {addr}' + f' udp port {port} succeeded.\n' + f'Your server appears to be' + f' joinable from the internet.{Clr.RST}') else: - print(f'{Clr.SRED}UDP port {gameport} access check failed.' - f' Your server does not appear to be joinable' - f' from the internet.{Clr.RST}') + print(f'{Clr.SRED}Master server access check of {addr}' + f' udp port {port} failed.\n' + f'Your server does not appear to be' + f' joinable from the internet.{Clr.RST}') def _prepare_to_serve(self) -> None: signed_in = _ba.get_account_state() == 'signed_in' diff --git a/assets/src/server/ballisticacore_server.py b/assets/src/server/ballisticacore_server.py index 1ca525c5..ae6ab242 100755 --- a/assets/src/server/ballisticacore_server.py +++ b/assets/src/server/ballisticacore_server.py @@ -48,6 +48,10 @@ if TYPE_CHECKING: from types import FrameType from bacommon.servermanager import ServerCommand +# Not sure how much versioning we'll do with this, but this will get +# printed at startup in case we need it. +VERSION_STR = '1.0' + class ServerManagerApp: """An app which manages BallisticaCore server execution. @@ -101,7 +105,8 @@ class ServerManagerApp: # Print basic usage info in interactive mode. if sys.stdin.isatty(): - print(f'{Clr.SMAG}BallisticaCore server manager starting up...\n' + print(f'{Clr.SMAG}BallisticaCore server manager {VERSION_STR}' + f' starting up...\n' f'Use the "mgr" object to make live server adjustments.\n' f'Type "help(mgr)" for more information.{Clr.RST}') @@ -115,22 +120,19 @@ class ServerManagerApp: self._process_thread = Thread(target=self._bg_thread_main) self._process_thread.start() - # According to Python docs, default locals dict has __name__ set - # to __console__ and __doc__ set to None; using that as start point. - # https://docs.python.org/3/library/code.html - locs = {'__name__': '__console__', '__doc__': None, 'mgr': self} + context = {'__name__': '__console__', '__doc__': None, 'mgr': self} # Enable tab-completion if possible. - self._enable_tab_completion(locs) + self._enable_tab_completion(context) # Now just sit in an interpreter. # TODO: make it possible to use IPython if the user has it available. try: - code.interact(local=locs, banner='', exitmsg='') + code.interact(local=context, banner='', exitmsg='') except SystemExit: # We get this from the builtin quit(), etc. # Need to catch this so we can clean up, otherwise we'll be - # left in limbo with our BG thread still running. + # left in limbo with our process thread still running. pass except BaseException as exc: print(f'{Clr.SRED}Unexpected interpreter exception:' @@ -150,7 +152,9 @@ class ServerManagerApp: raise TypeError(f'Expected a string arg; got {type(statement)}') with self._process_commands_lock: self._process_commands.append(statement) + self._block_for_command_completion() + def _block_for_command_completion(self) -> None: # Ideally we'd block here until the command was run so our prompt would # print after it's results. We currently don't get any response from # the app so the best we can do is block until our bg thread has sent @@ -166,6 +170,29 @@ class ServerManagerApp: # we'll hopefully still give it enough time to process/print. time.sleep(0.1) + def broadcast(self, message: str) -> None: + """Broadcast a message to all connected clients.""" + from bacommon.servermanager import BroadcastCommand + self._enqueue_server_command(BroadcastCommand(message=message)) + + def clientlist(self) -> None: + """Print a list of connected clients.""" + from bacommon.servermanager import ClientListCommand + self._enqueue_server_command(ClientListCommand()) + self._block_for_command_completion() + + def kick(self, client_id: int, ban_time: Optional[int] = None) -> None: + """Kick the client with the provided id. + + If ban_time is provided, the client will be banned for that + length of time in seconds. If it is None, ban duration will + be determined automatically. Pass 0 or a negative number for no + ban time. + """ + from bacommon.servermanager import KickCommand + self._enqueue_server_command( + KickCommand(client_id=client_id, ban_time=ban_time)) + def restart(self, immediate: bool = False) -> None: """Restart the server child-process. @@ -360,10 +387,15 @@ class ServerManagerApp: print(f'{Clr.SMAG}Server process stopped.{Clr.RST}') -if __name__ == '__main__': +def main() -> None: + """Run a BallisticaCore server manager in interactive mode.""" try: ServerManagerApp().run_interactive() - except CleanError as clean_exc: + except CleanError as exc: # For clean errors, do a simple print and fail; no tracebacks/etc. - clean_exc.pretty_print() + exc.pretty_print() sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/docs/ba_module.md b/docs/ba_module.md index c94cc09a..d1f28296 100644 --- a/docs/ba_module.md +++ b/docs/ba_module.md @@ -3988,11 +3988,17 @@ cause the powerup box to make a sound and disappear or whatnot.

Methods:

-
<constructor>, handle_transition(), shutdown()
+
<constructor>, broadcast_message(), handle_transition(), kick(), print_client_list(), shutdown()

<constructor>

ba.ServerController(config: ServerConfig)

+
+

broadcast_message()

+

broadcast_message(self, message: str) -> None

+ +

Broadcast a message to all connected clients.

+

handle_transition()

handle_transition(self) -> bool

@@ -4004,6 +4010,22 @@ a good 'end-point' (such as a final score screen). Should return True if action will be handled by us; False if the session should just continue on it's merry way.

+
+

kick()

+

kick(self, client_id: int, ban_time: Optional[int]) -> None

+ +

Kick the provided client id.

+ +

ban_time is provided in seconds. +If ban_time is None, ban duration will be determined automatically. +Pass 0 or a negative number for no ban time.

+ +
+

print_client_list()

+

print_client_list(self) -> None

+ +

Print info about all connected clients.

+

shutdown()

shutdown(self, reason: ShutdownReason, immediate: bool) -> None

diff --git a/tools/bacommon/servermanager.py b/tools/bacommon/servermanager.py index 4fff1ee9..d10b9d2a 100644 --- a/tools/bacommon/servermanager.py +++ b/tools/bacommon/servermanager.py @@ -121,3 +121,21 @@ class ShutdownCommand(ServerCommand): """Tells the server to shut down.""" reason: ShutdownReason immediate: bool + + +@dataclass +class BroadcastCommand(ServerCommand): + """Broadcast a message to all clients.""" + message: str + + +@dataclass +class ClientListCommand(ServerCommand): + """Print a list of clients.""" + + +@dataclass +class KickCommand(ServerCommand): + """Kick a client.""" + client_id: int + ban_time: Optional[int] diff --git a/tools/efro/dataclassutils.py b/tools/efro/dataclassutils.py index 501de4bf..c313fb2f 100644 --- a/tools/efro/dataclassutils.py +++ b/tools/efro/dataclassutils.py @@ -69,7 +69,7 @@ def dataclass_assign(instance: Any, values: Dict[str, Any]) -> None: for key, value in values.items(): if key not in fieldsdict: raise AttributeError( - f"'{type(instance).__name__}' has no '{key}' field") + f"'{type(instance).__name__}' has no '{key}' field.") field = fieldsdict[key] # We expect to be operating under 'from __future__ import annotations'