Work in progress on server mode cleanup

This commit is contained in:
Eric Froemling 2020-04-24 02:02:18 -07:00
parent 91f5005d21
commit ac75dce421
18 changed files with 702 additions and 962 deletions

View File

@ -148,6 +148,7 @@
<w>baseurl</w>
<w>basew</w>
<w>bastd</w>
<w>batools</w>
<w>bbot</w>
<w>bbtn</w>
<w>bcppcompiler</w>
@ -374,6 +375,7 @@
<w>cutscenes</w>
<w>cval</w>
<w>cwdg</w>
<w>cyaml</w>
<w>cygwinccompiler</w>
<w>darwiin</w>
<w>darwiinremote</w>
@ -835,6 +837,7 @@
<w>imgw</w>
<w>incentivized</w>
<w>includetest</w>
<w>incmd</w>
<w>incr</w>
<w>incrementbuild</w>
<w>indentfilter</w>
@ -1452,6 +1455,7 @@
<w>relpath</w>
<w>remainingchecks</w>
<w>remoteapp</w>
<w>representer</w>
<w>reprlib</w>
<w>reqs</w>
<w>resample</w>
@ -1533,6 +1537,7 @@
<w>serverdialog</w>
<w>serverget</w>
<w>serverput</w>
<w>serverutils</w>
<w>sessionclass</w>
<w>sessiondata</w>
<w>sessionglobals</w>
@ -1638,6 +1643,7 @@
<w>storable</w>
<w>storedhash</w>
<w>storeitemui</w>
<w>strftime</w>
<w>stringprep</w>
<w>stringptr</w>
<w>strobing</w>
@ -1888,6 +1894,7 @@
<w>virotic</w>
<w>vmaddr</w>
<w>vmcfg</w>
<w>vmhgfs</w>
<w>vmrun</w>
<w>vmshell</w>
<w>vmware</w>

View File

@ -107,8 +107,10 @@
"ba_data/python/bacommon/__pycache__/__init__.cpython-37.opt-1.pyc",
"ba_data/python/bacommon/__pycache__/assets.cpython-37.opt-1.pyc",
"ba_data/python/bacommon/__pycache__/err.cpython-37.opt-1.pyc",
"ba_data/python/bacommon/__pycache__/serverutils.cpython-37.opt-1.pyc",
"ba_data/python/bacommon/assets.py",
"ba_data/python/bacommon/err.py",
"ba_data/python/bacommon/serverutils.py",
"ba_data/python/bastd/__init__.py",
"ba_data/python/bastd/__pycache__/__init__.cpython-37.opt-1.pyc",
"ba_data/python/bastd/__pycache__/appdelegate.cpython-37.opt-1.pyc",
@ -141,7 +143,6 @@
"ba_data/python/bastd/actor/__pycache__/controlsguide.cpython-37.opt-1.pyc",
"ba_data/python/bastd/actor/__pycache__/flag.cpython-37.opt-1.pyc",
"ba_data/python/bastd/actor/__pycache__/image.cpython-37.opt-1.pyc",
"ba_data/python/bastd/actor/__pycache__/multiteamvictory.cpython-37.opt-1.pyc",
"ba_data/python/bastd/actor/__pycache__/onscreencountdown.cpython-37.opt-1.pyc",
"ba_data/python/bastd/actor/__pycache__/onscreentimer.cpython-37.opt-1.pyc",
"ba_data/python/bastd/actor/__pycache__/playerspaz.cpython-37.opt-1.pyc",
@ -162,7 +163,6 @@
"ba_data/python/bastd/actor/controlsguide.py",
"ba_data/python/bastd/actor/flag.py",
"ba_data/python/bastd/actor/image.py",
"ba_data/python/bastd/actor/multiteamvictory.py",
"ba_data/python/bastd/actor/onscreencountdown.py",
"ba_data/python/bastd/actor/onscreentimer.py",
"ba_data/python/bastd/actor/playerspaz.py",

View File

@ -156,6 +156,7 @@ SCRIPT_TARGETS_PY_1 = \
build/ba_data/python/efro/entity/_value.py \
build/ba_data/python/bacommon/__init__.py \
build/ba_data/python/bacommon/assets.py \
build/ba_data/python/bacommon/serverutils.py \
build/ba_data/python/bacommon/err.py \
build/ba_data/python/ba/_dualteamsession.py \
build/ba_data/python/ba/_gameactivity.py \
@ -361,7 +362,6 @@ SCRIPT_TARGETS_PY_1 = \
build/ba_data/python/bastd/actor/flag.py \
build/ba_data/python/bastd/actor/scoreboard.py \
build/ba_data/python/bastd/actor/popuptext.py \
build/ba_data/python/bastd/actor/multiteamvictory.py \
build/ba_data/python/bastd/actor/background.py \
build/ba_data/python/bastd/actor/__init__.py \
build/ba_data/python/bastd/actor/zoomtext.py \
@ -394,6 +394,7 @@ SCRIPT_TARGETS_PYC_1 = \
build/ba_data/python/efro/entity/__pycache__/_value.cpython-37.opt-1.pyc \
build/ba_data/python/bacommon/__pycache__/__init__.cpython-37.opt-1.pyc \
build/ba_data/python/bacommon/__pycache__/assets.cpython-37.opt-1.pyc \
build/ba_data/python/bacommon/__pycache__/serverutils.cpython-37.opt-1.pyc \
build/ba_data/python/bacommon/__pycache__/err.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_dualteamsession.cpython-37.opt-1.pyc \
build/ba_data/python/ba/__pycache__/_gameactivity.cpython-37.opt-1.pyc \
@ -599,7 +600,6 @@ SCRIPT_TARGETS_PYC_1 = \
build/ba_data/python/bastd/actor/__pycache__/flag.cpython-37.opt-1.pyc \
build/ba_data/python/bastd/actor/__pycache__/scoreboard.cpython-37.opt-1.pyc \
build/ba_data/python/bastd/actor/__pycache__/popuptext.cpython-37.opt-1.pyc \
build/ba_data/python/bastd/actor/__pycache__/multiteamvictory.cpython-37.opt-1.pyc \
build/ba_data/python/bastd/actor/__pycache__/background.cpython-37.opt-1.pyc \
build/ba_data/python/bastd/actor/__pycache__/__init__.cpython-37.opt-1.pyc \
build/ba_data/python/bastd/actor/__pycache__/zoomtext.cpython-37.opt-1.pyc \
@ -700,6 +700,11 @@ build/ba_data/python/bacommon/__pycache__/assets.cpython-37.opt-1.pyc: \
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
build/ba_data/python/bacommon/__pycache__/serverutils.cpython-37.opt-1.pyc: \
build/ba_data/python/bacommon/serverutils.py
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
build/ba_data/python/bacommon/__pycache__/err.cpython-37.opt-1.pyc: \
build/ba_data/python/bacommon/err.py
@echo Compiling script: $^
@ -1725,11 +1730,6 @@ build/ba_data/python/bastd/actor/__pycache__/popuptext.cpython-37.opt-1.pyc: \
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
build/ba_data/python/bastd/actor/__pycache__/multiteamvictory.cpython-37.opt-1.pyc: \
build/ba_data/python/bastd/actor/multiteamvictory.py
@echo Compiling script: $^
@rm -rf $@ && $(TOOLS_DIR)/snippets compile_python_files $^ && chmod 444 $@
build/ba_data/python/bastd/actor/__pycache__/background.cpython-37.opt-1.pyc: \
build/ba_data/python/bastd/actor/background.py
@echo Compiling script: $^

View File

@ -34,7 +34,7 @@ NOTE: This file was autogenerated by gendummymodule; do not edit by hand.
"""
# (hash we can use to see if this file is out of date)
# SOURCES_HASH=191214134064734166962017844135940898076
# SOURCES_HASH=246156902495207849674484484080481539734
# I'm sorry Pylint. I know this file saddens you. Be strong.
# pylint: disable=useless-suppression
@ -3426,8 +3426,8 @@ def set_public_party_name(name: str) -> None:
return None
def set_public_party_stats_url(url: str) -> None:
"""set_public_party_stats_url(url: str) -> None
def set_public_party_stats_url(url: Optional[str]) -> None:
"""set_public_party_stats_url(url: Optional[str]) -> None
(internal)
"""

