diff --git a/.idea/dictionaries/roman.xml b/.idea/dictionaries/roman.xml
index 240cd39e..d594223f 100644
--- a/.idea/dictionaries/roman.xml
+++ b/.idea/dictionaries/roman.xml
@@ -1,6 +1,7 @@
+ gamename
maxlen
pagename
diff --git a/assets/src/ba_data/python/ba/_activity.py b/assets/src/ba_data/python/ba/_activity.py
index 0060ab64..b19a2e40 100644
--- a/assets/src/ba_data/python/ba/_activity.py
+++ b/assets/src/ba_data/python/ba/_activity.py
@@ -112,6 +112,10 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
# transitions).
inherits_tint = False
+ # Whether players should be allowed to join in the middle of
+ # activity.
+ allow_mid_activity_joins: bool = True
+
# If the activity fades or transitions in, it should set the length of
# time here so that previous activities will be kept alive for that
# long (avoiding 'holes' in the screen)
diff --git a/assets/src/ba_data/python/ba/_coopsession.py b/assets/src/ba_data/python/ba/_coopsession.py
index 6273f867..bb85a4b0 100644
--- a/assets/src/ba_data/python/ba/_coopsession.py
+++ b/assets/src/ba_data/python/ba/_coopsession.py
@@ -90,6 +90,16 @@ class CoopSession(Session):
"""Get the game instance currently being played."""
return self._current_game_instance
+ def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
+ # pylint: disable=cyclic-import
+ from ba._gameactivity import GameActivity
+
+ # Disallow any joins in the middle of the game.
+ if isinstance(activity, GameActivity):
+ return False
+
+ return True
+
def _update_on_deck_game_instances(self) -> None:
# pylint: disable=cyclic-import
from ba._gameactivity import GameActivity
@@ -156,8 +166,24 @@ class CoopSession(Session):
from ba._general import WeakCall
super().on_player_leave(sessionplayer)
- # If all our players leave we wanna quit out of the session.
- _ba.timer(2.0, WeakCall(self._end_session_if_empty))
+ _ba.timer(2.0, WeakCall(self._check_end_game))
+
+ def _check_end_game(self) -> None:
+ if not _ba.app.server:
+ self._end_session_if_empty()
+
+ activity = self.getactivity()
+ if activity is None:
+ return # Probably everything is already broken, why do something?
+
+ if [player for player in activity.players if player.is_alive()]:
+ return
+
+ with _ba.Context(activity):
+ from ba._gameactivity import GameActivity
+
+ if isinstance(activity, GameActivity):
+ activity.end_game()
def _end_session_if_empty(self) -> None:
activity = self.getactivity()
@@ -250,10 +276,11 @@ class CoopSession(Session):
# If at any point we have no in-game players, quit out of the session
# (this can happen if someone leaves in the tutorial for instance).
- active_players = [p for p in self.sessionplayers if p.in_game]
- if not active_players:
- self.end()
- return
+ if isinstance(activity, TutorialActivity):
+ active_players = [p for p in self.sessionplayers if p.in_game]
+ if not active_players:
+ self.end()
+ return
# If we're in a between-round activity or a restart-activity,
# hop into a round.
diff --git a/assets/src/ba_data/python/ba/_servermode.py b/assets/src/ba_data/python/ba/_servermode.py
index ac3bab8d..0f03d4c5 100644
--- a/assets/src/ba_data/python/ba/_servermode.py
+++ b/assets/src/ba_data/python/ba/_servermode.py
@@ -16,6 +16,7 @@ import _ba
from ba._generated.enums import TimeType
from ba._freeforallsession import FreeForAllSession
from ba._dualteamsession import DualTeamSession
+from ba._coopsession import CoopSession
if TYPE_CHECKING:
from typing import Optional, Dict, Any, Type
@@ -97,6 +98,8 @@ class ServerController:
self._playlist_fetch_got_response = False
self._playlist_fetch_code = -1
+ self._coop_game_name = self._config.coop_game_name
+
# Now sit around doing any pre-launch prep such as waiting for
# account sign-in or fetching playlists; this will kick off the
# session once done.
@@ -289,11 +292,14 @@ class ServerController:
return FreeForAllSession
if self._config.session_type == 'teams':
return DualTeamSession
+ if self._config.session_type == 'coop':
+ return CoopSession
raise RuntimeError(
f'Invalid session_type: "{self._config.session_type}"')
def _launch_server_session(self) -> None:
"""Kick off a host-session based on the current server config."""
+ # pylint: disable=too-many-branches
app = _ba.app
appcfg = app.config
sessiontype = self._get_session_type()
@@ -311,6 +317,8 @@ class ServerController:
ptypename = 'Free-for-All'
elif sessiontype is DualTeamSession:
ptypename = 'Team Tournament'
+ elif sessiontype is CoopSession:
+ ptypename = 'Coop'
else:
raise RuntimeError(f'Unknown session type {sessiontype}')
@@ -340,6 +348,13 @@ class ServerController:
appcfg['Team Tournament Playlist Selection'] = self._playlist_name
appcfg['Team Tournament Playlist Randomize'] = (
self._config.playlist_shuffle)
+ elif sessiontype is CoopSession:
+ gamename = self._coop_game_name or 'Default:Onslaught Training'
+ campaignname, levelname = gamename.split(':')
+ app.coop_session_args = {
+ 'campaign': campaignname,
+ 'level': levelname,
+ }
else:
raise RuntimeError(f'Unknown session type {sessiontype}')
diff --git a/assets/src/ba_data/python/ba/_session.py b/assets/src/ba_data/python/ba/_session.py
index 7bb94288..618e9d00 100644
--- a/assets/src/ba_data/python/ba/_session.py
+++ b/assets/src/ba_data/python/ba/_session.py
@@ -63,10 +63,6 @@ class Session:
team instead of their own profile colors. This only applies if
use_teams is enabled.
- allow_mid_activity_joins
- Whether players should be allowed to join in the middle of
- activities.
-
customdata
A shared dictionary for objects to use as storage on this session.
Ensure that keys here are unique to avoid collisions.
@@ -74,7 +70,6 @@ class Session:
"""
use_teams: bool = False
use_team_colors: bool = True
- allow_mid_activity_joins: bool = True
# Note: even though these are instance vars, we annotate them at the
# class level so that docs generation can access their types.
@@ -210,6 +205,15 @@ class Session:
raise NodeNotFoundError()
return node
+ def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
+ """Returned value is used by the Session to determine
+ whether to allow players to join in the middle of activity.
+
+ Activity.allow_mid_activity_joins is also required to allow these
+ joins."""
+ del activity # Unused.
+ return True
+
def on_player_request(self, player: ba.SessionPlayer) -> bool:
"""Called when a new ba.Player wants to join the Session.
@@ -220,7 +224,6 @@ class Session:
if _ba.app.stress_test_reset_timer is None:
if len(self.sessionplayers) >= self.max_players:
-
# Print a rejection message *only* to the client trying to
# join (prevents spamming everyone else in the game).
_ba.playsound(_ba.getsound('error'))
@@ -657,7 +660,8 @@ class Session:
# However, if we're not allowing mid-game joins, don't actually pass;
# just announce the arrival and say they'll partake next round.
if pass_to_activity:
- if not self.allow_mid_activity_joins:
+ if not (activity.allow_mid_activity_joins
+ and self.should_allow_mid_activity_joins(activity)):
pass_to_activity = False
with _ba.Context(self):
_ba.screenmessage(
diff --git a/assets/src/ba_data/python/bastd/activity/coopscore.py b/assets/src/ba_data/python/bastd/activity/coopscore.py
index b8678cd0..dc201f55 100644
--- a/assets/src/ba_data/python/bastd/activity/coopscore.py
+++ b/assets/src/ba_data/python/bastd/activity/coopscore.py
@@ -118,6 +118,12 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
self._tournament_time_remaining_text: Optional[Text] = None
self._tournament_time_remaining_text_timer: Optional[ba.Timer] = None
+ # Stuff for activity skip by pressing button
+ self._birth_time = ba.time()
+ self._min_view_time = 5.0
+ self._allow_server_transition = False
+ self._server_transitioning: Optional[bool] = None
+
self._playerinfos: List[ba.PlayerInfo] = settings['playerinfos']
assert isinstance(self._playerinfos, list)
assert (isinstance(i, ba.PlayerInfo) for i in self._playerinfos)
@@ -485,6 +491,46 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
if self._store_button_instance is not None:
self._store_button_instance.set_position((pos_x + 100, pos_y))
+ def _player_press(self) -> None:
+ # (Only for headless builds).
+
+ # 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 restart current level.
+ self._campaign.set_selected_level(self._level_name)
+ with ba.Context(self):
+ self.end({'outcome': 'restart'})
+
+ def _safe_assign(self, player: ba.Player) -> None:
+ # (Only for headless builds).
+
+ # Just to be extra careful, don't assign if we're transitioning out.
+ # (though theoretically that should be ok).
+ if not self.is_transitioning_out() and player:
+ player.assigninput(
+ (ba.InputType.JUMP_PRESS, ba.InputType.PUNCH_PRESS,
+ ba.InputType.BOMB_PRESS, ba.InputType.PICK_UP_PRESS),
+ self._player_press)
+
+ def on_player_join(self, player: ba.Player) -> None:
+ super().on_player_join(player)
+
+ if ba.app.server is not None:
+ # Host can't press retry button, so anyone can do it instead.
+ time_till_assign = max(
+ 0, self._birth_time + self._min_view_time - _ba.time())
+
+ ba.timer(time_till_assign, ba.WeakCall(self._safe_assign, player))
+
def on_begin(self) -> None:
# FIXME: Clean this up.
# pylint: disable=too-many-statements
@@ -582,19 +628,35 @@ class CoopScoreScreen(ba.Activity[ba.Player, ba.Team]):
color=(0.5, 0.7, 0.5, 1),
position=(0, 230)).autoretain()
- adisp = _ba.get_account_display_string()
- txt = Text(ba.Lstr(resource='waitingForHostText',
- subs=[('${HOST}', adisp)]),
- maxwidth=300,
- transition=Text.Transition.FADE_IN,
- transition_delay=8.0,
- scale=0.85,
- h_align=Text.HAlign.CENTER,
- v_align=Text.VAlign.CENTER,
- color=(1, 1, 0, 1),
- position=(0, -230)).autoretain()
- assert txt.node
- txt.node.client_only = True
+ if ba.app.server is None:
+ # If we're running in normal non-headless build, show this text
+ # because only host can continue the game.
+ adisp = _ba.get_account_display_string()
+ txt = Text(ba.Lstr(resource='waitingForHostText',
+ subs=[('${HOST}', adisp)]),
+ maxwidth=300,
+ transition=Text.Transition.FADE_IN,
+ transition_delay=8.0,
+ scale=0.85,
+ h_align=Text.HAlign.CENTER,
+ v_align=Text.VAlign.CENTER,
+ color=(1, 1, 0, 1),
+ position=(0, -230)).autoretain()
+ assert txt.node
+ txt.node.client_only = True
+ else:
+ # In headless build, anyone can continue the game.
+ sval = ba.Lstr(resource='pressAnyButtonPlayAgainText')
+ Text(sval,
+ v_attach=Text.VAttach.BOTTOM,
+ h_align=Text.HAlign.CENTER,
+ flash=True,
+ vr_depth=50,
+ position=(0, 60),
+ scale=0.8,
+ color=(0.5, 0.7, 0.5, 0.5),
+ transition=Text.Transition.IN_BOTTOM_SLOW,
+ transition_delay=self._min_view_time).autoretain()
if self._score is not None:
ba.timer(0.35,
diff --git a/assets/src/ba_data/python/bastd/game/elimination.py b/assets/src/ba_data/python/bastd/game/elimination.py
index aac7d823..dd95a469 100644
--- a/assets/src/ba_data/python/bastd/game/elimination.py
+++ b/assets/src/ba_data/python/bastd/game/elimination.py
@@ -179,6 +179,8 @@ class EliminationGame(ba.TeamGameActivity[Player, Team]):
# Show messages when players die since it's meaningful here.
announce_player_deaths = True
+ allow_mid_activity_joins = False
+
@classmethod
def get_available_settings(
cls, sessiontype: Type[ba.Session]) -> List[ba.Setting]:
@@ -257,23 +259,6 @@ class EliminationGame(ba.TeamGameActivity[Player, Team]):
self.session, ba.DualTeamSession) else 'last one standing wins'
def on_player_join(self, player: Player) -> None:
-
- # No longer allowing mid-game joiners here; too easy to exploit.
- if self.has_begun():
-
- # Make sure their team has survival seconds set if they're all dead
- # (otherwise blocked new ffa players are considered 'still alive'
- # in score tallying).
- if (self._get_total_team_lives(player.team) == 0
- and player.team.survival_seconds is None):
- player.team.survival_seconds = 0
- ba.screenmessage(
- ba.Lstr(resource='playerDelayedJoinText',
- subs=[('${PLAYER}', player.getname(full=True))]),
- color=(0, 1, 0),
- )
- return
-
player.lives = self._lives_per_player
if self._solo_mode:
diff --git a/assets/src/ba_data/python/bastd/game/meteorshower.py b/assets/src/ba_data/python/bastd/game/meteorshower.py
index 9be79902..32c62c0d 100644
--- a/assets/src/ba_data/python/bastd/game/meteorshower.py
+++ b/assets/src/ba_data/python/bastd/game/meteorshower.py
@@ -44,7 +44,11 @@ class MeteorShowerGame(ba.TeamGameActivity[Player, Team]):
# Print messages when players die (since its meaningful in this game).
announce_player_deaths = True
- # we're currently hard-coded for one map..
+ # Don't allow joining after we start
+ # (would enable leave/rejoin tomfoolery).
+ allow_mid_activity_joins = False
+
+ # We're currently hard-coded for one map.
@classmethod
def get_supported_maps(cls, sessiontype: Type[ba.Session]) -> List[str]:
return ['Rampage']
@@ -93,22 +97,6 @@ class MeteorShowerGame(ba.TeamGameActivity[Player, Team]):
# Check for immediate end (if we've only got 1 player, etc).
ba.timer(5.0, self._check_end_game)
- def on_player_join(self, player: Player) -> None:
- # Don't allow joining after we start
- # (would enable leave/rejoin tomfoolery).
- if self.has_begun():
- ba.screenmessage(
- ba.Lstr(resource='playerDelayedJoinText',
- subs=[('${PLAYER}', player.getname(full=True))]),
- color=(0, 1, 0),
- )
- # For score purposes, mark them as having died right as the
- # game started.
- assert self._timer is not None
- player.death_time = self._timer.getstarttime()
- return
- self.spawn_player(player)
-
def on_player_leave(self, player: Player) -> None:
# Augment default behavior.
super().on_player_leave(player)
diff --git a/docs/ba_module.md b/docs/ba_module.md
index 1d7966f6..f1ef308a 100644
--- a/docs/ba_module.md
+++ b/docs/ba_module.md
@@ -1770,7 +1770,7 @@ and it should begin its actual game logic.
high score lists.
Attributes Inherited:
-
+
Attributes Defined Here:
@@ -1789,7 +1789,7 @@ there is no associated Campaign.
Methods Inherited:
Methods Defined or Overridden:
-
+
-
ba.CoopSession()
@@ -1833,6 +1833,16 @@ is pressed.
Restart the current game activity.
+
+-
+
should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool
+
+Returned value is used by the Session to determine
+whether to allow players to join in the middle of activity.
+
+Activity.allow_mid_activity_joins is also required to allow these
+joins.
+
@@ -2128,7 +2138,7 @@ its time with lingering corpses, sound effects, etc.
Attributes Inherited:
-
+
Attributes Defined Here:
-
@@ -2138,7 +2148,7 @@ its time with lingering corpses, sound effects, etc.
Methods Inherited:
-
+
Methods Defined or Overridden:
-
@@ -2309,7 +2319,7 @@ its time with lingering corpses, sound effects, etc.
Attributes Inherited:
-
+
Attributes Defined Here:
-
@@ -2319,7 +2329,7 @@ its time with lingering corpses, sound effects, etc.
Methods Inherited:
-
+
Methods Defined or Overridden:
@@ -3912,7 +3922,7 @@ Use ba.getmodel() to instantiate one.
Attributes Inherited:
-
+
Attributes Defined Here:
-
@@ -3922,7 +3932,7 @@ Use ba.getmodel() to instantiate one.
Methods Inherited:
-
+
Methods Defined or Overridden:
@@ -5080,14 +5090,8 @@ Pass 0 or a negative number for no ban time.
maintaining state between them (players, teams, score tallies, etc).
Attributes:
-
+
--
-
bool
-Whether players should be allowed to join in the middle of
-activities.
-
-
-
dict
A shared dictionary for objects to use as storage on this session.
@@ -5148,7 +5152,7 @@ player that joins.
Methods:
-
+
-
ba.Session(depsets: Sequence[ba.DependencySet], team_names: Sequence[str] = None, team_colors: Sequence[Sequence[float]] = None, min_players: int = 1, max_players: int = 8)
@@ -5255,6 +5259,16 @@ Activity's. Code must be run in the new activity's methods
(on_transition_in, etc) to get it. (so you can't do
session.setactivity(foo) and then ba.newnode() to add a node to foo)
+
+-
+
should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool
+
+Returned value is used by the Session to determine
+whether to allow players to join in the middle of activity.
+
+Activity.allow_mid_activity_joins is also required to allow these
+joins.
+
diff --git a/tools/bacommon/servermanager.py b/tools/bacommon/servermanager.py
index c4a22464..128d5090 100644
--- a/tools/bacommon/servermanager.py
+++ b/tools/bacommon/servermanager.py
@@ -51,10 +51,14 @@ class ServerConfig:
# exposed but I'll try to add that soon.
max_party_size: int = 6
- # Options here are 'ffa' (free-for-all) and 'teams'
+ # Options here are 'ffa' (free-for-all), 'teams' and 'coop' (cooperative)
# This value is ignored if you supply a playlist_code (see below).
session_type: str = 'ffa'
+ # There are unavailable co-op playlists now, so if you want to host a co-op
+ # game, pass level name here.
+ coop_game_name: Optional[str] = None
+
# 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