mirror of
https://github.com/RYDE-WORK/ballistica.git
synced 2026-01-25 00:13:27 +08:00
Work in progress on server mode cleanup
This commit is contained in:
parent
91f5005d21
commit
ac75dce421
7
.idea/dictionaries/ericf.xml
generated
7
.idea/dictionaries/ericf.xml
generated
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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: $^
|
||||
|
||||
@ -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)
|
||||
"""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
160
assets/src/ba_data/python/bacommon/serverutils.py
Normal file
160
assets/src/ba_data/python/bacommon/serverutils.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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><top level class></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__"><constructor></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><top level class></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__"><constructor></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__"><constructor></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) -> 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) -> None</span></p>
|
||||
|
||||
<p>Kick off a host-session based on the current server config.</p>
|
||||
|
||||
</dd>
|
||||
</dl>
|
||||
<hr>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user