View File

@ -59,6 +59,7 @@ from ba._gameresults import TeamGameResults
from ba._lang import Lstr, setlanguage, get_valid_languages
from ba._map import Map, getmaps
from ba._session import Session
from ba._server import Server
from ba._stats import PlayerScoredMessage, PlayerRecord, Stats
from ba._team import Team
from ba._teamgame import TeamGameActivity

View File

@ -326,24 +326,22 @@ class Achievement:
def description_full(self) -> ba.Lstr:
"""Get a ba.Lstr for the Achievement's full description."""
from ba._lang import Lstr
return Lstr(resource='achievements.' + self._name + '.descriptionFull',
subs=[('${LEVEL}',
Lstr(translate=[
'coopLevelNames',
ACH_LEVEL_NAMES.get(self._name, '?')
]))])
return Lstr(
resource='achievements.' + self._name + '.descriptionFull',
subs=[('${LEVEL}',
Lstr(translate=('coopLevelNames',
ACH_LEVEL_NAMES.get(self._name, '?'))))])
@property
def description_full_complete(self) -> ba.Lstr:
"""Get a ba.Lstr for the Achievement's full desc. when completed."""
from ba._lang import Lstr
return Lstr(resource='achievements.' + self._name +
'.descriptionFullComplete',
subs=[('${LEVEL}',
Lstr(translate=[
'coopLevelNames',
ACH_LEVEL_NAMES.get(self._name, '?')
]))])
return Lstr(
resource='achievements.' + self._name + '.descriptionFullComplete',
subs=[('${LEVEL}',
Lstr(translate=('coopLevelNames',
ACH_LEVEL_NAMES.get(self._name, '?'))))])
def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
"""Get the ticket award value for this achievement."""

View File

@ -21,7 +21,6 @@
"""Some handy base class and special purpose Activity types."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
import _ba
@ -40,7 +39,7 @@ class EndSessionActivity(Activity):
def __init__(self, settings: Dict[str, Any]):
super().__init__(settings)
# Keeps prev activity alive while we fadeout.
# Keeps prev activity alive while we fade out.
self.transition_time = 0.25
self.inherits_tint = True
self.inherits_slow_motion = True
@ -147,13 +146,14 @@ class ScoreScreenActivity(Activity):
self.default_music: Optional[MusicType] = MusicType.SCORES
self._birth_time = _ba.time()
self._min_view_time = 5.0
self._allow_server_restart = False
self._allow_server_transition = False
self._background: Optional[ba.Actor] = None
self._tips_text: Optional[ba.Actor] = None
self._kicked_off_server_shutdown = False
self._kicked_off_server_restart = False
self._default_show_tips = True
self._custom_continue_message: Optional[ba.Lstr] = None
self._server_transitioning: Optional[bool] = None
def on_player_join(self, player: ba.Player) -> None:
from ba import _general
@ -207,10 +207,18 @@ class ScoreScreenActivity(Activity):
def _player_press(self) -> None:
# If we're running in server-mode and it wants to shut down
# or restart, this is a good place to do it
if self._handle_server_restarts():
# If this activity is a good 'end point', ask server-mode just once if
# it wants to do anything special like switch sessions or kill the app.
if (self._allow_server_transition and _ba.app.server is not None
and self._server_transitioning is None):
self._server_transitioning = _ba.app.server.handle_transition()
assert isinstance(self._server_transitioning, bool)
# If server-mode is handling this, don't do anything ourself.
if self._server_transitioning is True:
return
# Otherwise end the activity normally.
self.end()
def _safe_assign(self, player: ba.Player) -> None:
@ -221,48 +229,3 @@ class ScoreScreenActivity(Activity):
player.assign_input_call(
('jumpPress', 'punchPress', 'bombPress', 'pickUpPress'),
self._player_press)
def _handle_server_restarts(self) -> bool:
"""Handle automatic restarts/shutdowns in server mode.
Returns True if an action was taken; otherwise default action
should occur (starting next round, etc).
"""
# pylint: disable=cyclic-import
# FIXME: Move server stuff to its own module.
if self._allow_server_restart and _ba.app.server_config_dirty:
from ba import _server
from ba._lang import Lstr
from ba._general import Call
from ba._enums import TimeType
if _ba.app.server_config.get('quit', False):
if not self._kicked_off_server_shutdown:
if _ba.app.server_config.get(
'quit_reason') == 'restarting':
# FIXME: Should add a server-screen-message call
# or something.
_ba.chat_message(
Lstr(resource='internal.serverRestartingText').
evaluate())
print(('Exiting for server-restart at ' +
time.strftime('%c')))
else:
print(('Exiting for server-shutdown at ' +
time.strftime('%c')))
with _ba.Context('ui'):
_ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
self._kicked_off_server_shutdown = True
return True
else:
if not self._kicked_off_server_restart:
print(('Running updated server config at ' +
time.strftime('%c')))
with _ba.Context('ui'):
_ba.timer(1.0,
Call(_ba.pushcall,
_server.launch_server_session),
timetype=TimeType.REAL)
self._kicked_off_server_restart = True
return True
return False

View File

@ -355,13 +355,14 @@ class App:
self.campaigns: Dict[str, ba.Campaign] = {}
# Server-Mode.
self.server_config: Dict[str, Any] = {}
self.server_config_dirty = False
self.run_server_wait_timer: Optional[ba.Timer] = None
self.server_playlist_fetch: Optional[Dict[str, Any]] = None
self.next_server_account_warn_time: Optional[float] = None
self.launched_server = False
self.run_server_first_run = True
self.server: Optional[ba.Server] = None
# self.server_config: Dict[str, Any] = {}
# self.server_config_dirty = False
# self.run_server_wait_timer: Optional[ba.Timer] = None
# self.server_playlist_fetch: Optional[Dict[str, Any]] = None
# self.next_server_account_warn_time: Optional[float] = None
# self.launched_server = False
# self.run_server_first_run = True
# Ads.
self.last_ad_network = 'unknown'

View File

@ -862,6 +862,7 @@ class GameActivity(Activity):
if 'sound' in tip:
sound = tip['sound']
tip = tip['tip']
assert isinstance(tip, str)
# a few subs..
tip_lstr = Lstr(translate=('tips', tip),

View File

@ -23,16 +23,16 @@ from __future__ import annotations
import json
import os
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, overload
import _ba
if TYPE_CHECKING:
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple, Union, Sequence
class Lstr:
"""Used to specify strings in a language-independent way.
"""Used to define strings in a language-independent way.
category: General Utility Classes
@ -63,6 +63,38 @@ class Lstr:
subs=[('${NAME}', ba.Lstr(resource='res_b'))])
"""
# pylint: disable=redefined-outer-name
# noinspection PyDefaultArgument
@overload
def __init__(self,
*,
resource: str,
fallback_resource: str = '',
fallback_value: str = '',
subs: Sequence[Tuple[str, Union[str, Lstr]]] = []) -> None:
"""Create an Lstr from a string resource."""
...
# noinspection PyShadowingNames,PyDefaultArgument
@overload
def __init__(self,
*,
translate: Tuple[str, str],
subs: Sequence[Tuple[str, Union[str, Lstr]]] = []) -> None:
"""Create an Lstr by translating a string in a category."""
...
# noinspection PyDefaultArgument
@overload
def __init__(self,
*,
value: str,
subs: Sequence[Tuple[str, Union[str, Lstr]]] = []) -> None:
"""Create an Lstr from a raw string value."""
...
# pylint: enable=redefined-outer-name
def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a Lstr.

View File

