Initial commit

This commit is contained in:
vortex 2024-02-26 00:17:10 +05:30
parent bc49523c99
commit 44d606cce7
1929 changed files with 612166 additions and 0 deletions

454
dist/ba_data/python/ba/_servermode.py vendored Normal file
View file

@ -0,0 +1,454 @@
# 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.')
import _ba
_ba.quit()
# 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