# Released under the MIT License. See LICENSE for details. # """Functionality related to running the game in server-mode.""" from __future__ import annotations import sys import time import logging from typing import TYPE_CHECKING from efro.terminal import Clr from bacommon.servermanager import ( ServerCommand, StartServerModeCommand, ShutdownCommand, ShutdownReason, ChatMessageCommand, ScreenMessageCommand, ClientListCommand, KickCommand, ) import _ba from ba._internal import add_transaction, run_transactions, get_v1_account_state from ba._generated.enums import TimeType from ba._freeforallsession import FreeForAllSession from ba._dualteamsession import DualTeamSession from ba._coopsession import CoopSession if TYPE_CHECKING: from typing import Any import ba from bacommon.servermanager import ServerConfig def _cmd(command_data: bytes) -> None: """Handle commands coming in from our server manager parent process.""" import pickle command = pickle.loads(command_data) assert isinstance(command, ServerCommand) if isinstance(command, StartServerModeCommand): assert _ba.app.server is None _ba.app.server = ServerController(command.config) return if isinstance(command, ShutdownCommand): assert _ba.app.server is not None _ba.app.server.shutdown( reason=command.reason, immediate=command.immediate ) return if isinstance(command, ChatMessageCommand): assert _ba.app.server is not None _ba.chatmessage(command.message, clients=command.clients) return if isinstance(command, ScreenMessageCommand): assert _ba.app.server is not None # Note: we have to do transient messages if # clients is specified, so they won't show up # in replays. _ba.screenmessage( command.message, color=command.color, clients=command.clients, transient=command.clients is not None, ) 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}' ) class ServerController: """Overall controller for the app in server mode. Category: **App Classes** """ def __init__(self, config: ServerConfig) -> None: self._config = config self._playlist_name = '__default__' self._ran_access_check = False self._prep_timer: ba.Timer | None = None self._next_stuck_login_warn_time = time.time() + 10.0 self._first_run = True self._shutdown_reason: ShutdownReason | None = None self._executing_shutdown = False # Make note if they want us to import a playlist; # we'll need to do that first if so. self._playlist_fetch_running = self._config.playlist_code is not None self._playlist_fetch_sent_request = False self._playlist_fetch_got_response = False self._playlist_fetch_code = -1 # Now sit around doing any pre-launch prep such as waiting for # account sign-in or fetching playlists; this will kick off the # session once done. with _ba.Context('ui'): self._prep_timer = _ba.Timer( 0.25, self._prepare_to_serve, timetype=TimeType.REAL, repeat=True, ) 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['spec_string']) 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: int | None) -> 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 if immediate: print(f'{Clr.SBLU}Immediate shutdown initiated.{Clr.RST}') self._execute_shutdown() else: print( f'{Clr.SBLU}Shutdown initiated;' f' server process will exit at the next clean opportunity.' f'{Clr.RST}' ) def handle_transition(self) -> bool: """Handle transitioning to a new ba.Session or quitting the app. Will be called once at the end of an activity that is marked as 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. """ if self._shutdown_reason is not None: self._execute_shutdown() return True return False def _execute_shutdown(self) -> None: from ba._language import Lstr if self._executing_shutdown: return self._executing_shutdown = True timestrval = time.strftime('%c') if self._shutdown_reason is ShutdownReason.RESTARTING: _ba.screenmessage( Lstr(resource='internal.serverRestartingText'), color=(1, 0.5, 0.0), ) print( f'{Clr.SBLU}Exiting for server-restart' f' at {timestrval}.{Clr.RST}' ) else: _ba.screenmessage( Lstr(resource='internal.serverShuttingDownText'), color=(1, 0.5, 0.0), ) print( f'{Clr.SBLU}Exiting for server-shutdown' f' at {timestrval}.{Clr.RST}' ) with _ba.Context('ui'): _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL) def _run_access_check(self) -> None: """Check with the master server to see if we're likely joinable.""" from ba._net import master_server_get master_server_get( 'bsAccessCheck', {'port': _ba.get_game_port(), 'b': _ba.app.build_number}, callback=self._access_check_response, ) def _access_check_response(self, data: dict[str, Any] | None) -> None: import os if data is None: print('error on UDP port access check (internet down?)') else: addr = data['address'] port = data['port'] show_addr = True if show_addr: addrstr = f' {addr}' poststr = '' else: addrstr = '' poststr = ( '\nSet environment variable BA_ACCESS_CHECK_VERBOSE=1' ' for more info.' ) if data['accessible']: print( f'{Clr.SBLU}Master server access check of{addrstr}' f' udp port {port} succeeded.\n' f'Your server appears to be' f' joinable from the internet .{poststr}{Clr.RST}' ) if self._config.party_is_public: print( f'{Clr.SBLU}Your party {self._config.party_name}' f' visible in public party list.{Clr.RST}' ) else: print( f'{Clr.SBLU}Your private party {self._config.party_name}' f'can be joined by {addrstr} {port}.{Clr.RST}' ) else: print( f'{Clr.SRED}Master server access check of{addrstr}' f' udp port {port} failed.\n' f'Your server does not appear to be' f' joinable from the internet. Please check your firewall or instance security group.{poststr}{Clr.RST}' ) def _prepare_to_serve(self) -> None: """Run in a timer to do prep before beginning to serve.""" signed_in = get_v1_account_state() == 'signed_in' if not signed_in: # Signing in to the local server account should not take long; # complain if it does... curtime = time.time() if curtime > self._next_stuck_login_warn_time: print('Still waiting for account sign-in...') self._next_stuck_login_warn_time = curtime + 10.0 return can_launch = False # If we're fetching a playlist, we need to do that first. if not self._playlist_fetch_running: can_launch = True else: if not self._playlist_fetch_sent_request: print( f'{Clr.SBLU}Requesting shared-playlist' f' {self._config.playlist_code}...{Clr.RST}' ) add_transaction( { 'type': 'IMPORT_PLAYLIST', 'code': str(self._config.playlist_code), 'overwrite': True, }, callback=self._on_playlist_fetch_response, ) run_transactions() self._playlist_fetch_sent_request = True if self._playlist_fetch_got_response: self._playlist_fetch_running = False can_launch = True if can_launch: self._prep_timer = None _ba.pushcall(self._launch_server_session) def _on_playlist_fetch_response( self, result: dict[str, Any] | None, ) -> None: if result is None: print('Error fetching playlist; aborting.') print('Falling back to use default playlist') self._config.session_type = 'teams' self._prep_timer = None _ba.pushcall(self._launch_server_session) return # Once we get here, simply modify our config to use this playlist. typename = ( 'teams' if result['playlistType'] == 'Team Tournament' else 'ffa' if result['playlistType'] == 'Free-for-All' else '??' ) plistname = result['playlistName'] print(f'{Clr.SBLU}Got playlist: "{plistname}" ({typename}).{Clr.RST}') self._playlist_fetch_got_response = True self._config.session_type = typename self._playlist_name = result['playlistName'] def _get_session_type(self) -> type[ba.Session]: # Convert string session type to the class. # Hmm should we just keep this as a string? if self._config.session_type == 'ffa': return FreeForAllSession if self._config.session_type == 'teams': return DualTeamSession if self._config.session_type == 'coop': return CoopSession raise RuntimeError( f'Invalid session_type: "{self._config.session_type}"' ) def _launch_server_session(self) -> None: """Kick off a host-session based on the current server config.""" # pylint: disable=too-many-branches app = _ba.app appcfg = app.config sessiontype = self._get_session_type() if get_v1_account_state() != 'signed_in': print( 'WARNING: launch_server_session() expects to run ' 'with a signed in server account' ) # If we didn't fetch a playlist but there's an inline one in the # server-config, pull it in to the game config and use it. if ( self._config.playlist_code is None and self._config.playlist_inline is not None ): self._playlist_name = 'ServerModePlaylist' if sessiontype is FreeForAllSession: ptypename = 'Free-for-All' elif sessiontype is DualTeamSession: ptypename = 'Team Tournament' elif sessiontype is CoopSession: ptypename = 'Coop' else: raise RuntimeError(f'Unknown session type {sessiontype}') # Need to add this in a transaction instead of just setting # it directly or it will get overwritten by the master-server. add_transaction( { 'type': 'ADD_PLAYLIST', 'playlistType': ptypename, 'playlistName': self._playlist_name, 'playlist': self._config.playlist_inline, } ) run_transactions() if self._first_run: curtimestr = time.strftime('%c') startupmsg = ( f'{Clr.BLD}{Clr.BLU}{_ba.appnameupper()} {app.version}' f' ({app.build_number})' f' entering server-mode {curtimestr}{Clr.RST}' ) logging.info(startupmsg) if sessiontype is FreeForAllSession: appcfg['Free-for-All Playlist Selection'] = self._playlist_name appcfg[ 'Free-for-All Playlist Randomize' ] = self._config.playlist_shuffle elif sessiontype is DualTeamSession: appcfg['Team Tournament Playlist Selection'] = self._playlist_name appcfg[ 'Team Tournament Playlist Randomize' ] = self._config.playlist_shuffle elif sessiontype is CoopSession: app.coop_session_args = { 'campaign': self._config.coop_campaign, 'level': self._config.coop_level, } else: raise RuntimeError(f'Unknown session type {sessiontype}') app.teams_series_length = self._config.teams_series_length app.ffa_series_length = self._config.ffa_series_length _ba.set_authenticate_clients(self._config.authenticate_clients) _ba.set_enable_default_kick_voting( self._config.enable_default_kick_voting ) _ba.set_admins(self._config.admins) # Call set-enabled last (will push state to the cloud). _ba.set_public_party_max_size(self._config.max_party_size) _ba.set_public_party_queue_enabled(self._config.enable_queue) _ba.set_public_party_name(self._config.party_name) _ba.set_public_party_stats_url(self._config.stats_url) _ba.set_public_party_enabled(self._config.party_is_public) # And here.. we.. go. if self._config.stress_test_players is not None: # Special case: run a stress test. from ba.internal import run_stress_test run_stress_test( playlist_type='Random', playlist_name='__default__', player_count=self._config.stress_test_players, round_duration=30, ) else: _ba.new_host_session(sessiontype) # Run an access check if we're trying to make a public party. if not self._ran_access_check: self._run_access_check() self._ran_access_check = True