@ -21,13 +21,14 @@
"""Functionality related to running the game in server-mode."""
from __future__ import annotations
import copy
import json
import os
import sys
import time
from typing import TYPE_CHECKING
from ba._enums import TimeType
from ba._freeforallsession import FreeForAllSession
from ba._dualteamsession import DualTeamSession
from bacommon.serverutils import ServerConfig, ServerCommand
import _ba
if TYPE_CHECKING:
@ -35,223 +36,266 @@ if TYPE_CHECKING:
import ba
def config_server(config_file: str = None) -> None:
"""Run the game in server mode with the provided server config file."""
def _cmd(command_data: bytes) -> None:
"""Handle commands coming in from the server wrapper."""
import pickle
command, payload = pickle.loads(command_data)
assert isinstance(command, ServerCommand)
from ba._enums import TimeType
# We expect to receive a config command to kick things off.
if command is ServerCommand.CONFIG:
assert isinstance(payload, ServerConfig)
assert _ba.app.server is None
_ba.app.server = Server(payload)
return
app = _ba.app
# Read and store the provided server config and then delete the file it
# came from.
if config_file is not None:
with open(config_file) as infile:
app.server_config = json.loads(infile.read())
os.remove(config_file)
else:
app.server_config = {}
# Make note if they want us to import a playlist;
# we'll need to do that first if so.
playlist_code = app.server_config.get('playlist_code')
if playlist_code is not None:
app.server_playlist_fetch = {
'sent_request': False,
'got_response': False,
'playlist_code': str(playlist_code)
}
# Apply config stuff that can take effect immediately (party name, etc).
_config_server()
# Launch the server only the first time through;
# after that it will be self-sustaining.
if not app.launched_server:
app.next_server_account_warn_time = time.time() + 10.0
# Now sit around until we're signed in and then kick off the server.
with _ba.Context('ui'):
def do_it() -> None:
signed_in = _ba.get_account_state() == 'signed_in'
if not signed_in:
curtime = time.time()
assert app.next_server_account_warn_time is not None
if curtime > app.next_server_account_warn_time:
print('Still waiting for account sign-in...')
app.next_server_account_warn_time = curtime + 10.0
else:
can_launch = False
# If we're trying to fetch a playlist, we do that first.
if app.server_playlist_fetch is not None:
# Send request if we haven't.
if not app.server_playlist_fetch['sent_request']:
def on_playlist_fetch_response(
result: Optional[Dict[str, Any]]) -> None:
if result is None:
print('Error fetching playlist; aborting.')
sys.exit(-1)
# Once we get here we simply modify our
# config to use this playlist.
type_name = (
'teams' if
result['playlistType'] == 'Team Tournament'
else 'ffa' if result['playlistType'] ==
'Free-for-All' else '??')
print(('Playlist \'' + result['playlistName'] +
'\' (' + type_name +
') downloaded; running...'))
assert app.server_playlist_fetch is not None
app.server_playlist_fetch['got_response'] = (
True)
app.server_config['session_type'] = type_name
app.server_config['playlist_name'] = (
result['playlistName'])
print(('Requesting shared-playlist ' + str(
app.server_playlist_fetch['playlist_code']) +
'...'))
app.server_playlist_fetch['sent_request'] = True
_ba.add_transaction(
{
'type':
'IMPORT_PLAYLIST',
'code':
app.
server_playlist_fetch['playlist_code'],
'overwrite':
True
},
callback=on_playlist_fetch_response)
_ba.run_transactions()
# If we got a valid result, forget the fetch ever
# existed and move on.
if app.server_playlist_fetch['got_response']:
app.server_playlist_fetch = None
can_launch = True
else:
can_launch = True
if can_launch:
app.run_server_wait_timer = None
_ba.pushcall(launch_server_session)
app.run_server_wait_timer = _ba.Timer(0.25,
do_it,
timetype=TimeType.REAL,
repeat=True)
app.launched_server = True
assert _ba.app.server is not None
print('WOULD DO OTHER SERVER COMMAND')
def launch_server_session() -> None:
"""Kick off a host-session based on the current server config."""
from ba._netutils import serverget
from ba import _freeforallsession
from ba import _dualteamsession
app = _ba.app
servercfg = copy.deepcopy(app.server_config)
appcfg = app.config
class Server:
"""Overall controller for the app in server mode.
# Convert string session type to the class.
# Hmm should we just keep this as a string?
session_type_name = servercfg.get('session_type', 'ffa')
sessiontype: Type[ba.Session]
if session_type_name == 'ffa':
sessiontype = _freeforallsession.FreeForAllSession
elif session_type_name == 'teams':
sessiontype = _dualteamsession.DualTeamSession
else:
raise Exception('invalid session_type value: ' + session_type_name)
if _ba.get_account_state() != 'signed_in':
print('WARNING: launch_server_session() expects to run '
'with a signed in server account')
if app.run_server_first_run:
print((('BallisticaCore headless '
if app.headless_build else 'BallisticaCore ') +
str(app.version) + ' (' + str(app.build_number) +
') entering server-mode ' + time.strftime('%c')))
playlist_shuffle = servercfg.get('playlist_shuffle', True)
appcfg['Show Tutorial'] = False
appcfg['Free-for-All Playlist Selection'] = (servercfg.get(
'playlist_name', '__default__') if session_type_name == 'ffa' else
'__default__')
appcfg['Free-for-All Playlist Randomize'] = playlist_shuffle
appcfg['Team Tournament Playlist Selection'] = (servercfg.get(
'playlist_name', '__default__') if session_type_name == 'teams' else
'__default__')
appcfg['Team Tournament Playlist Randomize'] = playlist_shuffle
appcfg['Port'] = servercfg.get('port', 43210)
# Set series lengths.
app.teams_series_length = servercfg.get('teams_series_length', 7)
app.ffa_series_length = servercfg.get('ffa_series_length', 24)
# And here we go.
_ba.new_host_session(sessiontype)
# Also lets fire off an access check if this is our first time
# through (and they want a public party).
if app.run_server_first_run:
def access_check_response(data: Optional[Dict[str, Any]]) -> None:
gameport = _ba.get_game_port()
if data is None:
print('error on UDP port access check (internet down?)')
else:
if data['accessible']:
print('UDP port', gameport,
('access check successful. Your '
'server appears to be joinable '
'from the internet.'))
else:
print('UDP port', gameport,
('access check failed. Your server '
'does not appear to be joinable '
'from the internet.'))
port = _ba.get_game_port()
serverget('bsAccessCheck', {
'port': port,
'b': app.build_number
},
callback=access_check_response)
app.run_server_first_run = False
app.server_config_dirty = False
def _config_server() -> None:
"""Apply server config changes that can take effect immediately.
(party name, etc)
Category: App Classes
"""
config = copy.deepcopy(_ba.app.server_config)
# FIXME: Should make a proper low level config entry for this or
# else not store in in app.config. Probably shouldn't be going through
# the app config for this anyway since it should just be for this run.
_ba.app.config['Auto Balance Teams'] = (config.get('auto_balance_teams',
True))
def __init__(self, config: ServerConfig) -> None:
print('Server()')
_ba.set_public_party_max_size(config.get('max_party_size', 9))
_ba.set_public_party_name(config.get('party_name', 'party'))
_ba.set_public_party_stats_url(config.get('stats_url', ''))
self._config = config
self._playlist_name = '__default__'
# Call set-enabled last (will push state).
_ba.set_public_party_enabled(config.get('party_is_public', True))
self._ran_access_check = False
self._run_server_wait_timer: Optional[ba.Timer] = None
if not _ba.app.run_server_first_run:
print('server config updated.')
self._first_run = True
# FIXME: We could avoid setting this as dirty if the only changes have
# been ones here we can apply immediately. Could reduce cases where
# players have to rejoin.
_ba.app.server_config_dirty = True
# 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
self._config_server()
# Launch the server only the first time through;
# after that it will be self-sustaining.
self._next_server_account_warn_time = time.time() + 10.0
# Now sit around until we're signed in and then
# kick off the server.
with _ba.Context('ui'):
self._run_server_wait_timer = _ba.Timer(
0.25,
self._update_server_playlist_fetch,
timetype=TimeType.REAL,
repeat=True)
def launch_server_session(self) -> None:
"""Kick off a host-session based on the current server config."""
app = _ba.app
appcfg = app.config
sessiontype = self._get_session_type()
if _ba.get_account_state() != 'signed_in':
print('WARNING: launch_server_session() expects to run '
'with a signed in server account')
if self._first_run:
print((('BallisticaCore headless '
if app.headless_build else 'BallisticaCore ') +
str(app.version) + ' (' + str(app.build_number) +
') entering server-mode ' + time.strftime('%c')))
appcfg['Show Tutorial'] = False
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)
else:
raise RuntimeError(f'Unknown session type {sessiontype}')
appcfg['Port'] = self._config.port
# Set series lengths.
app.teams_series_length = self._config.teams_series_length
app.ffa_series_length = self._config.ffa_series_length
# And here we go.
_ba.new_host_session(sessiontype)
if not self._ran_access_check:
self._run_access_check()
self._ran_access_check = True
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.
"""
print('FIXME: fill out server handle_transition()')
# If the app is in server mode and this activity
# if self._allow_server_transition and _ba.app.server_config_dirty:
# from ba import _server
# from ba._lang import Lstr
# from ba._general import Call
# from ba._enums import TimeType
# if _ba.app.server_config.get('quit', False):
# if not self._kicked_off_server_shutdown:
# if _ba.app.server_config.get(
# 'quit_reason') == 'restarting':
# # FIXME: Should add a server-screen-message call
# # or something.
# _ba.chat_message(
# Lstr(resource='internal.serverRestartingText').
# evaluate())
# print(('Exiting for server-restart at ' +
# time.strftime('%c')))
# else:
# print(('Exiting for server-shutdown at ' +
# time.strftime('%c')))
# with _ba.Context('ui'):
# _ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
# self._kicked_off_server_shutdown = True
# return True
# else:
# if not self._kicked_off_server_restart:
# print(('Running updated server config at ' +
# time.strftime('%c')))
# with _ba.Context('ui'):
# _ba.timer(1.0,
# Call(_ba.pushcall,
# _server.launch_server_session),
# timetype=TimeType.REAL)
# self._kicked_off_server_restart = True
# return True
return False
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
raise RuntimeError(
f'Invalid session_type: "{self._config.session_type}"')
def _update_server_playlist_fetch(self) -> None:
signed_in = _ba.get_account_state() == 'signed_in'
if not signed_in:
curtime = time.time()
if curtime > self._next_server_account_warn_time:
print('Still waiting for account sign-in...')
self._next_server_account_warn_time = curtime + 10.0
else:
can_launch = False
# If we're trying to fetch a playlist, we do that first.
# if self._server_playlist_fetch is not None:
if self._playlist_fetch_running:
# Send request if we haven't.
if not self._playlist_fetch_sent_request:
print(f'Requesting shared-playlist'
f' {self._config.playlist_code}...')
_ba.add_transaction(
{
'type': 'IMPORT_PLAYLIST',
'code': str(self._config.playlist_code),
'overwrite': True
},
callback=self._on_playlist_fetch_response)
_ba.run_transactions()
self._playlist_fetch_sent_request = True
# If we got a valid result, forget the fetch ever
# existed and move on.
if self._playlist_fetch_got_response:
self._playlist_fetch_running = False
can_launch = True
else:
can_launch = True
if can_launch:
self._run_server_wait_timer = None
_ba.pushcall(self.launch_server_session)
def _on_playlist_fetch_response(
self,
result: Optional[Dict[str, Any]],
) -> None:
if result is None:
print('Error fetching playlist;' ' aborting.')
sys.exit(-1)
# Once we get here we simply modify our
# config to use this playlist.
type_name = (
'teams' if result['playlistType'] == 'Team Tournament' else
'ffa' if result['playlistType'] == 'Free-for-All' else '??')
print(('Playlist \'' + result['playlistName'] + '\' (' + type_name +
') downloaded; running...'))
self._playlist_fetch_got_response = True
self._config.session_type = type_name
self._playlist_name = (result['playlistName'])
def _run_access_check(self) -> None:
"""Check with the master server to see if we're likely joinable."""
from ba._netutils import serverget
serverget(
'bsAccessCheck',
{
'port': _ba.get_game_port(),
'b': _ba.app.build_number
},
callback=self._access_check_response,
)
def _access_check_response(self, data: Optional[Dict[str, Any]]) -> None:
gameport = _ba.get_game_port()
if data is None:
print('error on UDP port access check (internet down?)')
else:
if data['accessible']:
print('UDP port', gameport, ('access check successful. Your '
'server appears to be joinable '
'from the internet.'))
else:
print('UDP port', gameport,
('access check failed. Your server '
'does not appear to be joinable '
'from the internet.'))
def _config_server(self) -> None:
"""Apply server config changes that can take effect immediately.
(party name, etc)
"""
_ba.app.config['Auto Balance Teams'] = (
self._config.auto_balance_teams)
_ba.set_public_party_max_size(self._config.max_party_size)
_ba.set_public_party_name(self._config.party_name)
_ba.set_public_party_stats_url(self._config.stats_url)
# Call set-enabled last (will push state to the cloud).
_ba.set_public_party_enabled(self._config.party_is_public)

