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:
-
+
-
ba.ServerController(config: ServerConfig)
+
+-
+
broadcast_message(self, message: str) -> None
+
+Broadcast a message to all connected clients.
+
-
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(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(self) -> None
+
+Print info about all connected clients.
+
-
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'