diff --git a/.idea/dictionaries/ericf.xml b/.idea/dictionaries/ericf.xml
index fa786aa6..88bb2e39 100644
--- a/.idea/dictionaries/ericf.xml
+++ b/.idea/dictionaries/ericf.xml
@@ -148,6 +148,7 @@
baseurl
basew
bastd
+ batools
bbot
bbtn
bcppcompiler
@@ -374,6 +375,7 @@
cutscenes
cval
cwdg
+ cyaml
cygwinccompiler
darwiin
darwiinremote
@@ -835,6 +837,7 @@
imgw
incentivized
includetest
+ incmd
incr
incrementbuild
indentfilter
@@ -1452,6 +1455,7 @@
relpath
remainingchecks
remoteapp
+ representer
reprlib
reqs
resample
@@ -1533,6 +1537,7 @@
serverdialog
serverget
serverput
+ serverutils
sessionclass
sessiondata
sessionglobals
@@ -1638,6 +1643,7 @@
storable
storedhash
storeitemui
+ strftime
stringprep
stringptr
strobing
@@ -1888,6 +1894,7 @@
virotic
vmaddr
vmcfg
+ vmhgfs
vmrun
vmshell
vmware
diff --git a/assets/.asset_manifest_1.json b/assets/.asset_manifest_1.json
index 1e1f76a6..636e7b5c 100644
--- a/assets/.asset_manifest_1.json
+++ b/assets/.asset_manifest_1.json
@@ -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",
diff --git a/assets/Makefile b/assets/Makefile
index ddb5f995..a23585ec 100644
--- a/assets/Makefile
+++ b/assets/Makefile
@@ -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: $^
diff --git a/assets/src/ba_data/python/_ba.py b/assets/src/ba_data/python/_ba.py
index a311d968..cf31652c 100644
--- a/assets/src/ba_data/python/_ba.py
+++ b/assets/src/ba_data/python/_ba.py
@@ -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)
"""
diff --git a/assets/src/ba_data/python/ba/__init__.py b/assets/src/ba_data/python/ba/__init__.py
index 0b312e1b..f5b12972 100644
--- a/assets/src/ba_data/python/ba/__init__.py
+++ b/assets/src/ba_data/python/ba/__init__.py
@@ -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
diff --git a/assets/src/ba_data/python/ba/_achievement.py b/assets/src/ba_data/python/ba/_achievement.py
index 34b74212..0c87eda6 100644
--- a/assets/src/ba_data/python/ba/_achievement.py
+++ b/assets/src/ba_data/python/ba/_achievement.py
@@ -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."""
diff --git a/assets/src/ba_data/python/ba/_activitytypes.py b/assets/src/ba_data/python/ba/_activitytypes.py
index b6530e24..9e53b6a6 100644
--- a/assets/src/ba_data/python/ba/_activitytypes.py
+++ b/assets/src/ba_data/python/ba/_activitytypes.py
@@ -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
diff --git a/assets/src/ba_data/python/ba/_app.py b/assets/src/ba_data/python/ba/_app.py
index b950c4ef..2d0fd15b 100644
--- a/assets/src/ba_data/python/ba/_app.py
+++ b/assets/src/ba_data/python/ba/_app.py
@@ -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'
diff --git a/assets/src/ba_data/python/ba/_gameactivity.py b/assets/src/ba_data/python/ba/_gameactivity.py
index 4ee3342c..87fed65b 100644
--- a/assets/src/ba_data/python/ba/_gameactivity.py
+++ b/assets/src/ba_data/python/ba/_gameactivity.py
@@ -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),
diff --git a/assets/src/ba_data/python/ba/_lang.py b/assets/src/ba_data/python/ba/_lang.py
index b9c54f17..49a1165c 100644
--- a/assets/src/ba_data/python/ba/_lang.py
+++ b/assets/src/ba_data/python/ba/_lang.py
@@ -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.
diff --git a/assets/src/ba_data/python/ba/_server.py b/assets/src/ba_data/python/ba/_server.py
index 886cdc01..99ddd9bb 100644
--- a/assets/src/ba_data/python/ba/_server.py
+++ b/assets/src/ba_data/python/ba/_server.py
@@ -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)
diff --git a/assets/src/ba_data/python/bacommon/serverutils.py b/assets/src/ba_data/python/bacommon/serverutils.py
new file mode 100644
index 00000000..65529359
--- /dev/null
+++ b/assets/src/ba_data/python/bacommon/serverutils.py
@@ -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
diff --git a/assets/src/ba_data/python/bastd/activity/coopscore.py b/assets/src/ba_data/python/bastd/activity/coopscore.py
index b2cfc2b0..5da5828e 100644
--- a/assets/src/ba_data/python/bastd/activity/coopscore.py
+++ b/assets/src/ba_data/python/bastd/activity/coopscore.py
@@ -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,
diff --git a/assets/src/ba_data/python/bastd/activity/multiteamvictory.py b/assets/src/ba_data/python/bastd/activity/multiteamvictory.py
index 74874dee..8f934b63 100644
--- a/assets/src/ba_data/python/bastd/activity/multiteamvictory.py
+++ b/assets/src/ba_data/python/bastd/activity/multiteamvictory.py
@@ -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,
diff --git a/assets/src/ba_data/python/bastd/actor/multiteamvictory.py b/assets/src/ba_data/python/bastd/actor/multiteamvictory.py
deleted file mode 100644
index 74874dee..00000000
--- a/assets/src/ba_data/python/bastd/actor/multiteamvictory.py
+++ /dev/null
@@ -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()
diff --git a/assets/src/ba_data/python/bastd/ui/profile/browser.py b/assets/src/ba_data/python/bastd/ui/profile/browser.py
index f59d4ff1..586d8ebd 100644
--- a/assets/src/ba_data/python/bastd/ui/profile/browser.py
+++ b/assets/src/ba_data/python/bastd/ui/profile/browser.py
@@ -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),
diff --git a/assets/src/server/server.py b/assets/src/server/server.py
index a4252268..48c744bb 100755
--- a/assets/src/server/server.py
+++ b/assets/src/server/server.py
@@ -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
diff --git a/docs/ba_module.md b/docs/ba_module.md
index a9b5f382..1862d293 100644
--- a/docs/ba_module.md
+++ b/docs/ba_module.md
@@ -1,5 +1,5 @@
-
last updated on 2020-04-22 for Ballistica version 1.5.0 build 20001
+last updated on 2020-04-24 for Ballistica version 1.5.0 build 20001
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 let me know. Happy modding!
@@ -137,6 +137,7 @@
ba.AppDelegate
ba.Campaign
ba.MusicPlayer
+ ba.Server
@@ -2736,7 +2737,7 @@ needs a chooser.
<top level class>
-Used to specify strings in a language-independent way.
+Used to define strings in a language-independent way.
Category: General Utility Classes
@@ -3971,6 +3972,40 @@ cause the powerup box to make a sound and disappear or whatnot.
-
ba.PowerupMessage(poweruptype: str, source_node: Optional[ba.Node] = None)
+
+
+
+<top level class>
+
+Overall controller for the app in server mode.
+
+Category: App Classes
+
+
+Methods:
+
+
+-
+
ba.Server(config: ServerConfig)
+
+
+-
+
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.
+
+
+-
+
launch_server_session(self) -> None
+
+Kick off a host-session based on the current server config.
+