View File

@ -0,0 +1,160 @@
# Copyright (c) 2011-2020 Eric Froemling
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# -----------------------------------------------------------------------------
"""Functionality related to running standalone server."""
from __future__ import annotations
from enum import Enum
from dataclasses import dataclass
from typing import TYPE_CHECKING, overload
if TYPE_CHECKING:
from typing import Optional, Any, Tuple
from typing_extensions import Literal
@dataclass
class ServerConfig:
"""Configuration for running a standalone server."""
# Name of our server in the public parties list.
party_name: str = 'FFA'
# If True, your party will show up in the global public party list
# Otherwise it will still be joinable via LAN or connecting by IP address.
party_is_public: bool = True
# UDP port to host on. Change this to work around firewalls or run multiple
# servers on one machine.
# 43210 is the default and the only port that will show up in the LAN
# browser tab.
port: int = 43210
# Max devices in the party. Note that this does *NOT* mean max players.
# Any device in the party can have more than one player on it if they have
# multiple controllers. Also, this number currently includes the server so
# generally make it 1 bigger than you need. Max-players is not currently
# exposed but I'll try to add that soon.
max_party_size: int = 6
# Options here are 'ffa' (free-for-all) and 'teams'
# This value is only used if you do not supply a playlist_code (see below).
# In that case the default teams or free-for-all playlist gets used.
session_type: str = 'ffa'
# To host your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game.
# This will give you a numeric code you can enter here to host that
# playlist.
playlist_code: Optional[int] = None
# Whether to shuffle the playlist or play its games in designated order.
playlist_shuffle: bool = True
# If True, keeps team sizes equal by disallowing joining the largest team
# (teams mode only).
auto_balance_teams: bool = True
# Whether to enable telnet access.
# This allows you to run python commands on the server as it is running.
# Note: you can now also run live commands via stdin so telnet is generally
# unnecessary. BallisticaCore's telnet server is very simple so you may
# have to turn off any fancy features in your telnet client to get it to
# work. There is no password protection so make sure to only enable this
# if access to this port is fully trusted (behind a firewall, etc).
# IMPORTANT: Telnet is not encrypted at all, so you really should not
# expose it's port to the world. If you need remote access, consider
# connecting to your machine via ssh and running telnet to localhost
# from there.
enable_telnet: bool = False
# Port used for telnet.
telnet_port: int = 43250
# This can be None for no password but PLEASE do not expose that to the
# world or your machine will likely get owned.
telnet_password: Optional[str] = 'changeme'
# Series length in teams mode (7 == 'best-of-7' series; a team must
# get 4 wins)
teams_series_length: int = 7
# Points to win in free-for-all mode (Points are awarded per game based on
# performance)
ffa_series_length: int = 24
# If you provide a custom stats webpage for your server, you can use
# this to provide a convenient in-game link to it in the server-browser
# beside the server name.
# if ${ACCOUNT} is present in the string, it will be replaced by the
# currently-signed-in account's id. To get info about an account,
# you can use the following url:
# http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE
stats_url: Optional[str] = None
# FIXME REMOVE
quit: bool = False
# FIXME REMOVE
quit_reason: Optional[str] = None
# NOTE: as much as possible, communication from the server-wrapper to the
# server-binary should go through this and not ad-hoc python string commands
# since this way is type safe.
class ServerCommand(Enum):
"""Command types that can be sent to the app in server-mode."""
CONFIG = 'config'
QUIT = 'quit'
@overload
def make_server_command(command: Literal[ServerCommand.CONFIG],
payload: ServerConfig) -> bytes:
"""Overload for CONFIG commands."""
...
@overload
def make_server_command(command: Literal[ServerCommand.QUIT],
payload: int) -> bytes:
"""Overload for QUIT commands."""
...
def make_server_command(command: ServerCommand, payload: Any) -> bytes:
"""Create a command that can be exec'ed on the server binary."""
import pickle
# Pickle this stuff down to bytes and wrap it in a command to
# extract/run it on the other end.
val = repr(pickle.dumps((command, payload)))
assert '\n' not in val
return f'import ba._server; ba._server._cmd({val})\n'.encode()
def extract_server_command(cmd: str) -> Tuple[ServerCommand, Any]:
"""Given a server-command string, returns command objects."""
# Yes, eval is unsafe and all that, but this is only intended
# for communication between a parent and child process so we
# can live with it here.
print('would extract', cmd)
return ServerCommand.CONFIG, None

View File

@ -563,6 +563,7 @@ class CoopScoreScreen(ba.Activity):
maxwidth=270,
color=(0.5, 0.7, 0.5, 1),
position=(270, -235)).autoretain()
assert self._next_level_name is not None
Text(ba.Lstr(translate=('coopLevelNames', self._next_level_name)),
transition=Text.Transition.IN_RIGHT,
transition_delay=5.2,

View File

@ -38,7 +38,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
super().__init__(settings=settings)
self._min_view_time = 15.0
self._is_ffa = isinstance(self.session, ba.FreeForAllSession)
self._allow_server_restart = True
self._allow_server_transition = True
self._tips_text = None
def on_transition_in(self) -> None:
@ -192,6 +192,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
scale=(70, 70),
transition=Image.Transition.IN_LEFT,
transition_delay=tval).autoretain()
assert mvp_name is not None
Text(ba.Lstr(value=mvp_name),
position=(280, ts_height / 2 - 55 + 15 - 5),
h_align=Text.HAlign.LEFT,
@ -237,6 +238,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
scale=(50, 50),
transition=Image.Transition.IN_LEFT,
transition_delay=tval).autoretain()
assert mvp_name is not None
Text(ba.Lstr(value=mvp_name),
position=(270, ts_height / 2 - 150 - 30 - 36 + v_extra + 15),
h_align=Text.HAlign.LEFT,
@ -281,6 +283,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
scale=(50, 50),
transition=Image.Transition.IN_LEFT,
transition_delay=tval).autoretain()
assert mkp_name is not None
Text(ba.Lstr(value=mkp_name),
position=(270, ts_height / 2 - 300 - 30 - 36 + v_extra + 15),
h_align=Text.HAlign.LEFT,

View File

@ -1,392 +0,0 @@
# Copyright (c) 2011-2020 Eric Froemling
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
# -----------------------------------------------------------------------------
"""Functionality related to the final screen in multi-teams sessions."""
from __future__ import annotations
from typing import TYPE_CHECKING
import ba
from bastd.activity.multiteamscore import MultiTeamScoreScreenActivity
if TYPE_CHECKING:
from typing import Any, Dict, List, Tuple, Optional
class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
"""Final score screen for a team series."""
def __init__(self, settings: Dict[str, Any]):
super().__init__(settings=settings)
self._min_view_time = 15.0
self._is_ffa = isinstance(self.session, ba.FreeForAllSession)
self._allow_server_restart = True
self._tips_text = None
def on_transition_in(self) -> None:
# We don't yet want music and whatnot...
self.default_music = None
self._default_show_tips = False
super().on_transition_in()
def on_begin(self) -> None:
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
from bastd.actor.text import Text
from bastd.actor.image import Image
from ba.deprecated import get_resource
ba.set_analytics_screen('FreeForAll Series Victory Screen' if self.
_is_ffa else 'Teams Series Victory Screen')
if ba.app.interface_type == 'large':
sval = ba.Lstr(resource='pressAnyKeyButtonPlayAgainText')
else:
sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
self._show_up_next = False
self._custom_continue_message = sval
super().on_begin()
winning_team = self.settings['winner']
# Pause a moment before playing victory music.
ba.timer(0.6, ba.WeakCall(self._play_victory_music))
ba.timer(4.4, ba.WeakCall(self._show_winner, self.settings['winner']))
ba.timer(4.6, ba.Call(ba.playsound, self._score_display_sound))
# Score / Name / Player-record.
player_entries: List[Tuple[int, str, ba.PlayerRecord]] = []
# Note: for ffa, exclude players who haven't entered the game yet.
if self._is_ffa:
for _pkey, prec in self.stats.get_records().items():
if prec.player.in_game:
player_entries.append(
(prec.player.team.sessiondata['score'],
prec.get_name(full=True), prec))
player_entries.sort(reverse=True, key=lambda x: x[0])
else:
for _pkey, prec in self.stats.get_records().items():
player_entries.append((prec.score, prec.name_full, prec))
player_entries.sort(reverse=True, key=lambda x: x[0])
ts_height = 300.0
ts_h_offs = -390.0
tval = 6.4
t_incr = 0.12
always_use_first_to = get_resource('bestOfUseFirstToInstead')
session = self.session
if self._is_ffa:
assert isinstance(session, ba.FreeForAllSession)
txt = ba.Lstr(
value='${A}:',
subs=[('${A}',
ba.Lstr(resource='firstToFinalText',
subs=[('${COUNT}',
str(session.get_ffa_series_length()))]))
])
else:
assert isinstance(session, ba.MultiTeamSession)
# Some languages may prefer to always show 'first to X' instead of
# 'best of X'.
# FIXME: This will affect all clients connected to us even if
# they're not using this language. Should try to come up
# with a wording that works everywhere.
if always_use_first_to:
txt = ba.Lstr(
value='${A}:',
subs=[
('${A}',
ba.Lstr(resource='firstToFinalText',
subs=[
('${COUNT}',
str(session.get_series_length() / 2 + 1))
]))
])
else:
txt = ba.Lstr(
value='${A}:',
subs=[('${A}',
ba.Lstr(resource='bestOfFinalText',
subs=[('${COUNT}',
str(session.get_series_length()))]))
])
Text(txt,
v_align=Text.VAlign.CENTER,
maxwidth=300,
color=(0.5, 0.5, 0.5, 1.0),
position=(0, 220),
scale=1.2,
transition=Text.Transition.IN_TOP_SLOW,
h_align=Text.HAlign.CENTER,
transition_delay=t_incr * 4).autoretain()
win_score = (session.get_series_length() - 1) / 2 + 1
lose_score = 0
for team in self.teams:
if team.sessiondata['score'] != win_score:
lose_score = team.sessiondata['score']
if not self._is_ffa:
Text(ba.Lstr(resource='gamesToText',
subs=[('${WINCOUNT}', str(win_score)),
('${LOSECOUNT}', str(lose_score))]),
color=(0.5, 0.5, 0.5, 1.0),
maxwidth=160,
v_align=Text.VAlign.CENTER,
position=(0, -215),
scale=1.8,
transition=Text.Transition.IN_LEFT,
h_align=Text.HAlign.CENTER,
transition_delay=4.8 + t_incr * 4).autoretain()
if self._is_ffa:
v_extra = 120
else:
v_extra = 0
mvp: Optional[ba.PlayerRecord] = None
mvp_name: Optional[str] = None
# Show game MVP.
if not self._is_ffa:
mvp, mvp_name = None, None
for entry in player_entries:
if entry[2].team == winning_team:
mvp = entry[2]
mvp_name = entry[1]
break
if mvp is not None:
Text(ba.Lstr(resource='mostValuablePlayerText'),
color=(0.5, 0.5, 0.5, 1.0),
v_align=Text.VAlign.CENTER,
maxwidth=300,
position=(180, ts_height / 2 + 15),
transition=Text.Transition.IN_LEFT,
h_align=Text.HAlign.LEFT,
transition_delay=tval).autoretain()
tval += 4 * t_incr
Image(mvp.get_icon(),
position=(230, ts_height / 2 - 55 + 14 - 5),
scale=(70, 70),
transition=Image.Transition.IN_LEFT,
transition_delay=tval).autoretain()
Text(ba.Lstr(value=mvp_name),
position=(280, ts_height / 2 - 55 + 15 - 5),
h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER,
maxwidth=170,
scale=1.3,
color=ba.safecolor(mvp.team.color + (1, )),
transition=Text.Transition.IN_LEFT,
transition_delay=tval).autoretain()
tval += 4 * t_incr
# Most violent.
most_kills = 0
for entry in player_entries:
if entry[2].kill_count >= most_kills:
mvp = entry[2]
mvp_name = entry[1]
most_kills = entry[2].kill_count
if mvp is not None:
Text(ba.Lstr(resource='mostViolentPlayerText'),
color=(0.5, 0.5, 0.5, 1.0),
v_align=Text.VAlign.CENTER,
maxwidth=300,
position=(180, ts_height / 2 - 150 + v_extra + 15),
transition=Text.Transition.IN_LEFT,
h_align=Text.HAlign.LEFT,
transition_delay=tval).autoretain()
Text(ba.Lstr(value='(${A})',
subs=[('${A}',
ba.Lstr(resource='killsTallyText',
subs=[('${COUNT}', str(most_kills))]))
]),
position=(260, ts_height / 2 - 150 - 15 + v_extra),
color=(0.3, 0.3, 0.3, 1.0),
scale=0.6,
h_align=Text.HAlign.LEFT,
transition=Text.Transition.IN_LEFT,
transition_delay=tval).autoretain()
tval += 4 * t_incr
Image(mvp.get_icon(),
position=(233, ts_height / 2 - 150 - 30 - 46 + 25 + v_extra),
scale=(50, 50),
transition=Image.Transition.IN_LEFT,
transition_delay=tval).autoretain()
Text(ba.Lstr(value=mvp_name),
position=(270, ts_height / 2 - 150 - 30 - 36 + v_extra + 15),
h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER,
maxwidth=180,
color=ba.safecolor(mvp.team.color + (1, )),
transition=Text.Transition.IN_LEFT,
transition_delay=tval).autoretain()
tval += 4 * t_incr
# Most killed.
most_killed = 0
mkp, mkp_name = None, None
for entry in player_entries:
if entry[2].killed_count >= most_killed:
mkp = entry[2]
mkp_name = entry[1]
most_killed = entry[2].killed_count
if mkp is not None:
Text(ba.Lstr(resource='mostViolatedPlayerText'),
color=(0.5, 0.5, 0.5, 1.0),
v_align=Text.VAlign.CENTER,
maxwidth=300,
position=(180, ts_height / 2 - 300 + v_extra + 15),
transition=Text.Transition.IN_LEFT,
h_align=Text.HAlign.LEFT,
transition_delay=tval).autoretain()
Text(ba.Lstr(value='(${A})',
subs=[('${A}',
ba.Lstr(resource='deathsTallyText',
subs=[('${COUNT}', str(most_killed))]))
]),
position=(260, ts_height / 2 - 300 - 15 + v_extra),
h_align=Text.HAlign.LEFT,
scale=0.6,
color=(0.3, 0.3, 0.3, 1.0),
transition=Text.Transition.IN_LEFT,
transition_delay=tval).autoretain()
tval += 4 * t_incr
Image(mkp.get_icon(),
position=(233, ts_height / 2 - 300 - 30 - 46 + 25 + v_extra),
scale=(50, 50),
transition=Image.Transition.IN_LEFT,
transition_delay=tval).autoretain()
Text(ba.Lstr(value=mkp_name),
position=(270, ts_height / 2 - 300 - 30 - 36 + v_extra + 15),
h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER,
color=ba.safecolor(mkp.team.color + (1, )),
maxwidth=180,
transition=Text.Transition.IN_LEFT,
transition_delay=tval).autoretain()
tval += 4 * t_incr
# Now show individual scores.
tdelay = tval
Text(ba.Lstr(resource='finalScoresText'),
color=(0.5, 0.5, 0.5, 1.0),
position=(ts_h_offs, ts_height / 2),
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay).autoretain()
tdelay += 4 * t_incr
v_offs = 0.0
tdelay += len(player_entries) * 8 * t_incr
for _score, name, prec in player_entries:
tdelay -= 4 * t_incr
v_offs -= 40
Text(str(prec.team.sessiondata['score'])
if self._is_ffa else str(prec.score),
color=(0.5, 0.5, 0.5, 1.0),
position=(ts_h_offs + 230, ts_height / 2 + v_offs),
h_align=Text.HAlign.RIGHT,
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay).autoretain()
tdelay -= 4 * t_incr
Image(prec.get_icon(),
position=(ts_h_offs - 72, ts_height / 2 + v_offs + 15),
scale=(30, 30),
transition=Image.Transition.IN_LEFT,
transition_delay=tdelay).autoretain()
Text(ba.Lstr(value=name),
position=(ts_h_offs - 50, ts_height / 2 + v_offs + 15),
h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER,
maxwidth=180,
color=ba.safecolor(prec.team.color + (1, )),
transition=Text.Transition.IN_RIGHT,
transition_delay=tdelay).autoretain()
ba.timer(15.0, ba.WeakCall(self._show_tips))
def _show_tips(self) -> None:
from bastd.actor.tipstext import TipsText
self._tips_text = TipsText(offs_y=70)
def _play_victory_music(self) -> None:
# Make sure we don't stomp on the next activity's music choice.
if not self.is_transitioning_out():
ba.setmusic(ba.MusicType.VICTORY)
def _show_winner(self, team: ba.Team) -> None:
from bastd.actor.image import Image
from bastd.actor.zoomtext import ZoomText
if not self._is_ffa:
offs_v = 0.0
ZoomText(team.name,
position=(0, 97),
color=team.color,
scale=1.15,
jitter=1.0,
maxwidth=250).autoretain()
else:
offs_v = -80.0
if len(team.players) == 1:
i = Image(team.players[0].get_icon(),
position=(0, 143),
scale=(100, 100)).autoretain()
assert i.node
ba.animate(i.node, 'opacity', {0.0: 0.0, 0.25: 1.0})
ZoomText(ba.Lstr(
value=team.players[0].get_name(full=True, icon=False)),
position=(0, 97 + offs_v),
color=team.color,
scale=1.15,
jitter=1.0,
maxwidth=250).autoretain()
s_extra = 1.0 if self._is_ffa else 1.0
# Some languages say "FOO WINS" differently for teams vs players.
if isinstance(self.session, ba.FreeForAllSession):
wins_resource = 'seriesWinLine1PlayerText'
else:
wins_resource = 'seriesWinLine1TeamText'
wins_text = ba.Lstr(resource=wins_resource)
# Temp - if these come up as the english default, fall-back to the
# unified old form which is more likely to be translated.
ZoomText(wins_text,
position=(0, -10 + offs_v),
color=team.color,
scale=0.65 * s_extra,
jitter=1.0,
maxwidth=250).autoretain()
ZoomText(ba.Lstr(resource='seriesWinLine2Text'),
position=(0, -110 + offs_v),
scale=1.0 * s_extra,
color=team.color,
jitter=1.0,
maxwidth=250).autoretain()

View File

@ -309,14 +309,14 @@ class ProfileBrowserWindow(ba.Window):
continue
color, _highlight = get_player_profile_colors(p_name)
scl = 1.1
tval = (account_name if p_name == '__account__' else
get_player_profile_icon(p_name) + p_name)
assert isinstance(tval, str)
txtw = ba.textwidget(
parent=self._columnwidget,
position=(0, 32),
size=((self._width - 40) / scl, 28),
text=ba.Lstr(
value=account_name if p_name ==
'__account__' else get_player_profile_icon(p_name) +
p_name),
text=ba.Lstr(value=tval),
h_align='left',
v_align='center',
on_select_call=ba.WeakCall(self._select, p_name, index),

View File

@ -22,303 +22,189 @@
"""Functionality for running a BallisticaCore server."""
from __future__ import annotations
import copy
import json
import os
import subprocess
import sys
import tempfile
import os
import json
import subprocess
import threading
import time
import traceback
from pathlib import Path
from typing import TYPE_CHECKING
# We make use of the bacommon package and site-packages included
# with our bundled Ballistica dist.
sys.path += [
str(Path(os.getcwd(), 'dist', 'ba_data', 'python')),
str(Path(os.getcwd(), 'dist', 'ba_data', 'python-site-packages'))
]
from bacommon.serverutils import (ServerConfig, ServerCommand,
make_server_command)
if TYPE_CHECKING:
from typing import Dict, Any, Sequence, Optional
from typing import Optional, List
def _get_default_config() -> Dict[str, Any]:
# Config values are initialized with defaults here.
# You an add your own overrides in config.py.
# noinspection PyDictCreation
config: Dict[str, Any] = {}
# Name of our server in the public parties list.
config['party_name'] = 'FFA'
# If True, your party will show up in the global public party list
# Otherwise it will still be joinable via LAN or connecting by IP address.
config['party_is_public'] = True
# UDP port to host on. Change this to work around firewalls or run multiple
# servers on one machine.
# 43210 is the default and the only port that will show up in the LAN
# browser tab.
config['port'] = 43210
# Max devices in the party. Note that this does *NOT* mean max players.
# Any device in the party can have more than one player on it if they have
# multiple controllers. Also, this number currently includes the server so
# generally make it 1 bigger than you need. Max-players is not currently
# exposed but I'll try to add that soon.
config['max_party_size'] = 6
# Options here are 'ffa' (free-for-all) and 'teams'
# This value is only used if you do not supply a playlist_code (see below).
# In that case the default teams or free-for-all playlist gets used.
config['session_type'] = 'ffa'
# To host your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game.
# This will give you a numeric code you can enter here to host that
# playlist.
config['playlist_code'] = None
# Whether to shuffle the playlist or play its games in designated order.
config['playlist_shuffle'] = True
# If True, keeps team sizes equal by disallowing joining the largest team
# (teams mode only).
config['auto_balance_teams'] = True
# Whether to enable telnet access on port 43250
# This allows you to run python commands on the server as it is running.
# Note: you can now also run live commands via stdin so telnet is generally
# unnecessary. BallisticaCore's telnet server is very simple so you may
# have to turn off any fancy features in your telnet client to get it to
# work. There is no password protection so make sure to only enable this
# if access to this port is fully trusted (behind a firewall, etc).
# IMPORTANT: Telnet is not encrypted at all, so you really should not
# expose it's port to the world. If you need remote access, consider
# connecting to your machine via ssh and running telnet to localhost
# from there.
config['enable_telnet'] = False
# Port used for telnet.
config['telnet_port'] = 43250
# This can be None for no password but PLEASE do not expose that to the
# world or your machine will likely get owned.
config['telnet_password'] = 'changeme'
# Series length in teams mode (7 == 'best-of-7' series; a team must
# get 4 wins)
config['teams_series_length'] = 7
# Points to win in free-for-all mode (Points are awarded per game based on
# performance)
config['ffa_series_length'] = 24
# If you provide a custom stats webpage for your server, you can use
# this to provide a convenient in-game link to it in the server-browser
# beside the server name.
# if ${ACCOUNT} is present in the string, it will be replaced by the
# currently-signed-in account's id. To get info about an account,
# you can use the following url:
# http://bombsquadgame.com/accountquery?id=ACCOUNT_ID_HERE
config['stats_url'] = ''
return config
def _run_process_until_exit(process: subprocess.Popen,
input_commands: Sequence[str],
restart_minutes: int, config: Dict[str,
Any]) -> None:
# So we pass our initial config.
config_dirty = True
launch_time = time.time()
# Now just sleep and run commands until the server exits.
while True:
# Run any commands that came in through stdin.
for cmd in input_commands:
print("GOT INPUT COMMAND", cmd)
old_config = copy.deepcopy(config)
try:
print('FIXME: input commands need updating for python 3')
# exec(cmd)
except Exception:
traceback.print_exc()
if config != old_config:
config_dirty = True
input_commands = []
# Request a restart after a while.
if (time.time() - launch_time > 60 * restart_minutes
and not config['quit']):
print('restart_minutes (' + str(restart_minutes) +
'm) elapsed; requesting server restart '
'at next clean opportunity...')
config['quit'] = True
config['quit_reason'] = 'restarting'
config_dirty = True
# Whenever the config changes, dump it to a json file and feed
# it to the running server.
# FIXME: We can probably just pass the new config directly
# instead of dumping it to a file and passing the path.
if config_dirty:
# Note: The game handles deleting this file for us once its
# done with it.
ftmp = tempfile.NamedTemporaryFile(mode='w', delete=False)
fname = ftmp.name
ftmp.write(json.dumps(config))
ftmp.close()
# Note to self: Is there a type-safe way we could do this?
assert process.stdin is not None
process.stdin.write(('from ba import _server; '
'_server.config_server(config_file=' +
repr(fname) + ')\n').encode('utf-8'))
process.stdin.flush()
config_dirty = False
code: Optional[int] = process.poll()
if code is not None:
print('BallisticaCore exited with code ' + str(code))
break
time.sleep(1)
def _run_server_cycle(binary_path: str, config: Dict[str, Any],
input_commands: Sequence[str],
restart_minutes: int) -> None:
"""Bring up the server binary and run it until exit."""
# Most of our config values we can feed to ballisticacore as it is running
# (see below). However certain things such as network-port need to be
# present in the config file at launch, so let's write that out first.
if not os.path.exists('ba_root'):
os.mkdir('ba_root')
if os.path.exists('ba_root/config.json'):
with open('ba_root/config.json') as infile:
ba_root = json.loads(infile.read())
else:
ba_root = {}
ba_root['Port'] = config['port']
ba_root['Enable Telnet'] = config['enable_telnet']
ba_root['Telnet Port'] = config['telnet_port']
ba_root['Telnet Password'] = config['telnet_password']
with open('ba_root/config.json', 'w') as outfile:
outfile.write(json.dumps(ba_root))
# Launch our binary and grab its stdin; we'll use this to feed
# it commands.
process = subprocess.Popen([binary_path, '-cfgdir', 'ba_root'],
stdin=subprocess.PIPE)
# Set quit to True any time after launching the server to gracefully
# quit it at the next clean opportunity (end of the current series,
# etc).
config['quit'] = False
config['quit_reason'] = None
try:
_run_process_until_exit(process, input_commands, restart_minutes,
config)
# If we hit ANY Exceptions (including KeyboardInterrupt) we want to kill
# the server binary, so we need to catch BaseException.
except BaseException:
print("Stopping server...")
# First, ask it nicely to die and give it a moment.
# If that doesn't work, bring down the hammer.
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
print("Server stopped.")
raise
def main() -> None:
class App:
"""Runs a BallisticaCore server.
Handles passing config values to the game and periodically restarting
the game binary to keep things fresh.
"""
# We want to actually run from the 'dist' subdir.
if not os.path.isdir('dist'):
raise RuntimeError('"dist" directory not found.')
os.chdir('dist')
def __init__(self) -> None:
config_path = '../config.yaml'
binary_path = None
if os.name == 'nt':
test_paths = ['ballisticacore_headless.exe']
else:
test_paths = ['./ballisticacore_headless']
for path in test_paths:
if os.path.exists(path):
binary_path = path
break
if binary_path is None:
# We actually run from the 'dist' subdir.
if not os.path.isdir('dist'):
raise RuntimeError('"dist" directory not found.')
os.chdir('dist')
self._binary_path = self._get_binary_path()
self._config = ServerConfig()
# Launch a thread to listen for input
# (in daemon mode so it won't prevent us from dying)
self._input_commands: List[str] = []
thread = threading.Thread(target=self._read_input)
thread.daemon = True
thread.start()
# Print basic usage info in interactive mode.
if sys.stdin.isatty():
print("BallisticaCore Server wrapper starting up...")
# The server-binary will get relaunched after this amount of time
# (combats memory leaks or other cruft that has built up).
self._restart_minutes = 360.0
self._process: Optional[subprocess.Popen[bytes]] = None
self._process_launch_time: Optional[float] = None
# The standard python exit/quit help messages don't apply here
# so let's get rid of them.
del __builtins__.exit
del __builtins__.quit
def _get_binary_path(self) -> str:
"""Locate the game binary we'll run."""
if os.name == 'nt':
test_paths = ['ballisticacore_headless.exe']
else:
test_paths = ['./ballisticacore_headless']
for path in test_paths:
if os.path.exists(path):
return path
raise RuntimeError('Unable to locate ballisticacore_headless binary.')
config = _get_default_config()
def _read_input(self) -> None:
"""Read from stdin and queue results for the app to handle."""
while True:
line = sys.stdin.readline()
print('read line', line)
self._input_commands.append(line.strip())
# If config.py exists, run it to apply any overrides it wants.
if os.path.isfile(config_path):
# pylint: disable=exec-used
exec(compile(open(config_path).read(), config_path, 'exec'), globals(),
config)
def run(self) -> None:
"""Run the app loop to completion."""
# Launch a thread to read our stdin for commands; this lets us modify the
# server as it runs.
input_commands = []
# We currently never stop until explicitly killed.
while True:
self._run_server_cycle()
# Print a little spiel in interactive mode (make sure we do this before our
# thread reads stdin).
if sys.stdin.isatty():
print("BallisticaCore server wrapper starting up...")
# "tip: enter python commands via stdin to "
# "reconfigure the server on the fly:\n"
# "example: config['party_name'] = 'New Party Name'")
def _run_server_cycle(self) -> None:
"""Bring up the server binary and run it until exit."""
class InputThread(threading.Thread):
"""A thread that just sits around waiting for input from stdin."""
self._setup_process_config()
def run(self) -> None:
while True:
line = sys.stdin.readline()
input_commands.append(line.strip())
# Launch the binary and grab its stdin;
# we'll use this to feed it commands.
self._process_launch_time = time.time()
self._process = subprocess.Popen(
[self._binary_path, '-cfgdir', 'ba_root'], stdin=subprocess.PIPE)
thread = InputThread()
# Set quit to True any time after launching the server
# to gracefully quit it at the next clean opportunity
# (the end of the current series, etc).
self._config.quit = False
self._config.quit_reason = None
# Set daemon mode so this thread's existence won't stop us from dying.
thread.daemon = True
thread.start()
# Do the thing.
# If we hit ANY Exceptions (including KeyboardInterrupt),
# we want to kill the server binary, so we need to catch BaseException.
try:
self._run_process_until_exit()
except BaseException:
self._kill_process()
raise
restart_server = True
def _setup_process_config(self) -> None:
"""Write files that must exist at process launch."""
os.makedirs('ba_root', exist_ok=True)
if os.path.exists('ba_root/config.json'):
with open('ba_root/config.json') as infile:
bacfg = json.loads(infile.read())
else:
bacfg = {}
bacfg['Port'] = self._config.port
bacfg['Enable Telnet'] = self._config.enable_telnet
bacfg['Telnet Port'] = self._config.telnet_port
bacfg['Telnet Password'] = self._config.telnet_password
with open('ba_root/config.json', 'w') as outfile:
outfile.write(json.dumps(bacfg))
# The server-binary will get relaunched after this amount of time
# (combats memory leaks or other cruft that has built up).
restart_minutes = 360
def _run_process_until_exit(self) -> None:
assert self._process is not None
# The standard python exit/quit help messages don't apply here
# so let's get rid of them.
del __builtins__.exit
del __builtins__.quit
# Send the initial server config which should kick things off.
cmd = make_server_command(ServerCommand.CONFIG, self._config)
assert self._process.stdin is not None
self._process.stdin.write(cmd)
self._process.stdin.flush()
# Sleep for a moment to allow initial stdin data to get through
# (since it is being read in another thread).
time.sleep(0.25)
# Now just sleep and run commands until the process exits.
while True:
# Restart indefinitely until we're told not to.
while restart_server:
_run_server_cycle(binary_path, config, input_commands, restart_minutes)
# Pass along any commands that have come in through stdin.
for incmd in self._input_commands:
print("WOULD PASS ALONG COMMAND", incmd)
self._input_commands = []
# Request a restart after a while.
assert self._process_launch_time is not None
if (time.time() - self._process_launch_time >
(self._restart_minutes * 60.0) and not self._config.quit):
print('restart_minutes (' + str(self._restart_minutes) +
'm) elapsed; requesting server restart '
'at next clean opportunity...')
self._config.quit = True
self._config.quit_reason = 'restarting'
# Watch for the process exiting.
code: Optional[int] = self._process.poll()
if code is not None:
print('Server process exited with code ' + str(code))
self._process = None
self._process_launch_time = None
break
time.sleep(0.25)
def _kill_process(self) -> None:
"""Forcefully end the server process."""
print("Stopping server process...")
assert self._process is not None
# First, ask it nicely to die and give it a moment.
# If that doesn't work, bring down the hammer.
self._process.terminate()
try:
self._process.wait(timeout=10)
except subprocess.TimeoutExpired:
self._process.kill()
print("Server process stopped.")
if __name__ == '__main__':
try:
main()
App().run()
except KeyboardInterrupt:
pass

View File

@ -1,5 +1,5 @@
<!-- THIS FILE IS AUTO GENERATED; DO NOT EDIT BY HAND -->
<h4><em>last updated on 2020-04-22 for Ballistica version 1.5.0 build 20001</em></h4>
<h4><em>last updated on 2020-04-24 for Ballistica version 1.5.0 build 20001</em></h4>
<p>This page documents the Python classes and functions in the 'ba' module,
which are the ones most relevant to modding in Ballistica. If you come across something you feel should be included here or could be better explained, please <a href="mailto:support@froemling.net">let me know</a>. Happy modding!</p>
<hr>
@ -137,6 +137,7 @@
<li><a href="#class_ba_AppDelegate">ba.AppDelegate</a></li>
<li><a href="#class_ba_Campaign">ba.Campaign</a></li>
<li><a href="#class_ba_MusicPlayer">ba.MusicPlayer</a></li>
<li><a href="#class_ba_Server">ba.Server</a></li>
</ul>
<h4><a name="class_category_User_Interface_Classes">User Interface Classes</a></h4>
<ul>
@ -2736,7 +2737,7 @@ needs a chooser.</p>
<h2><strong><a name="class_ba_Lstr">ba.Lstr</a></strong></h3>
<p><em>&lt;top level class&gt;</em>
</p>
<p>Used to specify strings in a language-independent way.</p>
<p>Used to define strings in a language-independent way.</p>
<p>Category: <a href="#class_category_General_Utility_Classes">General Utility Classes</a></p>
@ -3971,6 +3972,40 @@ cause the powerup box to make a sound and disappear or whatnot.</p>
<dt><h4><a name="method_ba_PowerupMessage____init__">&lt;constructor&gt;</a></dt></h4><dd>
<p><span>ba.PowerupMessage(poweruptype: str, source_node: Optional[<a href="#class_ba_Node">ba.Node</a>] = None)</span></p>
</dd>
</dl>
<hr>
<h2><strong><a name="class_ba_Server">ba.Server</a></strong></h3>
<p><em>&lt;top level class&gt;</em>
</p>
<p>Overall controller for the app in server mode.</p>
<p>Category: <a href="#class_category_App_Classes">App Classes</a>
</p>
<h3>Methods:</h3>
<h5><a href="#method_ba_Server____init__">&lt;constructor&gt;</a>, <a href="#method_ba_Server__handle_transition">handle_transition()</a>, <a href="#method_ba_Server__launch_server_session">launch_server_session()</a></h5>
<dl>
<dt><h4><a name="method_ba_Server____init__">&lt;constructor&gt;</a></dt></h4><dd>
<p><span>ba.Server(config: ServerConfig)</span></p>
</dd>
<dt><h4><a name="method_ba_Server__handle_transition">handle_transition()</a></dt></h4><dd>
<p><span>handle_transition(self) -&gt; bool</span></p>
<p>Handle transitioning to a new <a href="#class_ba_Session">ba.Session</a> or quitting the app.</p>
<p>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.</p>
</dd>
<dt><h4><a name="method_ba_Server__launch_server_session">launch_server_session()</a></dt></h4><dd>
<p><span>launch_server_session(self) -&gt; None</span></p>
<p>Kick off a host-session based on the current server config.</p>
</dd>
</dl>
<hr>