Merge pull request #107 from imayushsaini/api9

syncing changes from ballistica/master
This commit is contained in:
Ayush Saini 2025-04-06 15:32:03 +05:30 committed by GitHub
commit f45bd5e31e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
240 changed files with 16025 additions and 12980 deletions

View file

@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- public-server - public-server
- api8 - api9
jobs: jobs:
run_server_binary: run_server_binary:
@ -32,7 +32,7 @@ jobs:
if grep -E "Exception|RuntimeError" server-output.log; then if grep -E "Exception|RuntimeError" server-output.log; then
echo "Error message found. Check server-output.log for details." echo "Error message found. Check server-output.log for details."
exit 1 exit 1
elif ! grep -q "entering server-mode" server-output.log; then elif ! grep -q "Server started" server-output.log; then
echo "Success message not found in server's output." echo "Success message not found in server's output."
exit 1 exit 1
fi fi

View file

@ -11,16 +11,16 @@ Migrated from API 7 TO API 8 , this might be unstable and missing some features.
- Basic knowledge of Linux - Basic knowledge of Linux
- A VPS (e.g. [Amazon Web Services](https://aws.amazon.com/), [Microsoft Azure](https://portal.azure.com/)) - A VPS (e.g. [Amazon Web Services](https://aws.amazon.com/), [Microsoft Azure](https://portal.azure.com/))
- Any Linux distribution. - Any Linux distribution.
- It is recommended to use Ubuntu. - It is recommended to use Ubuntu (minimum Ubuntu 22).
- Python 3.10 - Python 3.12
- 1 GB free Memory (Recommended 2 GB) - 1 GB free Memory (Recommended 2 GB)
## Getting Started ## Getting Started
This assumes you are on Ubuntu or an Ubuntu based distribution. This assumes you are on Ubuntu or an Ubuntu based distribution.
Update and install `software-properties-common` Install `software-properties-common`
``` ```
sudo apt update; sudo apt install software-properties-common -y sudo apt install software-properties-common -y
``` ```
Add python Deadsnakes PPA Add python Deadsnakes PPA
``` ```
@ -30,6 +30,10 @@ Install Python 3.12
``` ```
sudo apt install python3-pip python3.12-dev python3.12-venv sudo apt install python3-pip python3.12-dev python3.12-venv
``` ```
Update installed and existing packages
```
sudo apt update && sudo apt upgrade
```
Create a tmux session. Create a tmux session.
``` ```
tmux new -s 43210 tmux new -s 43210
@ -44,6 +48,7 @@ Making the server files executable.
``` ```
chmod 777 bombsquad_server chmod 777 bombsquad_server
chmod 777 dist/bombsquad_headless chmod 777 dist/bombsquad_headless
chmod 777 dist/bombsquad_headless_aarch64
``` ```
Starting the server Starting the server
``` ```

View file

@ -18,10 +18,11 @@ party_name = "BombSquad Community Server"
# internet connection. # internet connection.
#authenticate_clients = true #authenticate_clients = true
# IDs of server admins. Server admins are not kickable through the default # IDs of server admins. Server admins are not kickable through the
# kick vote system and they are able to kick players without a vote. To get # default kick vote system and they are able to kick players without
# your account id, enter 'getaccountid' in settings->advanced->enter-code. # a vote. To get your account id, enter 'getaccountid' in
admins = ["pb-yOuRAccOuNtIdHErE", "pb-aNdMayBeAnotherHeRE"] # settings->advanced->enter-code.
#admins = ["pb-yOuRAccOuNtIdHErE", "pb-aNdMayBeAnotherHeRE"]
# Whether the default kick-voting system is enabled. # Whether the default kick-voting system is enabled.
#enable_default_kick_voting = true #enable_default_kick_voting = true
@ -66,12 +67,11 @@ admins = ["pb-yOuRAccOuNtIdHErE", "pb-aNdMayBeAnotherHeRE"]
# (see below). # (see below).
#session_type = "ffa" #session_type = "ffa"
# Playlist-code for teams or free-for-all mode sessions. # Playlist-code for teams or free-for-all mode sessions. To host
# To host your own custom playlists, use the 'share' functionality in the # your own custom playlists, use the 'share' functionality in the
# playlist editor in the regular version of the game. # playlist editor in the regular version of the game. This will give
# This will give you a numeric code you can enter here to host that # you a numeric code you can enter here to host that playlist.
# playlist. #playlist_code = 12345
playlist_code = 12345
# Alternately, you can embed playlist data here instead of using # Alternately, you can embed playlist data here instead of using
# codes. Make sure to set session_type to the correct type for the # codes. Make sure to set session_type to the correct type for the
@ -106,11 +106,11 @@ playlist_code = 12345
# Series length in teams mode (7 == 'best-of-7' series; a team must # Series length in teams mode (7 == 'best-of-7' series; a team must
# get 4 wins) # get 4 wins)
teams_series_length = 7 #teams_series_length = 7
# Points to win in free-for-all mode (Points are awarded per game based on # Points to win in free-for-all mode (Points are awarded per game
# performance) # based on performance)
ffa_series_length = 24 #ffa_series_length = 24
# If you have a custom stats webpage for your server, you can use # If you have a custom stats webpage for your server, you can use
# this to provide a convenient in-game link to it in the # this to provide a convenient in-game link to it in the
@ -166,3 +166,10 @@ team_colors = [[0.8, 0.0, 0.6], [0, 1, 0.8]]
# before rejoining the game. This can help suppress exploits # before rejoining the game. This can help suppress exploits
# involving leaving and rejoining or switching teams rapidly. # involving leaving and rejoining or switching teams rapidly.
#player_rejoin_cooldown = 10.0 #player_rejoin_cooldown = 10.0
# Log levels for particular loggers, overriding the engine's
# defaults. Valid values are NOTSET, DEBUG, INFO, WARNING, ERROR, or
# CRITICAL.
#[log_levels]
#"ba.lifecycle" = "INFO"
#"ba.assets" = "INFO"

View file

@ -9,6 +9,8 @@ a more focused way.
""" """
# pylint: disable=redefined-builtin # pylint: disable=redefined-builtin
# ba_meta require api 9
# The stuff we expose here at the top level is our 'public' api for use # The stuff we expose here at the top level is our 'public' api for use
# from other modules/packages. Code *within* this package should import # from other modules/packages. Code *within* this package should import
# things from this package's submodules directly to reduce the chance of # things from this package's submodules directly to reduce the chance of
@ -17,11 +19,12 @@ a more focused way.
from efro.util import set_canonical_module_names from efro.util import set_canonical_module_names
import _babase import _babase
from _babase import ( from _babase import (
add_clean_frame_callback, add_clean_frame_callback,
allows_ticket_sales,
android_get_external_files_dir, android_get_external_files_dir,
app_instance_uuid,
appname, appname,
appnameupper, appnameupper,
apptime, apptime,
@ -55,12 +58,16 @@ from _babase import (
get_replays_dir, get_replays_dir,
get_string_height, get_string_height,
get_string_width, get_string_width,
get_ui_scale,
get_v1_cloud_log_file_path, get_v1_cloud_log_file_path,
get_virtual_safe_area_size,
get_virtual_screen_size,
getsimplesound, getsimplesound,
has_user_run_commands, has_user_run_commands,
have_chars, have_chars,
have_permission, have_permission,
in_logic_thread, in_logic_thread,
in_main_menu,
increment_analytics_count, increment_analytics_count,
invoke_main_menu, invoke_main_menu,
is_os_playing_music, is_os_playing_music,
@ -86,6 +93,7 @@ from _babase import (
overlay_web_browser_is_supported, overlay_web_browser_is_supported,
overlay_web_browser_open_url, overlay_web_browser_open_url,
print_load_info, print_load_info,
push_back_press,
pushcall, pushcall,
quit, quit,
reload_media, reload_media,
@ -95,7 +103,9 @@ from _babase import (
set_analytics_screen, set_analytics_screen,
set_low_level_config_value, set_low_level_config_value,
set_thread_name, set_thread_name,
set_ui_account_state,
set_ui_input_device, set_ui_input_device,
set_ui_scale,
show_progress_bar, show_progress_bar,
shutdown_suppress_begin, shutdown_suppress_begin,
shutdown_suppress_end, shutdown_suppress_end,
@ -103,8 +113,11 @@ from _babase import (
SimpleSound, SimpleSound,
supports_max_fps, supports_max_fps,
supports_vsync, supports_vsync,
supports_unicode_display,
unlock_all_input, unlock_all_input,
update_internal_logger_levels,
user_agent_string, user_agent_string,
user_ran_commands,
Vec3, Vec3,
workspaces_in_use, workspaces_in_use,
) )
@ -123,7 +136,9 @@ from babase._apputils import (
garbage_collect, garbage_collect,
get_remote_app_name, get_remote_app_name,
AppHealthMonitor, AppHealthMonitor,
utc_now_cloud,
) )
from babase._cloud import CloudSubscription
from babase._devconsole import ( from babase._devconsole import (
DevConsoleTab, DevConsoleTab,
DevConsoleTabEntry, DevConsoleTabEntry,
@ -162,10 +177,9 @@ from babase._general import (
get_type_name, get_type_name,
) )
from babase._language import Lstr, LanguageSubsystem from babase._language import Lstr, LanguageSubsystem
from babase._logging import balog, applog, lifecyclelog
from babase._login import LoginAdapter, LoginInfo from babase._login import LoginAdapter, LoginInfo
# noinspection PyProtectedMember
# (PyCharm inspection bug?)
from babase._mgen.enums import ( from babase._mgen.enums import (
Permission, Permission,
SpecialChar, SpecialChar,
@ -180,6 +194,7 @@ from babase._plugin import PluginSpec, Plugin, PluginSubsystem
from babase._stringedit import StringEditAdapter, StringEditSubsystem from babase._stringedit import StringEditAdapter, StringEditSubsystem
from babase._text import timestring from babase._text import timestring
_babase.app = app = App() _babase.app = app = App()
app.postinit() app.postinit()
@ -188,6 +203,7 @@ __all__ = [
'AccountV2Subsystem', 'AccountV2Subsystem',
'ActivityNotFoundError', 'ActivityNotFoundError',
'ActorNotFoundError', 'ActorNotFoundError',
'allows_ticket_sales',
'add_clean_frame_callback', 'add_clean_frame_callback',
'android_get_external_files_dir', 'android_get_external_files_dir',
'app', 'app',
@ -199,6 +215,8 @@ __all__ = [
'AppIntentDefault', 'AppIntentDefault',
'AppIntentExec', 'AppIntentExec',
'AppMode', 'AppMode',
'app_instance_uuid',
'applog',
'appname', 'appname',
'appnameupper', 'appnameupper',
'AppModeSelector', 'AppModeSelector',
@ -209,6 +227,7 @@ __all__ = [
'apptimer', 'apptimer',
'AppTimer', 'AppTimer',
'asset_loads_allowed', 'asset_loads_allowed',
'balog',
'Call', 'Call',
'fullscreen_control_available', 'fullscreen_control_available',
'fullscreen_control_get', 'fullscreen_control_get',
@ -218,6 +237,7 @@ __all__ = [
'clipboard_get_text', 'clipboard_get_text',
'clipboard_has_text', 'clipboard_has_text',
'clipboard_is_supported', 'clipboard_is_supported',
'CloudSubscription',
'clipboard_set_text', 'clipboard_set_text',
'commit_app_config', 'commit_app_config',
'ContextCall', 'ContextCall',
@ -250,8 +270,11 @@ __all__ = [
'get_replays_dir', 'get_replays_dir',
'get_string_height', 'get_string_height',
'get_string_width', 'get_string_width',
'get_v1_cloud_log_file_path',
'get_type_name', 'get_type_name',
'get_ui_scale',
'get_virtual_safe_area_size',
'get_virtual_screen_size',
'get_v1_cloud_log_file_path',
'getclass', 'getclass',
'getsimplesound', 'getsimplesound',
'handle_leftover_v1_cloud_log_file', 'handle_leftover_v1_cloud_log_file',
@ -259,6 +282,7 @@ __all__ = [
'have_chars', 'have_chars',
'have_permission', 'have_permission',
'in_logic_thread', 'in_logic_thread',
'in_main_menu',
'increment_analytics_count', 'increment_analytics_count',
'InputDeviceNotFoundError', 'InputDeviceNotFoundError',
'InputType', 'InputType',
@ -269,6 +293,7 @@ __all__ = [
'is_point_in_box', 'is_point_in_box',
'is_xcode_build', 'is_xcode_build',
'LanguageSubsystem', 'LanguageSubsystem',
'lifecyclelog',
'lock_all_input', 'lock_all_input',
'LoginAdapter', 'LoginAdapter',
'LoginInfo', 'LoginInfo',
@ -305,6 +330,7 @@ __all__ = [
'print_error', 'print_error',
'print_exception', 'print_exception',
'print_load_info', 'print_load_info',
'push_back_press',
'pushcall', 'pushcall',
'quit', 'quit',
'QuitType', 'QuitType',
@ -318,7 +344,9 @@ __all__ = [
'set_analytics_screen', 'set_analytics_screen',
'set_low_level_config_value', 'set_low_level_config_value',
'set_thread_name', 'set_thread_name',
'set_ui_account_state',
'set_ui_input_device', 'set_ui_input_device',
'set_ui_scale',
'show_progress_bar', 'show_progress_bar',
'shutdown_suppress_begin', 'shutdown_suppress_begin',
'shutdown_suppress_end', 'shutdown_suppress_end',
@ -330,11 +358,15 @@ __all__ = [
'StringEditSubsystem', 'StringEditSubsystem',
'supports_max_fps', 'supports_max_fps',
'supports_vsync', 'supports_vsync',
'supports_unicode_display',
'TeamNotFoundError', 'TeamNotFoundError',
'timestring', 'timestring',
'UIScale', 'UIScale',
'unlock_all_input', 'unlock_all_input',
'update_internal_logger_levels',
'user_agent_string', 'user_agent_string',
'user_ran_commands',
'utc_now_cloud',
'utf8_all', 'utf8_all',
'Vec3', 'Vec3',
'vec3validate', 'vec3validate',

View file

@ -10,16 +10,16 @@ from functools import partial
from typing import TYPE_CHECKING, assert_never from typing import TYPE_CHECKING, assert_never
from efro.error import CommunicationError from efro.error import CommunicationError
from efro.call import CallbackSet
from bacommon.login import LoginType from bacommon.login import LoginType
import _babase import _babase
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any, Callable
from babase._login import LoginAdapter, LoginInfo from babase._login import LoginAdapter, LoginInfo
logger = logging.getLogger('ba.accountv2')
DEBUG_LOG = False
class AccountV2Subsystem: class AccountV2Subsystem:
@ -31,10 +31,22 @@ class AccountV2Subsystem:
""" """
def __init__(self) -> None: def __init__(self) -> None:
assert _babase.in_logic_thread()
from babase._login import LoginAdapterGPGS, LoginAdapterGameCenter from babase._login import LoginAdapterGPGS, LoginAdapterGameCenter
# Whether or not everything related to an initial login # Register to be informed when connectivity changes.
# (or lack thereof) has completed. This includes things like plus = _babase.app.plus
self._connectivity_changed_cb = (
None
if plus is None
else plus.cloud.on_connectivity_changed_callbacks.register(
self._on_cloud_connectivity_changed
)
)
# Whether or not everything related to an initial sign in (or
# lack thereof) has completed. This includes things like
# workspace syncing. Completion of this is what flips the app # workspace syncing. Completion of this is what flips the app
# into 'running' state. # into 'running' state.
self._initial_sign_in_completed = False self._initial_sign_in_completed = False
@ -46,6 +58,9 @@ class AccountV2Subsystem:
self._implicit_signed_in_adapter: LoginAdapter | None = None self._implicit_signed_in_adapter: LoginAdapter | None = None
self._implicit_state_changed = False self._implicit_state_changed = False
self._can_do_auto_sign_in = True self._can_do_auto_sign_in = True
self.on_primary_account_changed_callbacks: CallbackSet[
Callable[[AccountV2Handle | None], None]
] = CallbackSet()
adapter: LoginAdapter adapter: LoginAdapter
if _babase.using_google_play_game_services(): if _babase.using_google_play_game_services():
@ -64,11 +79,11 @@ class AccountV2Subsystem:
def have_primary_credentials(self) -> bool: def have_primary_credentials(self) -> bool:
"""Are credentials currently set for the primary app account? """Are credentials currently set for the primary app account?
Note that this does not mean these credentials are currently valid; Note that this does not mean these credentials have been checked
only that they exist. If/when credentials are validated, the 'primary' for validity; only that they exist. If/when credentials are
account handle will be set. validated, the 'primary' account handle will be set.
""" """
raise NotImplementedError('This should be overridden.') raise NotImplementedError()
@property @property
def primary(self) -> AccountV2Handle | None: def primary(self) -> AccountV2Handle | None:
@ -85,6 +100,13 @@ class AccountV2Subsystem:
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
# Fire any registered callbacks.
for call in self.on_primary_account_changed_callbacks.getcalls():
try:
call(account)
except Exception:
logging.exception('Error in primary-account-changed callback.')
# Currently don't do anything special on sign-outs. # Currently don't do anything special on sign-outs.
if account is None: if account is None:
return return
@ -105,9 +127,9 @@ class AccountV2Subsystem:
on_completed=self._on_set_active_workspace_completed, on_completed=self._on_set_active_workspace_completed,
) )
else: else:
# Don't activate workspaces if we've already told the game # Don't activate workspaces if we've already told the
# that initial-log-in is done or if we've already kicked # game that initial-log-in is done or if we've already
# off a workspace load. # kicked off a workspace load.
_babase.screenmessage( _babase.screenmessage(
f'\'{account.workspacename}\'' f'\'{account.workspacename}\''
f' will be activated at next app launch.', f' will be activated at next app launch.',
@ -242,19 +264,18 @@ class AccountV2Subsystem:
# generally this means the user has explicitly signed in/out or # generally this means the user has explicitly signed in/out or
# switched accounts within that back-end. # switched accounts within that back-end.
if prev_state != new_state: if prev_state != new_state:
if DEBUG_LOG: logger.debug(
logging.debug( 'Implicit state changed (%s -> %s);'
'AccountV2: Implicit state changed (%s -> %s);' ' will update app sign-in state accordingly.',
' will update app sign-in state accordingly.', prev_state,
prev_state, new_state,
new_state, )
)
self._implicit_state_changed = True self._implicit_state_changed = True
# We may want to auto-sign-in based on this new state. # We may want to auto-sign-in based on this new state.
self._update_auto_sign_in() self._update_auto_sign_in()
def on_cloud_connectivity_changed(self, connected: bool) -> None: def _on_cloud_connectivity_changed(self, connected: bool) -> None:
"""Should be called with cloud connectivity changes.""" """Should be called with cloud connectivity changes."""
del connected # Unused. del connected # Unused.
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
@ -264,11 +285,11 @@ class AccountV2Subsystem:
def do_get_primary(self) -> AccountV2Handle | None: def do_get_primary(self) -> AccountV2Handle | None:
"""Internal - should be overridden by subclass.""" """Internal - should be overridden by subclass."""
raise NotImplementedError('This should be overridden.') raise NotImplementedError()
def set_primary_credentials(self, credentials: str | None) -> None: def set_primary_credentials(self, credentials: str | None) -> None:
"""Set credentials for the primary app account.""" """Set credentials for the primary app account."""
raise NotImplementedError('This should be overridden.') raise NotImplementedError()
def _update_auto_sign_in(self) -> None: def _update_auto_sign_in(self) -> None:
plus = _babase.app.plus plus = _babase.app.plus
@ -279,11 +300,9 @@ class AccountV2Subsystem:
if self._implicit_signed_in_adapter is None: if self._implicit_signed_in_adapter is None:
# If implicit back-end has signed out, we follow suit # If implicit back-end has signed out, we follow suit
# immediately; no need to wait for network connectivity. # immediately; no need to wait for network connectivity.
if DEBUG_LOG: logger.debug(
logging.debug( 'Signing out as result of implicit state change...',
'AccountV2: Signing out as result' )
' of implicit state change...',
)
plus.accounts.set_primary_credentials(None) plus.accounts.set_primary_credentials(None)
self._implicit_state_changed = False self._implicit_state_changed = False
@ -300,11 +319,9 @@ class AccountV2Subsystem:
# switching accounts via the back-end). NOTE: should # switching accounts via the back-end). NOTE: should
# test case where we don't have connectivity here. # test case where we don't have connectivity here.
if plus.cloud.is_connected(): if plus.cloud.is_connected():
if DEBUG_LOG: logger.debug(
logging.debug( 'Signing in as result of implicit state change...',
'AccountV2: Signing in as result' )
' of implicit state change...',
)
self._implicit_signed_in_adapter.sign_in( self._implicit_signed_in_adapter.sign_in(
self._on_explicit_sign_in_completed, self._on_explicit_sign_in_completed,
description='implicit state change', description='implicit state change',
@ -335,10 +352,9 @@ class AccountV2Subsystem:
and not signed_in_v2 and not signed_in_v2
and self._implicit_signed_in_adapter is not None and self._implicit_signed_in_adapter is not None
): ):
if DEBUG_LOG: logger.debug(
logging.debug( 'Signing in due to on-launch-auto-sign-in...',
'AccountV2: Signing in due to on-launch-auto-sign-in...', )
)
self._can_do_auto_sign_in = False # Only ATTEMPT once self._can_do_auto_sign_in = False # Only ATTEMPT once
self._implicit_signed_in_adapter.sign_in( self._implicit_signed_in_adapter.sign_in(
self._on_implicit_sign_in_completed, description='auto-sign-in' self._on_implicit_sign_in_completed, description='auto-sign-in'

View file

@ -9,9 +9,10 @@ import logging
from enum import Enum from enum import Enum
from functools import partial from functools import partial
from typing import TYPE_CHECKING, TypeVar, override from typing import TYPE_CHECKING, TypeVar, override
from concurrent.futures import ThreadPoolExecutor
from threading import RLock from threading import RLock
from efro.threadpool import ThreadPoolExecutorPlus
import _babase import _babase
from babase._language import LanguageSubsystem from babase._language import LanguageSubsystem
from babase._plugin import PluginSubsystem from babase._plugin import PluginSubsystem
@ -23,6 +24,8 @@ from babase._appmodeselector import AppModeSelector
from babase._appintent import AppIntentDefault, AppIntentExec from babase._appintent import AppIntentDefault, AppIntentExec
from babase._stringedit import StringEditSubsystem from babase._stringedit import StringEditSubsystem
from babase._devconsole import DevConsoleSubsystem from babase._devconsole import DevConsoleSubsystem
from babase._appconfig import AppConfig
from babase._logging import lifecyclelog, applog
if TYPE_CHECKING: if TYPE_CHECKING:
import asyncio import asyncio
@ -36,9 +39,9 @@ if TYPE_CHECKING:
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__ # __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
# This section generated by batools.appmodule; do not edit. # This section generated by batools.appmodule; do not edit.
from baclassic import ClassicSubsystem from baclassic import ClassicAppSubsystem
from baplus import PlusSubsystem from baplus import PlusAppSubsystem
from bauiv1 import UIV1Subsystem from bauiv1 import UIV1AppSubsystem
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__ # __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__
@ -65,9 +68,10 @@ class App:
health_monitor: AppHealthMonitor health_monitor: AppHealthMonitor
# How long we allow shutdown tasks to run before killing them. # How long we allow shutdown tasks to run before killing them.
# Currently the entire app hard-exits if shutdown takes 10 seconds, # Currently the entire app hard-exits if shutdown takes 15 seconds,
# so we need to keep it under that. # so we need to keep it under that. Staying above 10 should allow
SHUTDOWN_TASK_TIMEOUT_SECONDS = 5 # 10 second network timeouts to happen though.
SHUTDOWN_TASK_TIMEOUT_SECONDS = 12
class State(Enum): class State(Enum):
"""High level state the app can be in.""" """High level state the app can be in."""
@ -137,11 +141,11 @@ class App:
# Ask our default app modes to handle it. # Ask our default app modes to handle it.
# (generated from 'default_app_modes' in projectconfig). # (generated from 'default_app_modes' in projectconfig).
import bascenev1 import baclassic
import babase import babase
for appmode in [ for appmode in [
bascenev1.SceneV1AppMode, baclassic.ClassicAppMode,
babase.EmptyAppMode, babase.EmptyAppMode,
]: ]:
if appmode.can_handle_intent(intent): if appmode.can_handle_intent(intent):
@ -164,6 +168,11 @@ class App:
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1': if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
return return
# Wrap our raw app config in our special wrapper and pass it to
# the native layer.
self.config = AppConfig(_babase.get_initial_app_config())
_babase.set_app_config(self.config)
self.env: babase.Env = _babase.Env() self.env: babase.Env = _babase.Env()
self.state = self.State.NOT_STARTED self.state = self.State.NOT_STARTED
@ -171,7 +180,7 @@ class App:
# processing. It should also be passed to any additional asyncio # processing. It should also be passed to any additional asyncio
# loops we create so that everything shares the same single set # loops we create so that everything shares the same single set
# of worker threads. # of worker threads.
self.threadpool = ThreadPoolExecutor( self.threadpool = ThreadPoolExecutorPlus(
thread_name_prefix='baworker', thread_name_prefix='baworker',
initializer=self._thread_pool_thread_init, initializer=self._thread_pool_thread_init,
) )
@ -205,11 +214,11 @@ class App:
self._asyncio_loop: asyncio.AbstractEventLoop | None = None self._asyncio_loop: asyncio.AbstractEventLoop | None = None
self._asyncio_tasks: set[asyncio.Task] = set() self._asyncio_tasks: set[asyncio.Task] = set()
self._asyncio_timer: babase.AppTimer | None = None self._asyncio_timer: babase.AppTimer | None = None
self._config: babase.AppConfig | None = None
self._pending_intent: AppIntent | None = None self._pending_intent: AppIntent | None = None
self._intent: AppIntent | None = None self._intent: AppIntent | None = None
self._mode: AppMode | None = None
self._mode_selector: babase.AppModeSelector | None = None self._mode_selector: babase.AppModeSelector | None = None
self._mode_instances: dict[type[AppMode], AppMode] = {}
self._mode: AppMode | None = None
self._shutdown_task: asyncio.Task[None] | None = None self._shutdown_task: asyncio.Task[None] | None = None
self._shutdown_tasks: list[Coroutine[None, None, None]] = [ self._shutdown_tasks: list[Coroutine[None, None, None]] = [
self._wait_for_shutdown_suppressions(), self._wait_for_shutdown_suppressions(),
@ -250,6 +259,12 @@ class App:
""" """
return _babase.app_is_active() return _babase.app_is_active()
@property
def mode(self) -> AppMode | None:
"""The app's current mode."""
assert _babase.in_logic_thread()
return self._mode
@property @property
def asyncio_loop(self) -> asyncio.AbstractEventLoop: def asyncio_loop(self) -> asyncio.AbstractEventLoop:
"""The logic thread's asyncio event loop. """The logic thread's asyncio event loop.
@ -289,7 +304,7 @@ class App:
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
# Hold a strong reference to the task until it is done. # We hold a strong reference to the task until it is done.
# Otherwise it is possible for it to be garbage collected and # Otherwise it is possible for it to be garbage collected and
# disappear midway if the caller does not hold on to the # disappear midway if the caller does not hold on to the
# returned task, which seems like a great way to introduce # returned task, which seems like a great way to introduce
@ -311,12 +326,6 @@ class App:
self._asyncio_tasks.remove(task) self._asyncio_tasks.remove(task)
@property
def config(self) -> babase.AppConfig:
"""The babase.AppConfig instance representing the app's config state."""
assert self._config is not None
return self._config
@property @property
def mode_selector(self) -> babase.AppModeSelector: def mode_selector(self) -> babase.AppModeSelector:
"""Controls which app-modes are used for handling given intents. """Controls which app-modes are used for handling given intents.
@ -387,19 +396,19 @@ class App:
# This section generated by batools.appmodule; do not edit. # This section generated by batools.appmodule; do not edit.
@property @property
def classic(self) -> ClassicSubsystem | None: def classic(self) -> ClassicAppSubsystem | None:
"""Our classic subsystem (if available).""" """Our classic subsystem (if available)."""
return self._get_subsystem_property( return self._get_subsystem_property(
'classic', self._create_classic_subsystem 'classic', self._create_classic_subsystem
) # type: ignore ) # type: ignore
@staticmethod @staticmethod
def _create_classic_subsystem() -> ClassicSubsystem | None: def _create_classic_subsystem() -> ClassicAppSubsystem | None:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
try: try:
from baclassic import ClassicSubsystem from baclassic import ClassicAppSubsystem
return ClassicSubsystem() return ClassicAppSubsystem()
except ImportError: except ImportError:
return None return None
except Exception: except Exception:
@ -407,19 +416,19 @@ class App:
return None return None
@property @property
def plus(self) -> PlusSubsystem | None: def plus(self) -> PlusAppSubsystem | None:
"""Our plus subsystem (if available).""" """Our plus subsystem (if available)."""
return self._get_subsystem_property( return self._get_subsystem_property(
'plus', self._create_plus_subsystem 'plus', self._create_plus_subsystem
) # type: ignore ) # type: ignore
@staticmethod @staticmethod
def _create_plus_subsystem() -> PlusSubsystem | None: def _create_plus_subsystem() -> PlusAppSubsystem | None:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
try: try:
from baplus import PlusSubsystem from baplus import PlusAppSubsystem
return PlusSubsystem() return PlusAppSubsystem()
except ImportError: except ImportError:
return None return None
except Exception: except Exception:
@ -427,19 +436,19 @@ class App:
return None return None
@property @property
def ui_v1(self) -> UIV1Subsystem: def ui_v1(self) -> UIV1AppSubsystem:
"""Our ui_v1 subsystem (always available).""" """Our ui_v1 subsystem (always available)."""
return self._get_subsystem_property( return self._get_subsystem_property(
'ui_v1', self._create_ui_v1_subsystem 'ui_v1', self._create_ui_v1_subsystem
) # type: ignore ) # type: ignore
@staticmethod @staticmethod
def _create_ui_v1_subsystem() -> UIV1Subsystem: def _create_ui_v1_subsystem() -> UIV1AppSubsystem:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bauiv1 import UIV1Subsystem from bauiv1 import UIV1AppSubsystem
return UIV1Subsystem() return UIV1AppSubsystem()
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__ # __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
@ -481,18 +490,6 @@ class App:
""" """
_babase.run_app() _babase.run_app()
def threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None:
"""Submit a call to the app threadpool where result is not needed.
Normally, doing work in a thread-pool involves creating a future
and waiting for its result, which is an important step because it
propagates any Exceptions raised by the submitted work. When the
result in not important, however, this call can be used. The app
will log any exceptions that occur.
"""
fut = self.threadpool.submit(call)
fut.add_done_callback(self._threadpool_no_wait_done)
def set_intent(self, intent: AppIntent) -> None: def set_intent(self, intent: AppIntent) -> None:
"""Set the intent for the app. """Set the intent for the app.
@ -510,7 +507,7 @@ class App:
# Do the actual work of calcing our app-mode/etc. in a bg thread # Do the actual work of calcing our app-mode/etc. in a bg thread
# since it may block for a moment to load modules/etc. # since it may block for a moment to load modules/etc.
self.threadpool_submit_no_wait(partial(self._set_intent, intent)) self.threadpool.submit_no_wait(self._set_intent, intent)
def push_apply_app_config(self) -> None: def push_apply_app_config(self) -> None:
"""Internal. Use app.config.apply() to apply app config changes.""" """Internal. Use app.config.apply() to apply app config changes."""
@ -566,12 +563,6 @@ class App:
if self._mode is not None: if self._mode is not None:
self._mode.on_app_active_changed() self._mode.on_app_active_changed()
def read_config(self) -> None:
"""(internal)"""
from babase._appconfig import read_app_config
self._config = read_app_config()
def handle_deep_link(self, url: str) -> None: def handle_deep_link(self, url: str) -> None:
"""Handle a deep link URL.""" """Handle a deep link URL."""
from babase._language import Lstr from babase._language import Lstr
@ -610,18 +601,71 @@ class App:
self._initial_sign_in_completed = True self._initial_sign_in_completed = True
self._update_state() self._update_state()
def set_ui_scale(self, scale: babase.UIScale) -> None:
"""Change ui-scale on the fly.
Currently this is mainly for debugging and will not be called as
part of normal app operation.
"""
assert _babase.in_logic_thread()
# Apply to the native layer.
_babase.set_ui_scale(scale.name.lower())
# Inform all subsystems that something screen-related has
# changed. We assume subsystems won't be added at this point so
# we can use the list directly.
assert self._subsystem_registration_ended
for subsystem in self._subsystems:
try:
subsystem.on_ui_scale_change()
except Exception:
logging.exception(
'Error in on_ui_scale_change() for subsystem %s.', subsystem
)
def on_screen_size_change(self) -> None:
"""Screen size has changed."""
# Inform all app subsystems in the same order they were inited.
# Operate on a copy of the list here because this can be called
# while subsystems are still being added.
for subsystem in self._subsystems.copy():
try:
subsystem.on_screen_size_change()
except Exception:
logging.exception(
'Error in on_screen_size_change() for subsystem %s.',
subsystem,
)
def _set_intent(self, intent: AppIntent) -> None: def _set_intent(self, intent: AppIntent) -> None:
from babase._appmode import AppMode
# This should be happening in a bg thread. # This should be happening in a bg thread.
assert not _babase.in_logic_thread() assert not _babase.in_logic_thread()
try: try:
# Ask the selector what app-mode to use for this intent. # Ask the selector what app-mode to use for this intent.
if self.mode_selector is None: if self.mode_selector is None:
raise RuntimeError('No AppModeSelector set.') raise RuntimeError('No AppModeSelector set.')
modetype = self.mode_selector.app_mode_for_intent(intent)
# NOTE: Since intents are somewhat high level things, should modetype: type[AppMode] | None
# we do some universal thing like a screenmessage saying
# 'The app cannot handle that request' on failure? # Special case - for testing we may force a specific
# app-mode to handle this intent instead of going through our
# usual selector.
forced_mode_type = getattr(intent, '_force_app_mode_handler', None)
if isinstance(forced_mode_type, type) and issubclass(
forced_mode_type, AppMode
):
modetype = forced_mode_type
else:
modetype = self.mode_selector.app_mode_for_intent(intent)
# NOTE: Since intents are somewhat high level things,
# perhaps we should do some universal thing like a
# screenmessage saying 'The app cannot handle the request'
# on failure.
if modetype is None: if modetype is None:
raise RuntimeError( raise RuntimeError(
@ -640,7 +684,9 @@ class App:
# Ok; seems legit. Now instantiate the mode if necessary and # Ok; seems legit. Now instantiate the mode if necessary and
# kick back to the logic thread to apply. # kick back to the logic thread to apply.
mode = modetype() mode = self._mode_instances.get(modetype)
if mode is None:
self._mode_instances[modetype] = mode = modetype()
_babase.pushcall( _babase.pushcall(
partial(self._apply_intent, intent, mode), partial(self._apply_intent, intent, mode),
from_other_thread=True, from_other_thread=True,
@ -661,7 +707,7 @@ class App:
return return
# If the app-mode for this intent is different than the active # If the app-mode for this intent is different than the active
# one, switch. # one, switch modes.
if type(mode) is not type(self._mode): if type(mode) is not type(self._mode):
if self._mode is None: if self._mode is None:
is_initial_mode = True is_initial_mode = True
@ -673,6 +719,18 @@ class App:
logging.exception( logging.exception(
'Error deactivating app-mode %s.', self._mode 'Error deactivating app-mode %s.', self._mode
) )
# Reset all subsystems. We assume subsystems won't be added
# at this point so we can use the list directly.
assert self._subsystem_registration_ended
for subsystem in self._subsystems:
try:
subsystem.reset()
except Exception:
logging.exception(
'Error in reset() for subsystem %s.', subsystem
)
self._mode = mode self._mode = mode
try: try:
mode.on_activate() mode.on_activate()
@ -750,14 +808,14 @@ class App:
self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete) self.meta.start_scan(scan_complete_cb=self._on_meta_scan_complete)
# Inform all app subsystems in the same order they were inited. # Inform all app subsystems in the same order they were inited.
# Operate on a copy here because subsystems can still be added # Operate on a copy of the list here because subsystems can
# at this point. # still be added at this point.
for subsystem in self._subsystems.copy(): for subsystem in self._subsystems.copy():
try: try:
subsystem.on_app_loading() subsystem.on_app_loading()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in on_app_loading for subsystem %s.', subsystem 'Error in on_app_loading() for subsystem %s.', subsystem
) )
# Normally plus tells us when initial sign-in is done. If plus # Normally plus tells us when initial sign-in is done. If plus
@ -806,7 +864,7 @@ class App:
subsystem.on_app_running() subsystem.on_app_running()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in on_app_running for subsystem %s.', subsystem 'Error in on_app_running() for subsystem %s.', subsystem
) )
# Cut off new subsystem additions at this point. # Cut off new subsystem additions at this point.
@ -825,7 +883,7 @@ class App:
def _apply_app_config(self) -> None: def _apply_app_config(self) -> None:
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
_babase.lifecyclelog('apply-app-config') lifecyclelog.info('apply-app-config')
# If multiple apply calls have been made, only actually apply # If multiple apply calls have been made, only actually apply
# once. # once.
@ -842,7 +900,8 @@ class App:
subsystem.do_apply_app_config() subsystem.do_apply_app_config()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in do_apply_app_config for subsystem %s.', subsystem 'Error in do_apply_app_config() for subsystem %s.',
subsystem,
) )
# Let the native layer do its thing. # Let the native layer do its thing.
@ -856,7 +915,7 @@ class App:
if self._native_shutdown_complete_called: if self._native_shutdown_complete_called:
if self.state is not self.State.SHUTDOWN_COMPLETE: if self.state is not self.State.SHUTDOWN_COMPLETE:
self.state = self.State.SHUTDOWN_COMPLETE self.state = self.State.SHUTDOWN_COMPLETE
_babase.lifecyclelog('app state shutdown complete') lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutdown_complete() self._on_shutdown_complete()
# Shutdown trumps all. Though we can't start shutting down until # Shutdown trumps all. Though we can't start shutting down until
@ -866,7 +925,8 @@ class App:
# Entering shutdown state: # Entering shutdown state:
if self.state is not self.State.SHUTTING_DOWN: if self.state is not self.State.SHUTTING_DOWN:
self.state = self.State.SHUTTING_DOWN self.state = self.State.SHUTTING_DOWN
_babase.lifecyclelog('app state shutting down') applog.info('Shutting down...')
lifecyclelog.info('app-state is now %s', self.state.name)
self._on_shutting_down() self._on_shutting_down()
elif self._native_suspended: elif self._native_suspended:
@ -883,15 +943,16 @@ class App:
if self._initial_sign_in_completed and self._meta_scan_completed: if self._initial_sign_in_completed and self._meta_scan_completed:
if self.state != self.State.RUNNING: if self.state != self.State.RUNNING:
self.state = self.State.RUNNING self.state = self.State.RUNNING
_babase.lifecyclelog('app state running') lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_running: if not self._called_on_running:
self._called_on_running = True self._called_on_running = True
self._on_running() self._on_running()
# Entering or returning to loading state: # Entering or returning to loading state:
elif self._init_completed: elif self._init_completed:
if self.state is not self.State.LOADING: if self.state is not self.State.LOADING:
self.state = self.State.LOADING self.state = self.State.LOADING
_babase.lifecyclelog('app state loading') lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_loading: if not self._called_on_loading:
self._called_on_loading = True self._called_on_loading = True
self._on_loading() self._on_loading()
@ -900,7 +961,7 @@ class App:
elif self._native_bootstrapping_completed: elif self._native_bootstrapping_completed:
if self.state is not self.State.INITING: if self.state is not self.State.INITING:
self.state = self.State.INITING self.state = self.State.INITING
_babase.lifecyclelog('app state initing') lifecyclelog.info('app-state is now %s', self.state.name)
if not self._called_on_initing: if not self._called_on_initing:
self._called_on_initing = True self._called_on_initing = True
self._on_initing() self._on_initing()
@ -909,7 +970,7 @@ class App:
elif self._native_start_called: elif self._native_start_called:
if self.state is not self.State.NATIVE_BOOTSTRAPPING: if self.state is not self.State.NATIVE_BOOTSTRAPPING:
self.state = self.State.NATIVE_BOOTSTRAPPING self.state = self.State.NATIVE_BOOTSTRAPPING
_babase.lifecyclelog('app state native bootstrapping') lifecyclelog.info('app-state is now %s', self.state.name)
else: else:
# Only logical possibility left is NOT_STARTED, in which # Only logical possibility left is NOT_STARTED, in which
# case we should not be getting called. # case we should not be getting called.
@ -965,7 +1026,7 @@ class App:
subsystem.on_app_suspend() subsystem.on_app_suspend()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in on_app_suspend for subsystem %s.', subsystem 'Error in on_app_suspend() for subsystem %s.', subsystem
) )
def _on_unsuspend(self) -> None: def _on_unsuspend(self) -> None:
@ -979,7 +1040,7 @@ class App:
subsystem.on_app_unsuspend() subsystem.on_app_unsuspend()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in on_app_unsuspend for subsystem %s.', subsystem 'Error in on_app_unsuspend() for subsystem %s.', subsystem
) )
def _on_shutting_down(self) -> None: def _on_shutting_down(self) -> None:
@ -993,7 +1054,7 @@ class App:
subsystem.on_app_shutdown() subsystem.on_app_shutdown()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in on_app_shutdown for subsystem %s.', subsystem 'Error in on_app_shutdown() for subsystem %s.', subsystem
) )
# Now kick off any async shutdown task(s). # Now kick off any async shutdown task(s).
@ -1011,7 +1072,7 @@ class App:
subsystem.on_app_shutdown_complete() subsystem.on_app_shutdown_complete()
except Exception: except Exception:
logging.exception( logging.exception(
'Error in on_app_shutdown_complete for subsystem %s.', 'Error in on_app_shutdown_complete() for subsystem %s.',
subsystem, subsystem,
) )
@ -1020,10 +1081,10 @@ class App:
# Spin and wait for anything blocking shutdown to complete. # Spin and wait for anything blocking shutdown to complete.
starttime = _babase.apptime() starttime = _babase.apptime()
_babase.lifecyclelog('shutdown-suppress wait begin') lifecyclelog.info('shutdown-suppress-wait begin')
while _babase.shutdown_suppress_count() > 0: while _babase.shutdown_suppress_count() > 0:
await asyncio.sleep(0.001) await asyncio.sleep(0.001)
_babase.lifecyclelog('shutdown-suppress wait end') lifecyclelog.info('shutdown-suppress-wait end')
duration = _babase.apptime() - starttime duration = _babase.apptime() - starttime
if duration > 1.0: if duration > 1.0:
logging.warning( logging.warning(
@ -1036,7 +1097,7 @@ class App:
import asyncio import asyncio
# Kick off a short fade and give it time to complete. # Kick off a short fade and give it time to complete.
_babase.lifecyclelog('fade-and-shutdown-graphics begin') lifecyclelog.info('fade-and-shutdown-graphics begin')
_babase.fade_screen(False, time=0.15) _babase.fade_screen(False, time=0.15)
await asyncio.sleep(0.15) await asyncio.sleep(0.15)
@ -1045,27 +1106,19 @@ class App:
_babase.graphics_shutdown_begin() _babase.graphics_shutdown_begin()
while not _babase.graphics_shutdown_is_complete(): while not _babase.graphics_shutdown_is_complete():
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
_babase.lifecyclelog('fade-and-shutdown-graphics end') lifecyclelog.info('fade-and-shutdown-graphics end')
async def _fade_and_shutdown_audio(self) -> None: async def _fade_and_shutdown_audio(self) -> None:
import asyncio import asyncio
# Tell the audio system to go down and give it a bit of # Tell the audio system to go down and give it a bit of
# time to do so gracefully. # time to do so gracefully.
_babase.lifecyclelog('fade-and-shutdown-audio begin') lifecyclelog.info('fade-and-shutdown-audio begin')
_babase.audio_shutdown_begin() _babase.audio_shutdown_begin()
await asyncio.sleep(0.15) await asyncio.sleep(0.15)
while not _babase.audio_shutdown_is_complete(): while not _babase.audio_shutdown_is_complete():
await asyncio.sleep(0.01) await asyncio.sleep(0.01)
_babase.lifecyclelog('fade-and-shutdown-audio end') lifecyclelog.info('fade-and-shutdown-audio end')
def _threadpool_no_wait_done(self, fut: Future) -> None:
try:
fut.result()
except Exception:
logging.exception(
'Error in work submitted via threadpool_submit_no_wait()'
)
def _thread_pool_thread_init(self) -> None: def _thread_pool_thread_init(self) -> None:
# Help keep things clear in profiling tools/etc. # Help keep things clear in profiling tools/etc.

View file

@ -3,7 +3,6 @@
"""Provides the AppConfig class.""" """Provides the AppConfig class."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import _babase import _babase
@ -101,43 +100,6 @@ class AppConfig(dict):
self.commit() self.commit()
def read_app_config() -> AppConfig:
"""Read the app config."""
import os
import json
# NOTE: it is assumed that this only gets called once and the config
# object will not change from here on out
config_file_path = _babase.app.env.config_file_path
config_contents = ''
try:
if os.path.exists(config_file_path):
with open(config_file_path, encoding='utf-8') as infile:
config_contents = infile.read()
config = AppConfig(json.loads(config_contents))
else:
config = AppConfig()
except Exception:
logging.exception(
"Error reading config file '%s' at time %.3f.\n"
"Backing up broken config to'%s.broken'.",
config_file_path,
_babase.apptime(),
config_file_path,
)
try:
import shutil
shutil.copyfile(config_file_path, config_file_path + '.broken')
except Exception:
logging.exception('Error copying broken config.')
config = AppConfig()
return config
def commit_app_config() -> None: def commit_app_config() -> None:
"""Commit the config to persistent storage. """Commit the config to persistent storage.
@ -145,6 +107,7 @@ def commit_app_config() -> None:
(internal) (internal)
""" """
# FIXME - this should not require plus.
plus = _babase.app.plus plus = _babase.app.plus
assert plus is not None assert plus is not None

View file

@ -27,20 +27,22 @@ class AppMode:
"""Return whether this mode can handle the provided intent. """Return whether this mode can handle the provided intent.
For this to return True, the AppMode must claim to support the For this to return True, the AppMode must claim to support the
provided intent (via its _supports_intent() method) AND the provided intent (via its _can_handle_intent() method) AND the
AppExperience associated with the AppMode must be supported by AppExperience associated with the AppMode must be supported by
the current app and runtime environment. the current app and runtime environment.
""" """
# FIXME: check AppExperience. # TODO: check AppExperience against current environment.
return cls._supports_intent(intent) return cls._can_handle_intent(intent)
@classmethod @classmethod
def _supports_intent(cls, intent: AppIntent) -> bool: def _can_handle_intent(cls, intent: AppIntent) -> bool:
"""Return whether our mode can handle the provided intent. """Return whether our mode can handle the provided intent.
AppModes should override this to define what they can handle. AppModes should override this to communicate what they can
Note that AppExperience does not have to be considered here; that handle. Note that AppExperience does not have to be considered
is handled automatically by the can_handle_intent() call.""" here; that is handled automatically by the can_handle_intent()
call.
"""
raise NotImplementedError('AppMode subclasses must override this.') raise NotImplementedError('AppMode subclasses must override this.')
def handle_intent(self, intent: AppIntent) -> None: def handle_intent(self, intent: AppIntent) -> None:
@ -54,7 +56,7 @@ class AppMode:
"""Called when the mode is being deactivated.""" """Called when the mode is being deactivated."""
def on_app_active_changed(self) -> None: def on_app_active_changed(self) -> None:
"""Called when babase.app.active changes. """Called when ba*.app.active changes while this mode is active.
The app-mode may want to take action such as pausing a running The app-mode may want to take action such as pausing a running
game in such cases. game in such cases.

View file

@ -11,7 +11,7 @@ if TYPE_CHECKING:
class AppModeSelector: class AppModeSelector:
"""Defines which AppModes to use to handle given AppIntents. """Defines which AppModes are available or used to handle given AppIntents.
Category: **App Classes** Category: **App Classes**
@ -29,4 +29,4 @@ class AppModeSelector:
This may be called in a background thread, so avoid any calls This may be called in a background thread, so avoid any calls
limited to logic thread use/etc. limited to logic thread use/etc.
""" """
raise NotImplementedError('app_mode_for_intent() should be overridden.') raise NotImplementedError()

View file

@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
import _babase import _babase
if TYPE_CHECKING: if TYPE_CHECKING:
pass from babase import UIScale
class AppSubsystem: class AppSubsystem:
@ -53,3 +53,22 @@ class AppSubsystem:
def do_apply_app_config(self) -> None: def do_apply_app_config(self) -> None:
"""Called when the app config should be applied.""" """Called when the app config should be applied."""
def on_ui_scale_change(self) -> None:
"""Called when screen ui-scale changes.
Will not be called for the initial ui scale.
"""
def on_screen_size_change(self) -> None:
"""Called when the screen size changes.
Will not be called for the initial screen size.
"""
def reset(self) -> None:
"""Reset the subsystem to a default state.
This is called when switching app modes, but may be called
at other times too.
"""

View file

@ -11,25 +11,38 @@ from functools import partial
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, override from typing import TYPE_CHECKING, override
from efro.log import LogLevel from efro.util import utc_now
from efro.logging import LogLevel
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
import _babase import _babase
from babase._appsubsystem import AppSubsystem from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime
from typing import Any, TextIO, Callable from typing import Any, TextIO, Callable
import babase import babase
def utc_now_cloud() -> datetime.datetime:
"""Returns estimated utc time regardless of local clock settings.
Applies offsets pulled from server communication/etc.
"""
# TODO: wire this up. Just using local time for now. Make sure that
# BaseFeatureSet::TimeSinceEpochCloudSeconds() and this are synced
# up.
return utc_now()
def is_browser_likely_available() -> bool: def is_browser_likely_available() -> bool:
"""Return whether a browser likely exists on the current device. """Return whether a browser likely exists on the current device.
category: General Utility Functions category: General Utility Functions
If this returns False you may want to avoid calling babase.show_url() If this returns False you may want to avoid calling babase.open_url()
with any lengthy addresses. (ba.show_url() will display an address with any lengthy addresses. (babase.open_url() will display an address
as a string in a window if unable to bring up a browser, but that as a string in a window if unable to bring up a browser, but that
is only useful for simple URLs.) is only useful for simple URLs.)
""" """
@ -115,7 +128,7 @@ def handle_v1_cloud_log() -> None:
'userRanCommands': _babase.has_user_run_commands(), 'userRanCommands': _babase.has_user_run_commands(),
'time': _babase.apptime(), 'time': _babase.apptime(),
'userModded': _babase.workspaces_in_use(), 'userModded': _babase.workspaces_in_use(),
'newsShow': plus.get_news_show(), 'newsShow': plus.get_classic_news_show(),
} }
def response(data: Any) -> None: def response(data: Any) -> None:

View file

@ -1,197 +1,26 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Functionality related to the cloud.""" """Cloud related functionality."""
from __future__ import annotations from __future__ import annotations
import logging from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, overload
import _babase import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Any pass
from efro.message import Message, Response
import bacommon.cloud
DEBUG_LOG = False
# TODO: Should make it possible to define a protocol in bacommon.cloud and
# autogenerate this. That would give us type safety between this and
# internal protocols.
class CloudSubsystem(AppSubsystem): class CloudSubscription:
"""Manages communication with cloud components.""" """User handle to a subscription to some cloud data.
@property Do not instantiate these directly; use the subscribe methods
def connected(self) -> bool: in *.app.plus.cloud to create them.
"""Property equivalent of CloudSubsystem.is_connected().""" """
return self.is_connected()
def is_connected(self) -> bool: def __init__(self, subscription_id: int) -> None:
"""Return whether a connection to the cloud is present. self._subscription_id = subscription_id
This is a good indicator (though not for certain) that sending def __del__(self) -> None:
messages will succeed. if _babase.app.plus is not None:
""" _babase.app.plus.cloud.unsubscribe(self._subscription_id)
return False # Needs to be overridden
def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes."""
if DEBUG_LOG:
logging.debug('CloudSubsystem: Connectivity is now %s.', connected)
plus = _babase.app.plus
assert plus is not None
# Inform things that use this.
# (TODO: should generalize this into some sort of registration system)
plus.accounts.on_cloud_connectivity_changed(connected)
@overload
def send_message_cb(
self,
msg: bacommon.cloud.LoginProxyRequestMessage,
on_response: Callable[
[bacommon.cloud.LoginProxyRequestResponse | Exception], None
],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.LoginProxyStateQueryMessage,
on_response: Callable[
[bacommon.cloud.LoginProxyStateQueryResponse | Exception], None
],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.LoginProxyCompleteMessage,
on_response: Callable[[None | Exception], None],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.PingMessage,
on_response: Callable[[bacommon.cloud.PingResponse | Exception], None],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.SignInMessage,
on_response: Callable[
[bacommon.cloud.SignInResponse | Exception], None
],
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.ManageAccountMessage,
on_response: Callable[
[bacommon.cloud.ManageAccountResponse | Exception], None
],
) -> None:
...
def send_message_cb(
self,
msg: Message,
on_response: Callable[[Any], None],
) -> None:
"""Asynchronously send a message to the cloud from the logic thread.
The provided on_response call will be run in the logic thread
and passed either the response or the error that occurred.
"""
from babase._general import Call
del msg # Unused.
_babase.pushcall(
Call(
on_response,
RuntimeError('Cloud functionality is not available.'),
)
)
@overload
def send_message(
self, msg: bacommon.cloud.WorkspaceFetchMessage
) -> bacommon.cloud.WorkspaceFetchResponse:
...
@overload
def send_message(
self, msg: bacommon.cloud.MerchAvailabilityMessage
) -> bacommon.cloud.MerchAvailabilityResponse:
...
@overload
def send_message(
self, msg: bacommon.cloud.TestMessage
) -> bacommon.cloud.TestResponse:
...
def send_message(self, msg: Message) -> Response | None:
"""Synchronously send a message to the cloud.
Must be called from a background thread.
"""
raise RuntimeError('Cloud functionality is not available.')
def cloud_console_exec(code: str) -> None:
"""Called by the cloud console to run code in the logic thread."""
import sys
import __main__
try:
# First try it as eval.
try:
evalcode = compile(code, '<console>', 'eval')
except SyntaxError:
evalcode = None
except Exception:
# hmm; when we can't compile it as eval will we always get
# syntax error?
logging.exception(
'unexpected error compiling code for cloud-console eval.'
)
evalcode = None
if evalcode is not None:
# pylint: disable=eval-used
value = eval(evalcode, vars(__main__), vars(__main__))
# For eval-able statements, print the resulting value if
# it is not None (just like standard Python interpreter).
if value is not None:
print(repr(value), file=sys.stderr)
# Fall back to exec if we couldn't compile it as eval.
else:
execcode = compile(code, '<console>', 'exec')
# pylint: disable=exec-used
exec(execcode, vars(__main__), vars(__main__))
except Exception:
import traceback
apptime = _babase.apptime()
print(f'Exec error at time {apptime:.2f}.', file=sys.stderr)
traceback.print_exc()
# This helps the logging system ship stderr back to the
# cloud promptly.
sys.stderr.flush()

View file

@ -4,9 +4,9 @@
from __future__ import annotations from __future__ import annotations
import os import os
from typing import TYPE_CHECKING, override
from dataclasses import dataclass
import logging import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
import _babase import _babase
@ -30,10 +30,27 @@ class DevConsoleTab:
pos: tuple[float, float], pos: tuple[float, float],
size: tuple[float, float], size: tuple[float, float],
call: Callable[[], Any] | None = None, call: Callable[[], Any] | None = None,
*,
h_anchor: Literal['left', 'center', 'right'] = 'center', h_anchor: Literal['left', 'center', 'right'] = 'center',
label_scale: float = 1.0, label_scale: float = 1.0,
corner_radius: float = 8.0, corner_radius: float = 8.0,
style: Literal['normal', 'dark'] = 'normal', style: Literal[
'normal',
'bright',
'red',
'red_bright',
'purple',
'purple_bright',
'yellow',
'yellow_bright',
'blue',
'blue_bright',
'white',
'white_bright',
'black',
'black_bright',
] = 'normal',
disabled: bool = False,
) -> None: ) -> None:
"""Add a button to the tab being refreshed.""" """Add a button to the tab being refreshed."""
assert _babase.app.devconsole.is_refreshing assert _babase.app.devconsole.is_refreshing
@ -48,12 +65,14 @@ class DevConsoleTab:
label_scale, label_scale,
corner_radius, corner_radius,
style, style,
disabled,
) )
def text( def text(
self, self,
text: str, text: str,
pos: tuple[float, float], pos: tuple[float, float],
*,
h_anchor: Literal['left', 'center', 'right'] = 'center', h_anchor: Literal['left', 'center', 'right'] = 'center',
h_align: Literal['left', 'center', 'right'] = 'center', h_align: Literal['left', 'center', 'right'] = 'center',
v_align: Literal['top', 'center', 'bottom', 'none'] = 'center', v_align: Literal['top', 'center', 'bottom', 'none'] = 'center',
@ -93,47 +112,6 @@ class DevConsoleTab:
return _babase.dev_console_base_scale() return _babase.dev_console_base_scale()
class DevConsoleTabPython(DevConsoleTab):
"""The Python dev-console tab."""
@override
def refresh(self) -> None:
self.python_terminal()
class DevConsoleTabTest(DevConsoleTab):
"""Test dev-console tab."""
@override
def refresh(self) -> None:
import random
self.button(
f'FLOOP-{random.randrange(200)}',
pos=(10, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
call=self.request_refresh,
)
self.button(
f'FLOOP2-{random.randrange(200)}',
pos=(120, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
style='dark',
)
self.text(
'TestText',
scale=0.8,
pos=(15, 50),
h_anchor='left',
h_align='left',
v_align='none',
)
@dataclass @dataclass
class DevConsoleTabEntry: class DevConsoleTabEntry:
"""Represents a distinct tab in the dev-console.""" """Represents a distinct tab in the dev-console."""
@ -154,26 +132,50 @@ class DevConsoleSubsystem:
""" """
def __init__(self) -> None: def __init__(self) -> None:
# pylint: disable=cyclic-import
from babase._devconsoletabs import (
DevConsoleTabPython,
DevConsoleTabAppModes,
DevConsoleTabUI,
DevConsoleTabLogging,
DevConsoleTabTest,
)
# All tabs in the dev-console. Add your own stuff here via # All tabs in the dev-console. Add your own stuff here via
# plugins or whatnot. # plugins or whatnot.
self.tabs: list[DevConsoleTabEntry] = [ self.tabs: list[DevConsoleTabEntry] = [
DevConsoleTabEntry('Python', DevConsoleTabPython) DevConsoleTabEntry('Python', DevConsoleTabPython),
DevConsoleTabEntry('AppModes', DevConsoleTabAppModes),
DevConsoleTabEntry('UI', DevConsoleTabUI),
DevConsoleTabEntry('Logging', DevConsoleTabLogging),
] ]
if os.environ.get('BA_DEV_CONSOLE_TEST_TAB', '0') == '1': if os.environ.get('BA_DEV_CONSOLE_TEST_TAB', '0') == '1':
self.tabs.append(DevConsoleTabEntry('Test', DevConsoleTabTest)) self.tabs.append(DevConsoleTabEntry('Test', DevConsoleTabTest))
self.is_refreshing = False self.is_refreshing = False
self._tab_instances: dict[str, DevConsoleTab] = {}
def do_refresh_tab(self, tabname: str) -> None: def do_refresh_tab(self, tabname: str) -> None:
"""Called by the C++ layer when a tab should be filled out.""" """Called by the C++ layer when a tab should be filled out."""
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
# FIXME: We currently won't handle multiple tabs with the same # Make noise if we have repeating tab names, as that breaks our
# name. We should give a clean error or something in that case. # logic.
tab: DevConsoleTab | None = None if __debug__:
for tabentry in self.tabs: alltabnames = set[str](tabentry.name for tabentry in self.tabs)
if tabentry.name == tabname: if len(alltabnames) != len(self.tabs):
tab = tabentry.factory() logging.error(
break 'Duplicate dev-console tab names found;'
' tabs may behave unpredictably.'
)
tab: DevConsoleTab | None = self._tab_instances.get(tabname)
# If we haven't instantiated this tab yet, do so.
if tab is None:
for tabentry in self.tabs:
if tabentry.name == tabname:
tab = self._tab_instances[tabname] = tabentry.factory()
break
if tab is None: if tab is None:
logging.error( logging.error(

View file

@ -0,0 +1,671 @@
# Released under the MIT License. See LICENSE for details.
#
"""Predefined tabs for the dev console."""
from __future__ import annotations
import math
import random
import logging
from functools import partial
from typing import TYPE_CHECKING, override, TypeVar, Generic
import _babase
from babase._devconsole import DevConsoleTab
if TYPE_CHECKING:
from typing import Callable, Literal
from bacommon.loggercontrol import LoggerControlConfig
from babase import AppMode
T = TypeVar('T')
class DevConsoleTabPython(DevConsoleTab):
"""The Python dev-console tab."""
@override
def refresh(self) -> None:
self.python_terminal()
class DevConsoleTabAppModes(DevConsoleTab):
"""Tab to switch app modes."""
def __init__(self) -> None:
self._app_modes: list[type[AppMode]] | None = None
self._app_modes_loading = False
def _on_app_modes_loaded(self, modes: list[type[AppMode]]) -> None:
from babase._appintent import AppIntentDefault
intent = AppIntentDefault()
# Limit to modes that can handle default intents since that's
# what we use.
self._app_modes = [
mode for mode in modes if mode.can_handle_intent(intent)
]
self.request_refresh()
@override
def refresh(self) -> None:
from babase import AppMode
# Kick off a load if applicable.
if self._app_modes is None and not self._app_modes_loading:
_babase.app.meta.load_exported_classes(
AppMode, self._on_app_modes_loaded
)
# Just say 'loading' if we don't have app-modes yet.
if self._app_modes is None:
self.text(
'Loading...', pos=(0, 30), h_anchor='center', h_align='center'
)
return
bwidth = 260
bpadding = 5
xoffs = -0.5 * bwidth * len(self._app_modes)
self.text(
'Available AppModes:',
scale=0.8,
pos=(0, 75),
h_align='center',
v_align='center',
)
# pylint: disable=protected-access
for i, mode in enumerate(self._app_modes):
self.button(
f'{mode.__module__}.{mode.__qualname__}',
pos=(xoffs + i * bwidth + bpadding, 10),
size=(bwidth - 2.0 * bpadding, 40),
label_scale=0.6,
call=partial(self._set_app_mode, mode),
style=(
'bright'
if isinstance(_babase.app._mode, mode)
else 'normal'
),
)
def _set_app_mode(self, mode: type[AppMode]) -> None:
from babase._appintent import AppIntentDefault
intent = AppIntentDefault()
# Use private functionality to force a specific app-mode to
# handle this intent. Note that this should never be done
# outside of this explicit testing case. It is the app's job to
# determine which app-mode should be used to handle a given
# intent.
setattr(intent, '_force_app_mode_handler', mode)
_babase.app.set_intent(intent)
# Slight hackish: need to wait a moment before refreshing to
# pick up the newly current mode, as mode switches are
# asynchronous.
_babase.apptimer(0.1, self.request_refresh)
class DevConsoleTabUI(DevConsoleTab):
"""Tab to debug/test UI stuff."""
@override
def refresh(self) -> None:
from babase._mgen.enums import UIScale
xoffs = -375
self.text(
'Make sure all UIs either fit in the virtual safe area'
' or dynamically respond to screen size changes.',
scale=0.6,
pos=(xoffs + 15, 70),
h_align='left',
v_align='center',
)
ui_overlay = _babase.get_draw_virtual_safe_area_bounds()
self.button(
'Virtual Safe Area ON' if ui_overlay else 'Virtual Safe Area OFF',
pos=(xoffs + 10, 10),
size=(200, 30),
label_scale=0.6,
call=self.toggle_ui_overlay,
style='bright' if ui_overlay else 'normal',
)
x = 300
self.text(
'UI-Scale',
pos=(xoffs + x - 5, 15),
h_align='right',
v_align='none',
scale=0.6,
)
bwidth = 100
for scale in UIScale:
self.button(
scale.name.capitalize(),
pos=(xoffs + x, 10),
size=(bwidth, 30),
label_scale=0.6,
call=partial(_babase.app.set_ui_scale, scale),
style=(
'bright'
if scale.name.lower() == _babase.get_ui_scale()
else 'normal'
),
)
x += bwidth + 2
def toggle_ui_overlay(self) -> None:
"""Toggle UI overlay drawing."""
_babase.set_draw_virtual_safe_area_bounds(
not _babase.get_draw_virtual_safe_area_bounds()
)
self.request_refresh()
class Table(Generic[T]):
"""Used to show controls for arbitrarily large data in a grid form."""
def __init__(
self,
title: str,
entries: list[T],
draw_entry_call: Callable[
[int, T, DevConsoleTab, float, float, float, float], None
],
*,
entry_width: float = 300.0,
entry_height: float = 40.0,
margin_left_right: float = 60.0,
debug_bounds: bool = False,
max_columns: int | None = None,
) -> None:
self._title = title
self._entry_width = entry_width
self._entry_height = entry_height
self._margin_left_right = margin_left_right
self._focus_entry_index = 0
self._entries_per_page = 1
self._debug_bounds = debug_bounds
self._entries = entries
self._draw_entry_call = draw_entry_call
self._max_columns = max_columns
# Values updated on refresh (for aligning other custom
# widgets/etc.)
self.top_left: tuple[float, float] = (0.0, 0.0)
self.top_right: tuple[float, float] = (0.0, 0.0)
def set_entries(self, entries: list[T]) -> None:
"""Update table entries."""
self._entries = entries
# Clamp focus to new entries.
self._focus_entry_index = max(
0, min(len(self._entries) - 1, self._focus_entry_index)
)
def set_focus_entry_index(self, index: int) -> None:
"""Explicitly set the focused entry.
This affects which page is shown at the next refresh.
"""
self._focus_entry_index = max(0, min(len(self._entries) - 1, index))
def refresh(self, tab: DevConsoleTab) -> None:
"""Call to refresh the data."""
# pylint: disable=too-many-locals
margin_top = 50.0
margin_bottom = 10.0
# Update how much we can fit on a page based on our current size.
max_entry_area_width = tab.width - (self._margin_left_right * 2.0)
max_entry_area_height = tab.height - (margin_top + margin_bottom)
columns = max(1, int(max_entry_area_width / self._entry_width))
if self._max_columns is not None:
columns = min(columns, self._max_columns)
rows = max(1, int(max_entry_area_height / self._entry_height))
self._entries_per_page = rows * columns
# See which page our focus index falls in.
pagemax = math.ceil(len(self._entries) / self._entries_per_page)
page = self._focus_entry_index // self._entries_per_page
entry_offset = page * self._entries_per_page
entries_on_this_page = min(
self._entries_per_page, len(self._entries) - entry_offset
)
columns_on_this_page = math.ceil(entries_on_this_page / rows)
rows_on_this_page = min(entries_on_this_page, rows)
# We attach things to the center so resizes are smooth but we do
# some math in a left-centric way.
center_to_left = tab.width * -0.5
# Center our columns.
xoffs = 0.5 * (
max_entry_area_width - columns_on_this_page * self._entry_width
)
# Align everything to the bottom of the dev-console.
#
# UPDATE: Nevermind; top feels better. Keeping this code around
# in case we ever want to make it an option though.
if bool(False):
yoffs = -1.0 * (
tab.height
- (
rows_on_this_page * self._entry_height
+ margin_top
+ margin_bottom
)
)
else:
yoffs = 0
# Keep our corners up to date for user use.
self.top_left = (center_to_left + xoffs, tab.height + yoffs)
self.top_right = (
self.top_left[0]
+ self._margin_left_right * 2.0
+ columns_on_this_page * self._entry_width,
self.top_left[1],
)
# Page left/right buttons.
tab.button(
'<',
pos=(
center_to_left + xoffs,
yoffs + tab.height - margin_top - rows * self._entry_height,
),
size=(
self._margin_left_right,
rows * self._entry_height,
),
call=partial(self._page_left, tab),
disabled=entry_offset == 0,
)
tab.button(
'>',
pos=(
center_to_left
+ xoffs
+ self._margin_left_right
+ columns_on_this_page * self._entry_width,
yoffs + tab.height - margin_top - rows * self._entry_height,
),
size=(
self._margin_left_right,
rows * self._entry_height,
),
call=partial(self._page_right, tab),
disabled=(
entry_offset + entries_on_this_page >= len(self._entries)
),
)
for column in range(columns):
for row in range(rows):
entry_index = entry_offset + column * rows + row
if entry_index >= len(self._entries):
break
xpos = (
xoffs + self._margin_left_right + self._entry_width * column
)
ypos = (
yoffs
+ tab.height
- margin_top
- self._entry_height * (row + 1.0)
)
# Draw debug bounds.
if self._debug_bounds:
tab.button(
str(entry_index),
pos=(
center_to_left + xpos,
ypos,
),
size=(self._entry_width, self._entry_height),
# h_anchor='left',
)
# Run user drawing.
self._draw_entry_call(
entry_index,
self._entries[entry_index],
tab,
center_to_left + xpos,
ypos,
self._entry_width,
self._entry_height,
)
if entry_index >= len(self._entries):
break
tab.text(
f'{self._title} ({page + 1}/{pagemax})',
scale=0.8,
pos=(0, yoffs + tab.height - margin_top * 0.5),
h_align='center',
v_align='center',
)
def _page_right(self, tab: DevConsoleTab) -> None:
# Set focus on the first entry in the page before the current.
page = self._focus_entry_index // self._entries_per_page
page += 1
self.set_focus_entry_index(page * self._entries_per_page)
tab.request_refresh()
def _page_left(self, tab: DevConsoleTab) -> None:
# Set focus on the first entry in the page after the current.
page = self._focus_entry_index // self._entries_per_page
page -= 1
self.set_focus_entry_index(page * self._entries_per_page)
tab.request_refresh()
class DevConsoleTabLogging(DevConsoleTab):
"""Tab to wrangle logging levels."""
def __init__(self) -> None:
self._table = Table(
title='Logging Levels',
entry_width=800,
entry_height=42,
debug_bounds=False,
entries=list[str](),
draw_entry_call=self._draw_entry,
max_columns=1,
)
@override
def refresh(self) -> None:
assert self._table is not None
# Update table entries with the latest set of loggers (this can
# change over time).
self._table.set_entries(
['root'] + sorted(logging.root.manager.loggerDict)
)
# Draw the table.
self._table.refresh(self)
# Draw our control buttons in the corners.
tl = self._table.top_left
tr = self._table.top_right
bwidth = 140.0
bheight = 30.0
bvpad = 10.0
self.button(
'Reset',
pos=(tl[0], tl[1] - bheight - bvpad),
size=(bwidth, bheight),
label_scale=0.6,
call=self._reset,
disabled=(
not self._get_reset_logger_control_config().would_make_changes()
),
)
self.button(
'Cloud Control OFF',
pos=(tr[0] - bwidth, tl[1] - bheight - bvpad),
size=(bwidth, bheight),
label_scale=0.6,
disabled=True,
)
def _get_reset_logger_control_config(self) -> LoggerControlConfig:
from bacommon.logging import get_base_logger_control_config_client
return get_base_logger_control_config_client()
def _reset(self) -> None:
self._get_reset_logger_control_config().apply()
# Let the native layer know that levels changed.
_babase.update_internal_logger_levels()
# Blow away any existing values in app-config.
appconfig = _babase.app.config
if 'Log Levels' in appconfig:
del appconfig['Log Levels']
appconfig.commit()
self.request_refresh()
def _set_entry_val(self, entry_index: int, entry: str, val: int) -> None:
from bacommon.logging import get_base_logger_control_config_client
from bacommon.loggercontrol import LoggerControlConfig
# Focus on this entry with any interaction, so if we get resized
# it'll still be visible.
self._table.set_focus_entry_index(entry_index)
logging.getLogger(entry).setLevel(val)
# Let the native layer know that levels changed.
_babase.update_internal_logger_levels()
# Store only changes compared to the base config.
baseconfig = get_base_logger_control_config_client()
config = LoggerControlConfig.from_current_loggers().diff(baseconfig)
appconfig = _babase.app.config
appconfig['Log Levels'] = config.levels
appconfig.commit()
self.request_refresh()
def _draw_entry(
self,
entry_index: int,
entry: str,
tab: DevConsoleTab,
x: float,
y: float,
width: float,
height: float,
) -> None:
# pylint: disable=too-many-positional-arguments
# pylint: disable=too-many-locals
xoffs = -15.0
bwidth = 80.0
btextscale = 0.5
tab.text(
entry,
(
x + width - bwidth * 6.5 - 10.0 + xoffs,
y + height * 0.5,
),
h_align='right',
scale=0.7,
)
logger = logging.getLogger(entry)
level = logger.level
index = 0
effectivelevel = logger.getEffectiveLevel()
# if entry != 'root' and level == logging.NOTSET:
# # Show the level being inherited in NOTSET cases.
# notsetlevelname = logging.getLevelName(logger.getEffectiveLevel())
# if notsetlevelname == 'NOTSET':
# notsetname = 'Not Set'
# else:
# notsetname = f'Not Set ({notsetlevelname.capitalize()})'
# else:
notsetname = 'Not Set'
tab.button(
notsetname,
pos=(x + width - bwidth * 6.5 + xoffs + 1.0, y + 5.0),
size=(bwidth * 1.0 - 2.0, height - 10),
label_scale=btextscale,
style='white_bright' if level == logging.NOTSET else 'black',
call=partial(
self._set_entry_val, entry_index, entry, logging.NOTSET
),
)
index += 1
tab.button(
'Debug',
pos=(x + width - bwidth * 5 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.DEBUG
else 'blue' if effectivelevel <= logging.DEBUG else 'black'
),
# style='bright' if level == logging.DEBUG else 'normal',
call=partial(
self._set_entry_val, entry_index, entry, logging.DEBUG
),
)
index += 1
tab.button(
'Info',
pos=(x + width - bwidth * 4 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.INFO
else 'white' if effectivelevel <= logging.INFO else 'black'
),
# style='bright' if level == logging.INFO else 'normal',
call=partial(self._set_entry_val, entry_index, entry, logging.INFO),
)
index += 1
tab.button(
'Warning',
pos=(x + width - bwidth * 3 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.WARNING
else 'yellow' if effectivelevel <= logging.WARNING else 'black'
),
call=partial(
self._set_entry_val, entry_index, entry, logging.WARNING
),
)
index += 1
tab.button(
'Error',
pos=(x + width - bwidth * 2 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.ERROR
else 'red' if effectivelevel <= logging.ERROR else 'black'
),
call=partial(
self._set_entry_val, entry_index, entry, logging.ERROR
),
)
index += 1
tab.button(
'Critical',
pos=(x + width - bwidth * 1 + xoffs + 1.0, y + 5.0),
size=(bwidth - 2.0, height - 10),
label_scale=btextscale,
style=(
'white_bright'
if level == logging.CRITICAL
else (
'purple' if effectivelevel <= logging.CRITICAL else 'black'
)
),
call=partial(
self._set_entry_val, entry_index, entry, logging.CRITICAL
),
)
class DevConsoleTabTest(DevConsoleTab):
"""Test dev-console tab."""
@override
def refresh(self) -> None:
self.button(
f'FLOOP-{random.randrange(200)}',
pos=(10, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
call=self.request_refresh,
)
self.button(
f'FLOOP2-{random.randrange(200)}',
pos=(120, 10),
size=(100, 30),
h_anchor='left',
label_scale=0.6,
style='bright',
)
self.text(
'TestText',
scale=0.8,
pos=(15, 50),
h_anchor='left',
h_align='left',
v_align='none',
)
# Throw little bits of text in the corners to make sure
# widths/heights are correct.
self.text(
'BL',
scale=0.25,
pos=(0, 0),
h_anchor='left',
h_align='left',
v_align='bottom',
)
self.text(
'BR',
scale=0.25,
pos=(self.width, 0),
h_anchor='left',
h_align='right',
v_align='bottom',
)
self.text(
'TL',
scale=0.25,
pos=(0, self.height),
h_anchor='left',
h_align='left',
v_align='top',
)
self.text(
'TR',
scale=0.25,
pos=(self.width, self.height),
h_anchor='left',
h_align='right',
v_align='top',
)

View file

@ -15,8 +15,9 @@ if TYPE_CHECKING:
from babase import AppIntent from babase import AppIntent
# ba_meta export babase.AppMode
class EmptyAppMode(AppMode): class EmptyAppMode(AppMode):
"""An empty app mode that can be used as a fallback/etc.""" """An AppMode that does not do much at all."""
@override @override
@classmethod @classmethod
@ -25,24 +26,24 @@ class EmptyAppMode(AppMode):
@override @override
@classmethod @classmethod
def _supports_intent(cls, intent: AppIntent) -> bool: def _can_handle_intent(cls, intent: AppIntent) -> bool:
# We support default and exec intents currently. # We support default and exec intents currently.
return isinstance(intent, AppIntentExec | AppIntentDefault) return isinstance(intent, AppIntentExec | AppIntentDefault)
@override @override
def handle_intent(self, intent: AppIntent) -> None: def handle_intent(self, intent: AppIntent) -> None:
if isinstance(intent, AppIntentExec): if isinstance(intent, AppIntentExec):
_babase.empty_app_mode_handle_intent_exec(intent.code) _babase.empty_app_mode_handle_app_intent_exec(intent.code)
return return
assert isinstance(intent, AppIntentDefault) assert isinstance(intent, AppIntentDefault)
_babase.empty_app_mode_handle_intent_default() _babase.empty_app_mode_handle_app_intent_default()
@override @override
def on_activate(self) -> None: def on_activate(self) -> None:
# Let the native layer do its thing. # Let the native layer do its thing.
_babase.on_empty_app_mode_activate() _babase.empty_app_mode_activate()
@override @override
def on_deactivate(self) -> None: def on_deactivate(self) -> None:
# Let the native layer do its thing. # Let the native layer do its thing.
_babase.on_empty_app_mode_deactivate() _babase.empty_app_mode_deactivate()

View file

@ -9,11 +9,11 @@ import logging
import warnings import warnings
from typing import TYPE_CHECKING, override from typing import TYPE_CHECKING, override
from efro.log import LogLevel from efro.logging import LogLevel
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
from efro.log import LogEntry, LogHandler from efro.logging import LogEntry, LogHandler
_g_babase_imported = False # pylint: disable=invalid-name _g_babase_imported = False # pylint: disable=invalid-name
_g_babase_app_started = False # pylint: disable=invalid-name _g_babase_app_started = False # pylint: disable=invalid-name
@ -186,7 +186,10 @@ def _feed_logs_to_babase(log_handler: LogHandler) -> None:
# Forward this along to the engine to display in the in-app # Forward this along to the engine to display in the in-app
# console, in the Android log, etc. # console, in the Android log, etc.
_babase.emit_log( _babase.emit_log(
name=entry.name, level=entry.level.name, message=entry.message name=entry.name,
level=entry.level.name,
timestamp=entry.time.timestamp(),
message=entry.message,
) )
# We also want to feed some logs to the old v1-cloud-log system. # We also want to feed some logs to the old v1-cloud-log system.

View file

@ -63,7 +63,7 @@ def existing(obj: ExistableT | None) -> ExistableT | None:
For more info, see notes on 'existables' here: For more info, see notes on 'existables' here:
https://ballistica.net/wiki/Coding-Style-Guide https://ballistica.net/wiki/Coding-Style-Guide
""" """
assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}' assert obj is None or hasattr(obj, 'exists'), f'No "exists" attr on {obj}.'
return obj if obj is not None and obj.exists() else None return obj if obj is not None and obj.exists() else None
@ -156,6 +156,9 @@ class _WeakCall:
to wrap them in weakrefs manually if desired. to wrap them in weakrefs manually if desired.
""" """
# Optimize performance a bit; we shouldn't need to be super dynamic.
__slots__ = ['_call', '_args', '_keywds']
_did_invalid_call_warning = False _did_invalid_call_warning = False
def __init__(self, *args: Any, **keywds: Any) -> None: def __init__(self, *args: Any, **keywds: Any) -> None:
@ -173,9 +176,10 @@ class _WeakCall:
'Warning: callable passed to babase.WeakCall() is not' 'Warning: callable passed to babase.WeakCall() is not'
' weak-referencable (%s); use functools.partial instead' ' weak-referencable (%s); use functools.partial instead'
' to avoid this warning.', ' to avoid this warning.',
args[0],
stack_info=True, stack_info=True,
) )
self._did_invalid_call_warning = True type(self)._did_invalid_call_warning = True
self._call = args[0] self._call = args[0]
self._args = args[1:] self._args = args[1:]
self._keywds = keywds self._keywds = keywds
@ -214,6 +218,9 @@ class _Call:
without keeping its object alive. without keeping its object alive.
""" """
# Optimize performance a bit; we shouldn't need to be super dynamic.
__slots__ = ['_call', '_args', '_keywds']
def __init__(self, *args: Any, **keywds: Any): def __init__(self, *args: Any, **keywds: Any):
"""Instantiate a Call. """Instantiate a Call.
@ -252,6 +259,14 @@ if TYPE_CHECKING:
# type checking on both positional and keyword arguments (as of mypy # type checking on both positional and keyword arguments (as of mypy
# 1.11). # 1.11).
# FIXME: Actually, currently (as of Dec 2024) mypy doesn't fully
# type check partial. The partial() call itself is checked, but the
# resulting callable seems to be essentially untyped. We should
# probably revise this stuff so that Call and WeakCall are for 100%
# complete calls so we can fully type check them using ParamSpecs or
# whatnot. We could then write a weak_partial() call if we actually
# need that particular combination of functionality.
# Note: Something here is wonky with pylint, possibly related to our # Note: Something here is wonky with pylint, possibly related to our
# custom pylint plugin. Disabling all checks seems to fix it. # custom pylint plugin. Disabling all checks seems to fix it.
# pylint: disable=all # pylint: disable=all
@ -272,6 +287,9 @@ class WeakMethod:
free to die. If called with a dead target, is simply a no-op. free to die. If called with a dead target, is simply a no-op.
""" """
# Optimize performance a bit; we shouldn't need to be super dynamic.
__slots__ = ['_func', '_obj']
def __init__(self, call: types.MethodType): def __init__(self, call: types.MethodType):
assert isinstance(call, types.MethodType) assert isinstance(call, types.MethodType)
self._func = call.__func__ self._func = call.__func__

View file

@ -430,3 +430,40 @@ def unsupported_controller_message(name: str) -> None:
Lstr(resource='unsupportedControllerText', subs=[('${NAME}', name)]), Lstr(resource='unsupportedControllerText', subs=[('${NAME}', name)]),
color=(1, 0, 0), color=(1, 0, 0),
) )
def copy_dev_console_history() -> None:
"""Copy log history from the dev console."""
import baenv
from babase._language import Lstr
if not _babase.clipboard_is_supported():
_babase.getsimplesound('error').play()
_babase.screenmessage(
'Clipboard not supported on this build.',
color=(1, 0, 0),
)
return
# This requires us to be running with a log-handler set up.
envconfig = baenv.get_config()
if envconfig.log_handler is None:
_babase.getsimplesound('error').play()
_babase.screenmessage(
'Not available; standard engine logging is not enabled.',
color=(1, 0, 0),
)
return
# Just dump everything that's in the log-handler's cache.
archive = envconfig.log_handler.get_cached()
lines: list[str] = []
stdnames = ('stdout', 'stderr')
for entry in archive.entries:
reltime = entry.time.timestamp() - envconfig.launch_time
level_ex = '' if entry.name in stdnames else f' {entry.level.name}'
lines.append(f'{reltime:.3f}{level_ex} {entry.name}: {entry.message}')
_babase.clipboard_set_text('\n'.join(lines))
_babase.screenmessage(Lstr(resource='copyConfirmText'), color=(0, 1, 0))
_babase.getsimplesound('gunCocking').play()

View file

@ -1,33 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""On-screen Keyboard related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class Keyboard:
"""Chars definitions for on-screen keyboard.
Category: **App Classes**
Keyboards are discoverable by the meta-tag system
and the user can select which one they want to use.
On-screen keyboard uses chars from active babase.Keyboard.
"""
name: str
"""Displays when user selecting this keyboard."""
chars: list[tuple[str, ...]]
"""Used for row/column lengths."""
pages: dict[str, tuple[str, ...]]
"""Extra chars like emojis."""
nums: tuple[str, ...]
"""The 'num' page."""

View file

@ -44,8 +44,13 @@ class LanguageSubsystem(AppSubsystem):
(which may differ from locale if the user sets a language, etc.) (which may differ from locale if the user sets a language, etc.)
""" """
env = _babase.env() env = _babase.env()
assert isinstance(env['locale'], str) locale = env.get('locale')
return env['locale'] if not isinstance(locale, str):
logging.warning(
'Seem to be running in a dummy env; returning en_US locale.'
)
locale = 'en_US'
return locale
@property @property
def language(self) -> str: def language(self) -> str:
@ -83,6 +88,8 @@ class LanguageSubsystem(AppSubsystem):
for i, name in enumerate(names): for i, name in enumerate(names):
if name == 'Chinesetraditional': if name == 'Chinesetraditional':
names[i] = 'ChineseTraditional' names[i] = 'ChineseTraditional'
elif name == 'Piratespeak':
names[i] = 'PirateSpeak'
except Exception: except Exception:
from babase import _error from babase import _error
@ -431,7 +438,7 @@ class LanguageSubsystem(AppSubsystem):
'Thai', 'Thai',
'Tamil', 'Tamil',
} }
and not _babase.can_display_full_unicode() and not _babase.supports_unicode_display()
): ):
return False return False
return True return True
@ -522,8 +529,10 @@ class Lstr:
... subs=[('${NAME}', babase.Lstr(resource='res_b'))]) ... subs=[('${NAME}', babase.Lstr(resource='res_b'))])
""" """
# pylint: disable=dangerous-default-value # This class is used a lot in UI stuff and doesn't need to be
# noinspection PyDefaultArgument # flexible, so let's optimize its performance a bit.
__slots__ = ['args']
@overload @overload
def __init__( def __init__(
self, self,
@ -531,29 +540,28 @@ class Lstr:
resource: str, resource: str,
fallback_resource: str = '', fallback_resource: str = '',
fallback_value: str = '', fallback_value: str = '',
subs: Sequence[tuple[str, str | Lstr]] = [], subs: Sequence[tuple[str, str | Lstr]] | None = None,
) -> None: ) -> None:
"""Create an Lstr from a string resource.""" """Create an Lstr from a string resource."""
# noinspection PyShadowingNames,PyDefaultArgument
@overload @overload
def __init__( def __init__(
self, self,
*, *,
translate: tuple[str, str], translate: tuple[str, str],
subs: Sequence[tuple[str, str | Lstr]] = [], subs: Sequence[tuple[str, str | Lstr]] | None = None,
) -> None: ) -> None:
"""Create an Lstr by translating a string in a category.""" """Create an Lstr by translating a string in a category."""
# noinspection PyDefaultArgument
@overload @overload
def __init__( def __init__(
self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = [] self,
*,
value: str,
subs: Sequence[tuple[str, str | Lstr]] | None = None,
) -> None: ) -> None:
"""Create an Lstr from a raw string value.""" """Create an Lstr from a raw string value."""
# pylint: enable=redefined-outer-name, dangerous-default-value
def __init__(self, *args: Any, **keywds: Any) -> None: def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a Lstr. """Instantiate a Lstr.
@ -581,14 +589,16 @@ class Lstr:
if isinstance(self.args.get('value'), our_type): if isinstance(self.args.get('value'), our_type):
raise TypeError("'value' must be a regular string; not an Lstr") raise TypeError("'value' must be a regular string; not an Lstr")
if 'subs' in self.args: if 'subs' in keywds:
subs_new = [] subs = keywds.get('subs')
for key, value in keywds['subs']: subs_filtered = []
if isinstance(value, our_type): if subs is not None:
subs_new.append((key, value.args)) for key, value in keywds['subs']:
else: if isinstance(value, our_type):
subs_new.append((key, value)) subs_filtered.append((key, value.args))
self.args['subs'] = subs_new else:
subs_filtered.append((key, value))
self.args['subs'] = subs_filtered
# As of protocol 31 we support compact key names # As of protocol 31 we support compact key names
# ('t' instead of 'translate', etc). Convert as needed. # ('t' instead of 'translate', etc). Convert as needed.

12
dist/ba_data/python/babase/_logging.py vendored Normal file
View file

@ -0,0 +1,12 @@
# Released under the MIT License. See LICENSE for details.
#
"""Logging functionality."""
from __future__ import annotations
import logging
# Our standard set of loggers.
balog = logging.getLogger('ba')
applog = logging.getLogger('ba.app')
lifecyclelog = logging.getLogger('ba.lifecycle')

View file

@ -17,8 +17,7 @@ import _babase
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable from typing import Callable
logger = logging.getLogger('ba.loginadapter')
DEBUG_LOG = False
@dataclass @dataclass
@ -94,20 +93,17 @@ class LoginAdapter:
if state == self._implicit_login_state: if state == self._implicit_login_state:
return return
if DEBUG_LOG: if state is None:
if state is None: logger.debug(
logging.debug( '%s implicit state changed; now signed out.',
'LoginAdapter: %s implicit state changed;' self.login_type.name,
' now signed out.', )
self.login_type.name, else:
) logger.debug(
else: '%s implicit state changed; now signed in as %s.',
logging.debug( self.login_type.name,
'LoginAdapter: %s implicit state changed;' state.display_name,
' now signed in as %s.', )
self.login_type.name,
state.display_name,
)
self._implicit_login_state = state self._implicit_login_state = state
self._implicit_login_state_dirty = True self._implicit_login_state_dirty = True
@ -128,12 +124,11 @@ class LoginAdapter:
only a reference to it is stored, not a copy. only a reference to it is stored, not a copy.
""" """
assert _babase.in_logic_thread() assert _babase.in_logic_thread()
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter got active logins %s.',
'LoginAdapter: %s adapter got active logins %s.', self.login_type.name,
self.login_type.name, {k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
{k: v[:4] + '...' + v[-4:] for k, v in logins.items()}, )
)
self._active_login_id = logins.get(self.login_type) self._active_login_id = logins.get(self.login_type)
self._update_back_end_active() self._update_back_end_active()
@ -197,24 +192,21 @@ class LoginAdapter:
self._last_sign_in_desc = description self._last_sign_in_desc = description
self._last_sign_in_time = now self._last_sign_in_time = now
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter sign_in() called; fetching sign-in-token...',
'LoginAdapter: %s adapter sign_in() called;' self.login_type.name,
' fetching sign-in-token...', )
self.login_type.name,
)
def _got_sign_in_token_result(result: str | None) -> None: def _got_sign_in_token_result(result: str | None) -> None:
import bacommon.cloud import bacommon.cloud
# Failed to get a sign-in-token. # Failed to get a sign-in-token.
if result is None: if result is None:
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter sign-in-token fetch failed;'
'LoginAdapter: %s adapter sign-in-token fetch failed;' ' aborting sign-in.',
' aborting sign-in.', self.login_type.name,
self.login_type.name, )
)
_babase.pushcall( _babase.pushcall(
partial( partial(
result_cb, result_cb,
@ -227,25 +219,22 @@ class LoginAdapter:
# Got a sign-in token! Now pass it to the cloud which will use # Got a sign-in token! Now pass it to the cloud which will use
# it to verify our identity and give us app credentials on # it to verify our identity and give us app credentials on
# success. # success.
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter sign-in-token fetch succeeded;'
'LoginAdapter: %s adapter sign-in-token fetch succeeded;' ' passing to cloud for verification...',
' passing to cloud for verification...', self.login_type.name,
self.login_type.name, )
)
def _got_sign_in_response( def _got_sign_in_response(
response: bacommon.cloud.SignInResponse | Exception, response: bacommon.cloud.SignInResponse | Exception,
) -> None: ) -> None:
# This likely means we couldn't communicate with the server. # This likely means we couldn't communicate with the server.
if isinstance(response, Exception): if isinstance(response, Exception):
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter got error sign-in response: %s',
'LoginAdapter: %s adapter got error' self.login_type.name,
' sign-in response: %s', response,
self.login_type.name, )
response,
)
_babase.pushcall(partial(result_cb, self, response)) _babase.pushcall(partial(result_cb, self, response))
else: else:
# This means our credentials were explicitly rejected. # This means our credentials were explicitly rejected.
@ -254,12 +243,10 @@ class LoginAdapter:
RuntimeError('Sign-in-token was rejected.') RuntimeError('Sign-in-token was rejected.')
) )
else: else:
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter got successful sign-in response',
'LoginAdapter: %s adapter got successful' self.login_type.name,
' sign-in response', )
self.login_type.name,
)
result2 = self.SignInResult( result2 = self.SignInResult(
credentials=response.credentials credentials=response.credentials
) )
@ -305,12 +292,10 @@ class LoginAdapter:
# any existing state so it can properly respond to this. # any existing state so it can properly respond to this.
if self._implicit_login_state_dirty and self._on_app_loading_called: if self._implicit_login_state_dirty and self._on_app_loading_called:
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter sending implicit-state-changed to app.',
'LoginAdapter: %s adapter sending' self.login_type.name,
' implicit-state-changed to app.', )
self.login_type.name,
)
assert _babase.app.plus is not None assert _babase.app.plus is not None
_babase.pushcall( _babase.pushcall(
@ -331,12 +316,11 @@ class LoginAdapter:
self._implicit_login_state.login_id == self._active_login_id self._implicit_login_state.login_id == self._active_login_id
) )
if was_active != is_active: if was_active != is_active:
if DEBUG_LOG: logger.debug(
logging.debug( '%s adapter back-end-active is now %s.',
'LoginAdapter: %s adapter back-end-active is now %s.', self.login_type.name,
self.login_type.name, is_active,
is_active, )
)
self.on_back_end_active_change(is_active) self.on_back_end_active_change(is_active)
self._back_end_active = is_active self._back_end_active = is_active

View file

@ -279,7 +279,7 @@ class DirectoryScan:
except Exception: except Exception:
logging.exception("metascan: Error scanning '%s'.", subpath) logging.exception("metascan: Error scanning '%s'.", subpath)
# Sort our results # Sort our results.
for exportlist in self.results.exports.values(): for exportlist in self.results.exports.values():
exportlist.sort() exportlist.sort()
@ -327,7 +327,11 @@ class DirectoryScan:
meta_lines = { meta_lines = {
lnum: l[1:].split() lnum: l[1:].split()
for lnum, l in enumerate(flines) for lnum, l in enumerate(flines)
if '# ba_meta ' in l # Do a simple 'in' check for speed but then make sure its
# also at the beginning of the line. This allows disabling
# meta-lines and avoids false positives from code that
# wrangles them.
if ('# ba_meta' in l and l.strip().startswith('# ba_meta '))
} }
is_top_level = len(subpath.parts) <= 1 is_top_level = len(subpath.parts) <= 1
required_api = self._get_api_requirement( required_api = self._get_api_requirement(
@ -384,12 +388,16 @@ class DirectoryScan:
# meta_lines is just anything containing '# ba_meta '; make sure # meta_lines is just anything containing '# ba_meta '; make sure
# the ba_meta is in the right place. # the ba_meta is in the right place.
if mline[0] != 'ba_meta': if mline[0] != 'ba_meta':
logging.warning( # Make an exception for this specific file, otherwise we
'metascan: %s:%d: malformed ba_meta statement.', # get lots of warnings about ba_meta showing up in weird
subpath, # places here.
lindex + 1, if subpath.as_posix() != 'babase/_meta.py':
) logging.warning(
self.results.announce_errors_occurred = True 'metascan: %s:%d: malformed ba_meta statement.',
subpath,
lindex + 1,
)
self.results.announce_errors_occurred = True
elif ( elif (
len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api' len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api'
): ):

View file

@ -80,9 +80,9 @@ class UIScale(Enum):
readable from an average distance. readable from an average distance.
""" """
LARGE = 0 SMALL = 0
MEDIUM = 1 MEDIUM = 1
SMALL = 2 LARGE = 2
class Permission(Enum): class Permission(Enum):

View file

@ -33,7 +33,7 @@ class NetworkSubsystem:
# that a nearby server has been pinged. # that a nearby server has been pinged.
self.zone_pings: dict[str, float] = {} self.zone_pings: dict[str, float] = {}
# For debugging. # For debugging/progress.
self.v1_test_log: str = '' self.v1_test_log: str = ''
self.v1_ctest_results: dict[int, str] = {} self.v1_ctest_results: dict[int, str] = {}
self.connectivity_state = 'uninited' self.connectivity_state = 'uninited'

View file

@ -93,7 +93,7 @@ class PluginSubsystem(AppSubsystem):
# that weren't covered by the meta stuff above, either creating # that weren't covered by the meta stuff above, either creating
# plugin-specs for them or clearing them out. This covers # plugin-specs for them or clearing them out. This covers
# plugins with api versions not matching ours, plugins without # plugins with api versions not matching ours, plugins without
# ba_meta tags, and plugins that have since disappeared. # ba_*meta tags, and plugins that have since disappeared.
assert isinstance(plugstates, dict) assert isinstance(plugstates, dict)
wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules] wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules]

View file

@ -1,38 +1,49 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Classic ballistica components. """Components for the classic BombSquad experience.
This package is used as a 'dumping ground' for functionality that is This package/feature-set contains functionality related to the classic
necessary to keep legacy parts of the app working, but which may no BombSquad experience. Note that much legacy BombSquad code is still a
longer be the best way to do things going forward. bit tangled and thus this feature-set is largely inseperable from
scenev1 and uiv1. Future feature-sets will be designed in a more modular
New code should try to avoid using code from here when possible. way.
Functionality in this package should be exposed through the
ClassicSubsystem. This allows type-checked code to go through the
babase.app.classic singleton which forces it to explicitly handle the
possibility of babase.app.classic being None. When code instead imports
classic submodules directly, it is much harder to make it cleanly handle
classic not being present.
""" """
# ba_meta require api 8 # ba_meta require api 9
# Note: Code relying on classic should import things from here *only* # Note: Code relying on classic should import things from here *only*
# for type-checking and use the versions in app.classic at runtime; that # for type-checking and use the versions in ba*.app.classic at runtime;
# way type-checking will cleanly cover the classic-not-present case # that way type-checking will cleanly cover the classic-not-present case
# (app.classic being None). # (ba*.app.classic being None).
import logging import logging
from baclassic._subsystem import ClassicSubsystem from efro.util import set_canonical_module_names
from baclassic._appmode import ClassicAppMode
from baclassic._appsubsystem import ClassicAppSubsystem
from baclassic._achievement import Achievement, AchievementSubsystem from baclassic._achievement import Achievement, AchievementSubsystem
from baclassic._chest import (
ChestAppearanceDisplayInfo,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
CHEST_APPEARANCE_DISPLAY_INFOS,
)
from baclassic._displayitem import show_display_item
__all__ = [ __all__ = [
'ClassicSubsystem', 'ChestAppearanceDisplayInfo',
'CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT',
'CHEST_APPEARANCE_DISPLAY_INFOS',
'ClassicAppMode',
'ClassicAppSubsystem',
'Achievement', 'Achievement',
'AchievementSubsystem', 'AchievementSubsystem',
'show_display_item',
] ]
# We want stuff here to show up as packagename.Foo instead of
# packagename._submodule.Foo.
set_canonical_module_names(globals())
# Sanity check: we want to keep ballistica's dependencies and # Sanity check: we want to keep ballistica's dependencies and
# bootstrapping order clearly defined; let's check a few particular # bootstrapping order clearly defined; let's check a few particular
# modules to make sure they never directly or indirectly import us # modules to make sure they never directly or indirectly import us

View file

@ -125,7 +125,11 @@ class AccountV1Subsystem:
if subset is not None: if subset is not None:
raise ValueError('invalid subset value: ' + str(subset)) raise ValueError('invalid subset value: ' + str(subset))
if data['p']: # We used to give this bonus for pro, but on recent versions of
# the game give it for everyone (since we are phasing out Pro).
# if data['p']:
if bool(True):
if babase.app.plus is None: if babase.app.plus is None:
pro_mult = 1.0 pro_mult = 1.0
else: else:
@ -176,7 +180,9 @@ class AccountV1Subsystem:
else {} else {}
) )
for item_name, item in list(store_items.items()): for item_name, item in list(store_items.items()):
if item_name.startswith('icons.') and plus.get_purchased(item_name): if item_name.startswith(
'icons.'
) and plus.get_v1_account_product_purchased(item_name):
icons.append(item['icon']) icons.append(item['icon'])
return icons return icons
@ -227,13 +233,12 @@ class AccountV1Subsystem:
if plus is None: if plus is None:
return False return False
# Check our tickets-based pro upgrade and our two real-IAP based # Check various server-side purchases that mean we have pro.
# upgrades. Also always unlock this stuff in ballistica-core builds.
return bool( return bool(
plus.get_purchased('upgrades.pro') plus.get_v1_account_product_purchased('gold_pass')
or plus.get_purchased('static.pro') or plus.get_v1_account_product_purchased('upgrades.pro')
or plus.get_purchased('static.pro_sale') or plus.get_v1_account_product_purchased('static.pro')
or 'ballistica' + 'kit' == babase.appname() or plus.get_v1_account_product_purchased('static.pro_sale')
) )
def have_pro_options(self) -> bool: def have_pro_options(self) -> bool:
@ -247,10 +252,9 @@ class AccountV1Subsystem:
if plus is None: if plus is None:
return False return False
# We expose pro options if the server tells us to # We expose pro options if the server tells us to (which is
# (which is generally just when we own pro), # generally just when we own pro), or also if we've been
# or also if we've been grandfathered in # grandfathered in.
# or are using ballistica-core builds.
return self.have_pro() or bool( return self.have_pro() or bool(
plus.get_v1_account_misc_read_val_2('proOptionsUnlocked', False) plus.get_v1_account_misc_read_val_2('proOptionsUnlocked', False)
or babase.app.config.get('lc14292', 0) > 1 or babase.app.config.get('lc14292', 0) > 1

View file

@ -6,6 +6,11 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bacommon.bs import ClassicChestAppearance
from baclassic._chest import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
import babase import babase
import bascenev1 import bascenev1
import bauiv1 import bauiv1
@ -86,429 +91,311 @@ class AchievementSubsystem:
def _init_achievements(self) -> None: def _init_achievements(self) -> None:
"""Fill in available achievements.""" """Fill in available achievements."""
achs = self.achievements self.achievements += [
Achievement(
# 5 'In Control',
achs.append( 'achievementInControl',
Achievement('In Control', 'achievementInControl', (1, 1, 1), '', 5) (1, 1, 1),
) '',
# 15 award=5,
achs.append( ),
Achievement( Achievement(
'Sharing is Caring', 'Sharing is Caring',
'achievementSharingIsCaring', 'achievementSharingIsCaring',
(1, 1, 1), (1, 1, 1),
'', '',
15, award=15,
) ),
)
# 10
achs.append(
Achievement( Achievement(
'Dual Wielding', 'achievementDualWielding', (1, 1, 1), '', 10 'Dual Wielding',
) 'achievementDualWielding',
) (1, 1, 1),
'',
# 10 award=10,
achs.append( ),
Achievement( Achievement(
'Free Loader', 'achievementFreeLoader', (1, 1, 1), '', 10 'Free Loader',
) 'achievementFreeLoader',
) (1, 1, 1),
# 20 '',
achs.append( award=10,
),
Achievement( Achievement(
'Team Player', 'achievementTeamPlayer', (1, 1, 1), '', 20 'Team Player',
) 'achievementTeamPlayer',
) (1, 1, 1),
'',
# 5 award=20,
achs.append( ),
Achievement( Achievement(
'Onslaught Training Victory', 'Onslaught Training Victory',
'achievementOnslaught', 'achievementOnslaught',
(1, 1, 1), (1, 1, 1),
'Default:Onslaught Training', 'Default:Onslaught Training',
5, award=5,
) ),
)
# 5
achs.append(
Achievement( Achievement(
'Off You Go Then', 'Off You Go Then',
'achievementOffYouGo', 'achievementOffYouGo',
(1, 1.1, 1.3), (1, 1.1, 1.3),
'Default:Onslaught Training', 'Default:Onslaught Training',
5, award=5,
) ),
)
# 10
achs.append(
Achievement( Achievement(
'Boxer', 'Boxer',
'achievementBoxer', 'achievementBoxer',
(1, 0.6, 0.6), (1, 0.6, 0.6),
'Default:Onslaught Training', 'Default:Onslaught Training',
10, award=10,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 10
achs.append(
Achievement( Achievement(
'Rookie Onslaught Victory', 'Rookie Onslaught Victory',
'achievementOnslaught', 'achievementOnslaught',
(0.5, 1.4, 0.6), (0.5, 1.4, 0.6),
'Default:Rookie Onslaught', 'Default:Rookie Onslaught',
10, award=10,
) ),
)
# 10
achs.append(
Achievement( Achievement(
'Mine Games', 'Mine Games',
'achievementMine', 'achievementMine',
(1, 1, 1.4), (1, 1, 1.4),
'Default:Rookie Onslaught', 'Default:Rookie Onslaught',
10, award=10,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Flawless Victory', 'Flawless Victory',
'achievementFlawlessVictory', 'achievementFlawlessVictory',
(1, 1, 1), (1, 1, 1),
'Default:Rookie Onslaught', 'Default:Rookie Onslaught',
15, award=15,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 10
achs.append(
Achievement( Achievement(
'Rookie Football Victory', 'Rookie Football Victory',
'achievementFootballVictory', 'achievementFootballVictory',
(1.0, 1, 0.6), (1.0, 1, 0.6),
'Default:Rookie Football', 'Default:Rookie Football',
10, award=10,
) ),
)
# 10
achs.append(
Achievement( Achievement(
'Super Punch', 'Super Punch',
'achievementSuperPunch', 'achievementSuperPunch',
(1, 1, 1.8), (1, 1, 1.8),
'Default:Rookie Football', 'Default:Rookie Football',
10, award=10,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Rookie Football Shutout', 'Rookie Football Shutout',
'achievementFootballShutout', 'achievementFootballShutout',
(1, 1, 1), (1, 1, 1),
'Default:Rookie Football', 'Default:Rookie Football',
15, award=15,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Pro Onslaught Victory', 'Pro Onslaught Victory',
'achievementOnslaught', 'achievementOnslaught',
(0.3, 1, 2.0), (0.3, 1, 2.0),
'Default:Pro Onslaught', 'Default:Pro Onslaught',
15, award=15,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Boom Goes the Dynamite', 'Boom Goes the Dynamite',
'achievementTNT', 'achievementTNT',
(1.4, 1.2, 0.8), (1.4, 1.2, 0.8),
'Default:Pro Onslaught', 'Default:Pro Onslaught',
15, award=15,
) ),
)
# 20
achs.append(
Achievement( Achievement(
'Pro Boxer', 'Pro Boxer',
'achievementBoxer', 'achievementBoxer',
(2, 2, 0), (2, 2, 0),
'Default:Pro Onslaught', 'Default:Pro Onslaught',
20, award=20,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Pro Football Victory', 'Pro Football Victory',
'achievementFootballVictory', 'achievementFootballVictory',
(1.3, 1.3, 2.0), (1.3, 1.3, 2.0),
'Default:Pro Football', 'Default:Pro Football',
15, award=15,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Super Mega Punch', 'Super Mega Punch',
'achievementSuperPunch', 'achievementSuperPunch',
(2, 1, 0.6), (2, 1, 0.6),
'Default:Pro Football', 'Default:Pro Football',
15, award=15,
) ),
)
# 20
achs.append(
Achievement( Achievement(
'Pro Football Shutout', 'Pro Football Shutout',
'achievementFootballShutout', 'achievementFootballShutout',
(0.7, 0.7, 2.0), (0.7, 0.7, 2.0),
'Default:Pro Football', 'Default:Pro Football',
20, award=20,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Pro Runaround Victory', 'Pro Runaround Victory',
'achievementRunaround', 'achievementRunaround',
(1, 1, 1), (1, 1, 1),
'Default:Pro Runaround', 'Default:Pro Runaround',
15, award=15,
) ),
)
# 20
achs.append(
Achievement( Achievement(
'Precision Bombing', 'Precision Bombing',
'achievementCrossHair', 'achievementCrossHair',
(1, 1, 1.3), (1, 1, 1.3),
'Default:Pro Runaround', 'Default:Pro Runaround',
20, award=20,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 25
achs.append(
Achievement( Achievement(
'The Wall', 'The Wall',
'achievementWall', 'achievementWall',
(1, 0.7, 0.7), (1, 0.7, 0.7),
'Default:Pro Runaround', 'Default:Pro Runaround',
25, award=25,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Uber Onslaught Victory', 'Uber Onslaught Victory',
'achievementOnslaught', 'achievementOnslaught',
(2, 2, 1), (2, 2, 1),
'Default:Uber Onslaught', 'Default:Uber Onslaught',
30, award=30,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Gold Miner', 'Gold Miner',
'achievementMine', 'achievementMine',
(2, 1.6, 0.2), (2, 1.6, 0.2),
'Default:Uber Onslaught', 'Default:Uber Onslaught',
30, award=30,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'TNT Terror', 'TNT Terror',
'achievementTNT', 'achievementTNT',
(2, 1.8, 0.3), (2, 1.8, 0.3),
'Default:Uber Onslaught', 'Default:Uber Onslaught',
30, award=30,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Uber Football Victory', 'Uber Football Victory',
'achievementFootballVictory', 'achievementFootballVictory',
(1.8, 1.4, 0.3), (1.8, 1.4, 0.3),
'Default:Uber Football', 'Default:Uber Football',
30, award=30,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Got the Moves', 'Got the Moves',
'achievementGotTheMoves', 'achievementGotTheMoves',
(2, 1, 0), (2, 1, 0),
'Default:Uber Football', 'Default:Uber Football',
30, award=30,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 40
achs.append(
Achievement( Achievement(
'Uber Football Shutout', 'Uber Football Shutout',
'achievementFootballShutout', 'achievementFootballShutout',
(2, 2, 0), (2, 2, 0),
'Default:Uber Football', 'Default:Uber Football',
40, award=40,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Uber Runaround Victory', 'Uber Runaround Victory',
'achievementRunaround', 'achievementRunaround',
(1.5, 1.2, 0.2), (1.5, 1.2, 0.2),
'Default:Uber Runaround', 'Default:Uber Runaround',
30, award=30,
) ),
)
# 40
achs.append(
Achievement( Achievement(
'The Great Wall', 'The Great Wall',
'achievementWall', 'achievementWall',
(2, 1.7, 0.4), (2, 1.7, 0.4),
'Default:Uber Runaround', 'Default:Uber Runaround',
40, award=40,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 40
achs.append(
Achievement( Achievement(
'Stayin\' Alive', 'Stayin\' Alive',
'achievementStayinAlive', 'achievementStayinAlive',
(2, 2, 1), (2, 2, 1),
'Default:Uber Runaround', 'Default:Uber Runaround',
40, award=40,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 20
achs.append(
Achievement( Achievement(
'Last Stand Master', 'Last Stand Master',
'achievementMedalSmall', 'achievementMedalSmall',
(2, 1.5, 0.3), (2, 1.5, 0.3),
'Default:The Last Stand', 'Default:The Last Stand',
20, award=20,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 40
achs.append(
Achievement( Achievement(
'Last Stand Wizard', 'Last Stand Wizard',
'achievementMedalMedium', 'achievementMedalMedium',
(2, 1.5, 0.3), (2, 1.5, 0.3),
'Default:The Last Stand', 'Default:The Last Stand',
40, award=40,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 60
achs.append(
Achievement( Achievement(
'Last Stand God', 'Last Stand God',
'achievementMedalLarge', 'achievementMedalLarge',
(2, 1.5, 0.3), (2, 1.5, 0.3),
'Default:The Last Stand', 'Default:The Last Stand',
60, award=60,
hard_mode_only=True, hard_mode_only=True,
) ),
)
# 5
achs.append(
Achievement( Achievement(
'Onslaught Master', 'Onslaught Master',
'achievementMedalSmall', 'achievementMedalSmall',
(0.7, 1, 0.7), (0.7, 1, 0.7),
'Challenges:Infinite Onslaught', 'Challenges:Infinite Onslaught',
5, award=5,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Onslaught Wizard', 'Onslaught Wizard',
'achievementMedalMedium', 'achievementMedalMedium',
(0.7, 1.0, 0.7), (0.7, 1.0, 0.7),
'Challenges:Infinite Onslaught', 'Challenges:Infinite Onslaught',
15, award=15,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Onslaught God', 'Onslaught God',
'achievementMedalLarge', 'achievementMedalLarge',
(0.7, 1.0, 0.7), (0.7, 1.0, 0.7),
'Challenges:Infinite Onslaught', 'Challenges:Infinite Onslaught',
30, award=30,
) ),
)
# 5
achs.append(
Achievement( Achievement(
'Runaround Master', 'Runaround Master',
'achievementMedalSmall', 'achievementMedalSmall',
(1.0, 1.0, 1.2), (1.0, 1.0, 1.2),
'Challenges:Infinite Runaround', 'Challenges:Infinite Runaround',
5, award=5,
) ),
)
# 15
achs.append(
Achievement( Achievement(
'Runaround Wizard', 'Runaround Wizard',
'achievementMedalMedium', 'achievementMedalMedium',
(1.0, 1.0, 1.2), (1.0, 1.0, 1.2),
'Challenges:Infinite Runaround', 'Challenges:Infinite Runaround',
15, award=15,
) ),
)
# 30
achs.append(
Achievement( Achievement(
'Runaround God', 'Runaround God',
'achievementMedalLarge', 'achievementMedalLarge',
(1.0, 1.0, 1.2), (1.0, 1.0, 1.2),
'Challenges:Infinite Runaround', 'Challenges:Infinite Runaround',
30, award=30,
) ),
) ]
def award_local_achievement(self, achname: str) -> None: def award_local_achievement(self, achname: str) -> None:
"""For non-game-based achievements such as controller-connection.""" """For non-game-based achievements such as controller-connection."""
@ -652,14 +539,16 @@ class Achievement:
self, self,
name: str, name: str,
icon_name: str, icon_name: str,
icon_color: Sequence[float], icon_color: tuple[float, float, float],
level_name: str, level_name: str,
*,
award: int, award: int,
hard_mode_only: bool = False, hard_mode_only: bool = False,
): ):
self._name = name self._name = name
self._icon_name = icon_name self._icon_name = icon_name
self._icon_color: Sequence[float] = list(icon_color) + [1] assert len(icon_color) == 3
self._icon_color = icon_color + (1.0,)
self._level_name = level_name self._level_name = level_name
self._completion_banner_slot: int | None = None self._completion_banner_slot: int | None = None
self._award = award self._award = award
@ -842,16 +731,48 @@ class Achievement:
], ],
) )
def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int: def get_award_chest_type(self) -> ClassicChestAppearance:
"""Get the ticket award value for this achievement.""" """Return the type of chest given for this achievement."""
# For now just map our old ticket values to chest types.
# Can add distinct values if need be later.
plus = babase.app.plus plus = babase.app.plus
if plus is None: assert plus is not None
return 0 t = plus.get_v1_account_misc_read_val(
val: int = plus.get_v1_account_misc_read_val( f'achAward.{self.name}', self._award
'achAward.' + self._name, self._award )
) * _get_ach_mult(include_pro_bonus) return (
assert isinstance(val, int) ClassicChestAppearance.L6
return val if t >= 30
else (
ClassicChestAppearance.L5
if t >= 25
else (
ClassicChestAppearance.L4
if t >= 20
else (
ClassicChestAppearance.L3
if t >= 15
else (
ClassicChestAppearance.L2
if t >= 10
else ClassicChestAppearance.L1
)
)
)
)
)
# def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
# """Get the ticket award value for this achievement."""
# plus = babase.app.plus
# if plus is None:
# return 0
# val: int = plus.get_v1_account_misc_read_val(
# 'achAward.' + self._name, self._award
# ) * _get_ach_mult(include_pro_bonus)
# assert isinstance(val, int)
# return val
@property @property
def power_ranking_value(self) -> int: def power_ranking_value(self) -> int:
@ -870,6 +791,7 @@ class Achievement:
x: float, x: float,
y: float, y: float,
delay: float, delay: float,
*,
outdelay: float | None = None, outdelay: float | None = None,
color: Sequence[float] | None = None, color: Sequence[float] | None = None,
style: str = 'post_game', style: str = 'post_game',
@ -1015,42 +937,72 @@ class Achievement:
txtactor.node.rotate = 10 txtactor.node.rotate = 10
objs.append(txtactor) objs.append(txtactor)
# Ticket-award. # Chest award.
award_x = -100 award_x = -100
objs.append( chesttype = self.get_award_chest_type()
Text( chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
babase.charstr(babase.SpecialChar.TICKET), chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
host_only=True,
position=(x + award_x + 33, y + 7),
transition=Text.Transition.FADE_IN,
scale=1.5,
h_attach=h_attach,
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(1, 1, 1, 0.2 if hmo else 0.4),
transition_delay=delay + 0.05,
transition_out_delay=out_delay_fin,
).autoretain()
) )
objs.append( objs.append(
Text( Image(
'+' + str(self.get_award_ticket_value()), # Provide magical extended dict version of texture
host_only=True, # that Image actor supports.
position=(x + award_x + 28, y + 16), texture={
transition=Text.Transition.FADE_IN, 'texture': bascenev1.gettexture(
scale=0.7, chestdisplayinfo.texclosed
flatness=1, ),
h_attach=h_attach, 'tint_texture': bascenev1.gettexture(
v_attach=v_attach, chestdisplayinfo.texclosedtint
h_align=Text.HAlign.CENTER, ),
v_align=Text.VAlign.CENTER, 'tint_color': chestdisplayinfo.tint,
color=cl2, 'tint2_color': chestdisplayinfo.tint2,
'mask_texture': None,
},
color=chestdisplayinfo.color + (0.5 if hmo else 1.0,),
position=(x + award_x + 37, y + 12),
scale=(32.0, 32.0),
transition=Image.Transition.FADE_IN,
transition_delay=delay + 0.05, transition_delay=delay + 0.05,
transition_out_delay=out_delay_fin, transition_out_delay=out_delay_fin,
host_only=True,
attach=Image.Attach.TOP_LEFT,
).autoretain() ).autoretain()
) )
# objs.append(
# Text(
# babase.charstr(babase.SpecialChar.TICKET),
# host_only=True,
# position=(x + award_x + 33, y + 7),
# transition=Text.Transition.FADE_IN,
# scale=1.5,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=(1, 1, 1, 0.2 if hmo else 0.4),
# transition_delay=delay + 0.05,
# transition_out_delay=out_delay_fin,
# ).autoretain()
# )
# objs.append(
# Text(
# '+' + str(self.get_award_ticket_value()),
# host_only=True,
# position=(x + award_x + 28, y + 16),
# transition=Text.Transition.FADE_IN,
# scale=0.7,
# flatness=1,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=cl2,
# transition_delay=delay + 0.05,
# transition_out_delay=out_delay_fin,
# ).autoretain()
# )
else: else:
complete = self.complete complete = self.complete
objs = [] objs = []
@ -1092,40 +1044,71 @@ class Achievement:
else: else:
if not complete: if not complete:
award_x = -100 award_x = -100
objs.append( # objs.append(
Text( # Text(
babase.charstr(babase.SpecialChar.TICKET), # babase.charstr(babase.SpecialChar.TICKET),
host_only=True, # host_only=True,
position=(x + award_x + 33, y + 7), # position=(x + award_x + 33, y + 7),
transition=Text.Transition.IN_RIGHT, # transition=Text.Transition.IN_RIGHT,
scale=1.5, # scale=1.5,
h_attach=h_attach, # h_attach=h_attach,
v_attach=v_attach, # v_attach=v_attach,
h_align=Text.HAlign.CENTER, # h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER, # v_align=Text.VAlign.CENTER,
color=(1, 1, 1, (0.1 if hmo else 0.2)), # color=(1, 1, 1, (0.1 if hmo else 0.2)),
transition_delay=delay + 0.05, # transition_delay=delay + 0.05,
transition_out_delay=None, # transition_out_delay=None,
).autoretain() # ).autoretain()
# )
chesttype = self.get_award_chest_type()
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
) )
objs.append( objs.append(
Text( Image(
'+' + str(self.get_award_ticket_value()), # Provide magical extended dict version of texture
host_only=True, # that Image actor supports.
position=(x + award_x + 28, y + 16), texture={
transition=Text.Transition.IN_RIGHT, 'texture': bascenev1.gettexture(
scale=0.7, chestdisplayinfo.texclosed
flatness=1, ),
h_attach=h_attach, 'tint_texture': bascenev1.gettexture(
v_attach=v_attach, chestdisplayinfo.texclosedtint
h_align=Text.HAlign.CENTER, ),
v_align=Text.VAlign.CENTER, 'tint_color': chestdisplayinfo.tint,
color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)), 'tint2_color': chestdisplayinfo.tint2,
'mask_texture': None,
},
color=chestdisplayinfo.color
+ (0.5 if hmo else 1.0,),
position=(x + award_x + 38, y + 14),
scale=(32.0, 32.0),
transition=Image.Transition.IN_RIGHT,
transition_delay=delay + 0.05, transition_delay=delay + 0.05,
transition_out_delay=None, transition_out_delay=None,
host_only=True,
attach=attach,
).autoretain() ).autoretain()
) )
# objs.append(
# Text(
# '+' + str(self.get_award_ticket_value()),
# host_only=True,
# position=(x + award_x + 28, y + 16),
# transition=Text.Transition.IN_RIGHT,
# scale=0.7,
# flatness=1,
# h_attach=h_attach,
# v_attach=v_attach,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
# transition_delay=delay + 0.05,
# transition_out_delay=None,
# ).autoretain()
# )
# Show 'hard-mode-only' only over incomplete achievements # Show 'hard-mode-only' only over incomplete achievements
# when that's the case. # when that's the case.
if hmo: if hmo:
@ -1457,68 +1440,70 @@ class Achievement:
assert objt.node assert objt.node
objt.node.host_only = True objt.node.host_only = True
objt = Text( # objt = Text(
babase.charstr(babase.SpecialChar.TICKET), # babase.charstr(babase.SpecialChar.TICKET),
position=(-120 - 170 + 5, 75 + y_offs - 20), # position=(-120 - 170 + 5, 75 + y_offs - 20),
front=True, # front=True,
v_attach=Text.VAttach.BOTTOM, # v_attach=Text.VAttach.BOTTOM,
h_align=Text.HAlign.CENTER, # h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER, # v_align=Text.VAlign.CENTER,
transition=Text.Transition.IN_BOTTOM, # transition=Text.Transition.IN_BOTTOM,
vr_depth=base_vr_depth, # vr_depth=base_vr_depth,
transition_delay=in_time, # transition_delay=in_time,
transition_out_delay=out_time, # transition_out_delay=out_time,
flash=True, # flash=True,
color=(0.5, 0.5, 0.5, 1), # color=(0.5, 0.5, 0.5, 1),
scale=3.0, # scale=3.0,
).autoretain() # ).autoretain()
objs.append(objt) # objs.append(objt)
assert objt.node # assert objt.node
objt.node.host_only = True # objt.node.host_only = True
# print('FIXME SHOW ACH CHEST3')
# objt = Text(
# '+' + str(self.get_award_ticket_value()),
# position=(-120 - 180 + 5, 80 + y_offs - 20),
# v_attach=Text.VAttach.BOTTOM,
# front=True,
# h_align=Text.HAlign.CENTER,
# v_align=Text.VAlign.CENTER,
# transition=Text.Transition.IN_BOTTOM,
# vr_depth=base_vr_depth,
# flatness=0.5,
# shadow=1.0,
# transition_delay=in_time,
# transition_out_delay=out_time,
# flash=True,
# color=(0, 1, 0, 1),
# scale=1.5,
# ).autoretain()
# objs.append(objt)
objt = Text(
'+' + str(self.get_award_ticket_value()),
position=(-120 - 180 + 5, 80 + y_offs - 20),
v_attach=Text.VAttach.BOTTOM,
front=True,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
transition=Text.Transition.IN_BOTTOM,
vr_depth=base_vr_depth,
flatness=0.5,
shadow=1.0,
transition_delay=in_time,
transition_out_delay=out_time,
flash=True,
color=(0, 1, 0, 1),
scale=1.5,
).autoretain()
objs.append(objt)
assert objt.node assert objt.node
objt.node.host_only = True objt.node.host_only = True
# Add the 'x 2' if we've got pro. # Add the 'x 2' if we've got pro.
if app.classic.accounts.have_pro(): # if app.classic.accounts.have_pro():
objt = Text( # objt = Text(
'x 2', # 'x 2',
position=(-120 - 180 + 45, 80 + y_offs - 50), # position=(-120 - 180 + 45, 80 + y_offs - 50),
v_attach=Text.VAttach.BOTTOM, # v_attach=Text.VAttach.BOTTOM,
front=True, # front=True,
h_align=Text.HAlign.CENTER, # h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER, # v_align=Text.VAlign.CENTER,
transition=Text.Transition.IN_BOTTOM, # transition=Text.Transition.IN_BOTTOM,
vr_depth=base_vr_depth, # vr_depth=base_vr_depth,
flatness=0.5, # flatness=0.5,
shadow=1.0, # shadow=1.0,
transition_delay=in_time, # transition_delay=in_time,
transition_out_delay=out_time, # transition_out_delay=out_time,
flash=True, # flash=True,
color=(0.4, 0, 1, 1), # color=(0.4, 0, 1, 1),
scale=0.9, # scale=0.9,
).autoretain() # ).autoretain()
objs.append(objt) # objs.append(objt)
assert objt.node # assert objt.node
objt.node.host_only = True # objt.node.host_only = True
objt = Text( objt = Text(
self.description_complete, self.description_complete,

View file

@ -48,19 +48,7 @@ class AdsSubsystem:
1.0, 1.0,
lambda: babase.screenmessage( lambda: babase.screenmessage(
babase.Lstr( babase.Lstr(
resource='removeInGameAdsText', resource='removeInGameAdsTokenPurchaseText'
subs=[
(
'${PRO}',
babase.Lstr(
resource='store.bombSquadProNameText'
),
),
(
'${APP_NAME}',
babase.Lstr(resource='titleText'),
),
],
), ),
color=(1, 1, 0), color=(1, 1, 0),
), ),
@ -100,8 +88,14 @@ class AdsSubsystem:
# No ads without net-connections, etc. # No ads without net-connections, etc.
if not plus.can_show_ad(): if not plus.can_show_ad():
show = False show = False
if classic.accounts.have_pro():
show = False # Pro disables interstitials. # Pro or other upgrades disable interstitials.
if (
classic.accounts.have_pro()
or classic.gold_pass
or classic.remove_ads
):
show = False
try: try:
session = bascenev1.get_foreground_host_session() session = bascenev1.get_foreground_host_session()
assert session is not None assert session is not None

View file

@ -1,46 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Defines AppDelegate class for handling high level app functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import babase
if TYPE_CHECKING:
from typing import Callable
import bascenev1
class AppDelegate:
"""Defines handlers for high level app functionality.
Category: App Classes
"""
def create_default_game_settings_ui(
self,
gameclass: type[bascenev1.GameActivity],
sessiontype: type[bascenev1.Session],
settings: dict | None,
completion_call: Callable[[dict | None], None],
) -> None:
"""Launch a UI to configure the given game config.
It should manipulate the contents of config and call completion_call
when done.
"""
# Replace the main window once we come up successfully.
from bauiv1lib.playlist.editgame import PlaylistEditGameWindow
assert babase.app.classic is not None
babase.app.ui_v1.clear_main_menu_window(transition='out_left')
babase.app.ui_v1.set_main_menu_window(
PlaylistEditGameWindow(
gameclass,
sessiontype,
settings,
completion_call=completion_call,
).get_root_widget(),
from_window=False, # Disable check since we don't know.
)

View file

@ -0,0 +1,658 @@
# Released under the MIT License. See LICENSE for details.
#
"""Contains ClassicAppMode."""
from __future__ import annotations
import os
import logging
from functools import partial
from typing import TYPE_CHECKING, override
from bacommon.app import AppExperience
import babase
import bauiv1
from bauiv1lib.connectivity import wait_for_connectivity
from bauiv1lib.account.signin import show_sign_in_prompt
import _baclassic
if TYPE_CHECKING:
from typing import Callable, Any, Literal
from efro.call import CallbackRegistration
import bacommon.cloud
from bauiv1lib.chest import ChestWindow
# ba_meta export babase.AppMode
class ClassicAppMode(babase.AppMode):
"""AppMode for the classic BombSquad experience."""
def __init__(self) -> None:
self._on_primary_account_changed_callback: (
CallbackRegistration | None
) = None
self._on_connectivity_changed_callback: CallbackRegistration | None = (
None
)
self._test_sub: babase.CloudSubscription | None = None
self._account_data_sub: babase.CloudSubscription | None = None
self._have_account_values = False
self._have_connectivity = False
@override
@classmethod
def get_app_experience(cls) -> AppExperience:
return AppExperience.MELEE
@override
@classmethod
def _can_handle_intent(cls, intent: babase.AppIntent) -> bool:
# We support default and exec intents currently.
return isinstance(
intent, babase.AppIntentExec | babase.AppIntentDefault
)
@override
def handle_intent(self, intent: babase.AppIntent) -> None:
if isinstance(intent, babase.AppIntentExec):
_baclassic.classic_app_mode_handle_app_intent_exec(intent.code)
return
assert isinstance(intent, babase.AppIntentDefault)
_baclassic.classic_app_mode_handle_app_intent_default()
@override
def on_activate(self) -> None:
# Let the native layer do its thing.
_baclassic.classic_app_mode_activate()
app = babase.app
plus = app.plus
assert plus is not None
# Wire up the root ui to do what we want.
ui = app.ui_v1
ui.root_ui_calls[ui.RootUIElement.ACCOUNT_BUTTON] = (
self._root_ui_account_press
)
ui.root_ui_calls[ui.RootUIElement.MENU_BUTTON] = (
self._root_ui_menu_press
)
ui.root_ui_calls[ui.RootUIElement.SQUAD_BUTTON] = (
self._root_ui_squad_press
)
ui.root_ui_calls[ui.RootUIElement.SETTINGS_BUTTON] = (
self._root_ui_settings_press
)
ui.root_ui_calls[ui.RootUIElement.STORE_BUTTON] = (
self._root_ui_store_press
)
ui.root_ui_calls[ui.RootUIElement.INVENTORY_BUTTON] = (
self._root_ui_inventory_press
)
ui.root_ui_calls[ui.RootUIElement.GET_TOKENS_BUTTON] = (
self._root_ui_get_tokens_press
)
ui.root_ui_calls[ui.RootUIElement.INBOX_BUTTON] = (
self._root_ui_inbox_press
)
ui.root_ui_calls[ui.RootUIElement.TICKETS_METER] = (
self._root_ui_tickets_meter_press
)
ui.root_ui_calls[ui.RootUIElement.TOKENS_METER] = (
self._root_ui_tokens_meter_press
)
ui.root_ui_calls[ui.RootUIElement.TROPHY_METER] = (
self._root_ui_trophy_meter_press
)
ui.root_ui_calls[ui.RootUIElement.LEVEL_METER] = (
self._root_ui_level_meter_press
)
ui.root_ui_calls[ui.RootUIElement.ACHIEVEMENTS_BUTTON] = (
self._root_ui_achievements_press
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_0] = partial(
self._root_ui_chest_slot_pressed, 0
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_1] = partial(
self._root_ui_chest_slot_pressed, 1
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_2] = partial(
self._root_ui_chest_slot_pressed, 2
)
ui.root_ui_calls[ui.RootUIElement.CHEST_SLOT_3] = partial(
self._root_ui_chest_slot_pressed, 3
)
# We want to be informed when connectivity changes.
self._on_connectivity_changed_callback = (
plus.cloud.on_connectivity_changed_callbacks.register(
self._update_for_connectivity_change
)
)
# We want to be informed when primary account changes.
self._on_primary_account_changed_callback = (
plus.accounts.on_primary_account_changed_callbacks.register(
self._update_for_primary_account
)
)
# Establish subscriptions/etc. for any current primary account.
self._update_for_primary_account(plus.accounts.primary)
self._have_connectivity = plus.cloud.is_connected()
self._update_for_connectivity_change(self._have_connectivity)
@override
def on_deactivate(self) -> None:
classic = babase.app.classic
# Stop being informed of account changes.
self._on_primary_account_changed_callback = None
# Remove anything following any current account.
self._update_for_primary_account(None)
# Save where we were in the UI so we return there next time.
if classic is not None:
classic.save_ui_state()
# Let the native layer do its thing.
_baclassic.classic_app_mode_deactivate()
@override
def on_app_active_changed(self) -> None:
# If we've gone inactive, bring up the main menu, which has the
# side effect of pausing the action (when possible).
if not babase.app.active:
babase.invoke_main_menu()
def _update_for_primary_account(
self, account: babase.AccountV2Handle | None
) -> None:
"""Update subscriptions/etc. for a new primary account state."""
assert babase.in_logic_thread()
plus = babase.app.plus
assert plus is not None
classic = babase.app.classic
assert classic is not None
if account is not None:
babase.set_ui_account_state(True, account.tag)
else:
babase.set_ui_account_state(False)
# For testing subscription functionality.
if os.environ.get('BA_SUBSCRIPTION_TEST') == '1':
if account is None:
self._test_sub = None
else:
with account:
self._test_sub = plus.cloud.subscribe_test(
self._on_sub_test_update
)
else:
self._test_sub = None
if account is None:
classic.gold_pass = False
classic.chest_dock_full = False
classic.remove_ads = False
self._account_data_sub = None
_baclassic.set_root_ui_account_values(
tickets=-1,
tokens=-1,
league_rank=-1,
league_type='',
achievements_percent_text='',
level_text='',
xp_text='',
inbox_count_text='',
gold_pass=False,
chest_0_appearance='',
chest_1_appearance='',
chest_2_appearance='',
chest_3_appearance='',
chest_0_unlock_time=-1.0,
chest_1_unlock_time=-1.0,
chest_2_unlock_time=-1.0,
chest_3_unlock_time=-1.0,
chest_0_ad_allow_time=-1.0,
chest_1_ad_allow_time=-1.0,
chest_2_ad_allow_time=-1.0,
chest_3_ad_allow_time=-1.0,
)
self._have_account_values = False
self._update_ui_live_state()
else:
with account:
self._account_data_sub = (
plus.cloud.subscribe_classic_account_data(
self._on_classic_account_data_change
)
)
def _update_for_connectivity_change(self, connected: bool) -> None:
"""Update when the app's connectivity state changes."""
self._have_connectivity = connected
self._update_ui_live_state()
def _update_ui_live_state(self) -> None:
# We want to show ui elements faded if we don't have a live
# connection to the master-server OR if we haven't received a
# set of account values from them yet. If we just plug in raw
# connectivity state here we get UI stuff un-fading a moment or
# two before values appear (since the subscriptions have not
# sent us any values yet) which looks odd.
_baclassic.set_root_ui_have_live_values(
self._have_connectivity and self._have_account_values
)
def _on_sub_test_update(self, val: int | None) -> None:
print(f'GOT SUB TEST UPDATE: {val}')
def _on_classic_account_data_change(
self, val: bacommon.bs.ClassicAccountLiveData
) -> None:
# print('ACCOUNT CHANGED:', val)
achp = round(val.achievements / max(val.achievements_total, 1) * 100.0)
ibc = str(val.inbox_count)
if val.inbox_count_is_max:
ibc += '+'
chest0 = val.chests.get('0')
chest1 = val.chests.get('1')
chest2 = val.chests.get('2')
chest3 = val.chests.get('3')
# Keep a few handy values on classic updated with the latest
# data.
classic = babase.app.classic
assert classic is not None
classic.remove_ads = val.remove_ads
classic.gold_pass = val.gold_pass
classic.chest_dock_full = (
chest0 is not None
and chest1 is not None
and chest2 is not None
and chest3 is not None
)
_baclassic.set_root_ui_account_values(
tickets=val.tickets,
tokens=val.tokens,
league_rank=(-1 if val.league_rank is None else val.league_rank),
league_type=(
'' if val.league_type is None else val.league_type.value
),
achievements_percent_text=f'{achp}%',
level_text=str(val.level),
xp_text=f'{val.xp}/{val.xpmax}',
inbox_count_text=ibc,
gold_pass=val.gold_pass,
chest_0_appearance=(
'' if chest0 is None else chest0.appearance.value
),
chest_1_appearance=(
'' if chest1 is None else chest1.appearance.value
),
chest_2_appearance=(
'' if chest2 is None else chest2.appearance.value
),
chest_3_appearance=(
'' if chest3 is None else chest3.appearance.value
),
chest_0_unlock_time=(
-1.0 if chest0 is None else chest0.unlock_time.timestamp()
),
chest_1_unlock_time=(
-1.0 if chest1 is None else chest1.unlock_time.timestamp()
),
chest_2_unlock_time=(
-1.0 if chest2 is None else chest2.unlock_time.timestamp()
),
chest_3_unlock_time=(
-1.0 if chest3 is None else chest3.unlock_time.timestamp()
),
chest_0_ad_allow_time=(
-1.0
if chest0 is None or chest0.ad_allow_time is None
else chest0.ad_allow_time.timestamp()
),
chest_1_ad_allow_time=(
-1.0
if chest1 is None or chest1.ad_allow_time is None
else chest1.ad_allow_time.timestamp()
),
chest_2_ad_allow_time=(
-1.0
if chest2 is None or chest2.ad_allow_time is None
else chest2.ad_allow_time.timestamp()
),
chest_3_ad_allow_time=(
-1.0
if chest3 is None or chest3.ad_allow_time is None
else chest3.ad_allow_time.timestamp()
),
)
# Note that we have values and updated faded state accordingly.
self._have_account_values = True
self._update_ui_live_state()
def _root_ui_menu_press(self) -> None:
from babase import push_back_press
ui = babase.app.ui_v1
# If *any* main-window is up, kill it and resume play.
old_window = ui.get_main_window()
if old_window is not None:
classic = babase.app.classic
assert classic is not None
classic.resume()
ui.clear_main_window()
return
# Otherwise
push_back_press()
def _root_ui_account_press(self) -> None:
from bauiv1lib.account.settings import AccountSettingsWindow
self._auxiliary_window_nav(
win_type=AccountSettingsWindow,
win_create_call=lambda: AccountSettingsWindow(
origin_widget=bauiv1.get_special_widget('account_button')
),
)
def _root_ui_squad_press(self) -> None:
btn = bauiv1.get_special_widget('squad_button')
center = btn.get_screen_space_center()
if bauiv1.app.classic is not None:
bauiv1.app.classic.party_icon_activate(center)
else:
logging.warning('party_icon_activate: no classic.')
def _root_ui_settings_press(self) -> None:
from bauiv1lib.settings.allsettings import AllSettingsWindow
self._auxiliary_window_nav(
win_type=AllSettingsWindow,
win_create_call=lambda: AllSettingsWindow(
origin_widget=bauiv1.get_special_widget('settings_button')
),
)
def _auxiliary_window_nav(
self,
win_type: type[bauiv1.MainWindow],
win_create_call: Callable[[], bauiv1.MainWindow],
) -> None:
"""Navigate to or away from an Auxiliary window.
Auxiliary windows can be thought of as 'side quests' in the
window hierarchy; places such as settings windows or league
ranking windows that the user might want to visit without losing
their place in the regular hierarchy.
"""
# pylint: disable=unidiomatic-typecheck
ui = babase.app.ui_v1
current_main_window = ui.get_main_window()
# Scan our ancestors for auxiliary states matching our type as
# well as auxiliary states in general.
aux_matching_state: bauiv1.MainWindowState | None = None
aux_state: bauiv1.MainWindowState | None = None
if current_main_window is None:
raise RuntimeError(
'Not currently handling no-top-level-window case.'
)
state = current_main_window.main_window_back_state
while state is not None:
assert state.window_type is not None
if state.is_auxiliary:
if state.window_type is win_type:
aux_matching_state = state
else:
aux_state = state
state = state.parent
# If there's an ancestor auxiliary window-state matching our
# type, back out past it (example: poking settings, navigating
# down a level or two, and then poking settings again should
# back out of settings).
if aux_matching_state is not None:
current_main_window.main_window_back_state = (
aux_matching_state.parent
)
current_main_window.main_window_back()
return
# If there's an ancestory auxiliary state *not* matching our
# type, crop the state and swap in our new auxiliary UI
# (example: poking settings, then poking account, then poking
# back should end up where things were before the settings
# poke).
if aux_state is not None:
# Blow away the window stack and build a fresh one.
ui.clear_main_window()
ui.set_main_window(
win_create_call(),
from_window=False, # Disable from-check.
back_state=aux_state.parent,
suppress_warning=True,
is_auxiliary=True,
)
return
# Ok, no auxiliary states found. Now if current window is
# auxiliary and the type matches, simply do a back.
if (
current_main_window.main_window_is_auxiliary
and type(current_main_window) is win_type
):
current_main_window.main_window_back()
return
# If current window is auxiliary but type doesn't match,
# swap it out for our new auxiliary UI.
if current_main_window.main_window_is_auxiliary:
ui.clear_main_window()
ui.set_main_window(
win_create_call(),
from_window=False, # Disable from-check.
back_state=current_main_window.main_window_back_state,
suppress_warning=True,
is_auxiliary=True,
)
return
# Ok, no existing auxiliary stuff was found period. Just
# navigate forward to this UI.
current_main_window.main_window_replace(
win_create_call(), is_auxiliary=True
)
def _root_ui_achievements_press(self) -> None:
from bauiv1lib.achievements import AchievementsWindow
if not self._ensure_signed_in_v1():
return
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=AchievementsWindow,
win_create_call=lambda: AchievementsWindow(
origin_widget=bauiv1.get_special_widget(
'achievements_button'
)
),
)
)
def _root_ui_inbox_press(self) -> None:
from bauiv1lib.inbox import InboxWindow
if not self._ensure_signed_in():
return
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=InboxWindow,
win_create_call=lambda: InboxWindow(
origin_widget=bauiv1.get_special_widget('inbox_button')
),
)
)
def _root_ui_store_press(self) -> None:
from bauiv1lib.store.browser import StoreBrowserWindow
if not self._ensure_signed_in_v1():
return
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=StoreBrowserWindow,
win_create_call=lambda: StoreBrowserWindow(
origin_widget=bauiv1.get_special_widget('store_button')
),
)
)
def _root_ui_tickets_meter_press(self) -> None:
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
'tickets', origin_widget=bauiv1.get_special_widget('tickets_meter')
)
def _root_ui_tokens_meter_press(self) -> None:
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
'tokens', origin_widget=bauiv1.get_special_widget('tokens_meter')
)
def _root_ui_trophy_meter_press(self) -> None:
from bauiv1lib.league.rankwindow import LeagueRankWindow
if not self._ensure_signed_in_v1():
return
self._auxiliary_window_nav(
win_type=LeagueRankWindow,
win_create_call=lambda: LeagueRankWindow(
origin_widget=bauiv1.get_special_widget('trophy_meter')
),
)
def _root_ui_level_meter_press(self) -> None:
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
'xp', origin_widget=bauiv1.get_special_widget('level_meter')
)
def _root_ui_inventory_press(self) -> None:
from bauiv1lib.inventory import InventoryWindow
if not self._ensure_signed_in_v1():
return
self._auxiliary_window_nav(
win_type=InventoryWindow,
win_create_call=lambda: InventoryWindow(
origin_widget=bauiv1.get_special_widget('inventory_button')
),
)
def _ensure_signed_in(self) -> bool:
"""Make sure we're signed in (requiring modern v2 accounts)."""
plus = bauiv1.app.plus
if plus is None:
bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
bauiv1.getsound('error').play()
return False
if plus.accounts.primary is None:
show_sign_in_prompt()
return False
return True
def _ensure_signed_in_v1(self) -> bool:
"""Make sure we're signed in (allowing legacy v1-only accounts)."""
plus = bauiv1.app.plus
if plus is None:
bauiv1.screenmessage('This requires plus.', color=(1, 0, 0))
bauiv1.getsound('error').play()
return False
if plus.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return False
return True
def _root_ui_get_tokens_press(self) -> None:
from bauiv1lib.gettokens import GetTokensWindow
if not self._ensure_signed_in():
return
self._auxiliary_window_nav(
win_type=GetTokensWindow,
win_create_call=lambda: GetTokensWindow(
origin_widget=bauiv1.get_special_widget('get_tokens_button')
),
)
def _root_ui_chest_slot_pressed(self, index: int) -> None:
from bauiv1lib.chest import (
ChestWindow0,
ChestWindow1,
ChestWindow2,
ChestWindow3,
)
widgetid: Literal[
'chest_0_button',
'chest_1_button',
'chest_2_button',
'chest_3_button',
]
winclass: type[ChestWindow]
if index == 0:
widgetid = 'chest_0_button'
winclass = ChestWindow0
elif index == 1:
widgetid = 'chest_1_button'
winclass = ChestWindow1
elif index == 2:
widgetid = 'chest_2_button'
winclass = ChestWindow2
elif index == 3:
widgetid = 'chest_3_button'
winclass = ChestWindow3
else:
raise RuntimeError(f'Invalid index {index}')
wait_for_connectivity(
on_connected=lambda: self._auxiliary_window_nav(
win_type=winclass,
win_create_call=lambda: winclass(
index=index,
origin_widget=bauiv1.get_special_widget(widgetid),
),
)
)

View file

@ -3,10 +3,10 @@
"""Provides classic app subsystem.""" """Provides classic app subsystem."""
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING, override
import random import random
import logging import logging
import weakref import weakref
from typing import TYPE_CHECKING, override, assert_never
from efro.dataclassio import dataclass_from_dict from efro.dataclassio import dataclass_from_dict
import babase import babase
@ -26,15 +26,15 @@ from baclassic import _input
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Any, Sequence from typing import Callable, Any, Sequence
import bacommon.bs
from bascenev1lib.actor import spazappearance from bascenev1lib.actor import spazappearance
from bauiv1lib.party import PartyWindow from bauiv1lib.party import PartyWindow
from baclassic._appdelegate import AppDelegate
from baclassic._servermode import ServerController from baclassic._servermode import ServerController
from baclassic._net import MasterServerCallback from baclassic._net import MasterServerCallback
class ClassicSubsystem(babase.AppSubsystem): class ClassicAppSubsystem(babase.AppSubsystem):
"""Subsystem for classic functionality in the app. """Subsystem for classic functionality in the app.
The single shared instance of this app can be accessed at The single shared instance of this app can be accessed at
@ -45,7 +45,6 @@ class ClassicSubsystem(babase.AppSubsystem):
# pylint: disable=too-many-public-methods # pylint: disable=too-many-public-methods
# noinspection PyUnresolvedReferences
from baclassic._music import MusicPlayMode from baclassic._music import MusicPlayMode
def __init__(self) -> None: def __init__(self) -> None:
@ -72,10 +71,14 @@ class ClassicSubsystem(babase.AppSubsystem):
self.stress_test_update_timer: babase.AppTimer | None = None self.stress_test_update_timer: babase.AppTimer | None = None
self.stress_test_update_timer_2: babase.AppTimer | None = None self.stress_test_update_timer_2: babase.AppTimer | None = None
self.value_test_defaults: dict = {} self.value_test_defaults: dict = {}
self.special_offer: dict | None = None
self.ping_thread_count = 0 self.ping_thread_count = 0
self.allow_ticket_purchases: bool = True self.allow_ticket_purchases: bool = True
# Classic-specific account state.
self.remove_ads = False
self.gold_pass = False
self.chest_dock_full = False
# Main Menu. # Main Menu.
self.main_menu_did_initial_transition = False self.main_menu_did_initial_transition = False
self.main_menu_last_news_fetch_time: float | None = None self.main_menu_last_news_fetch_time: float | None = None
@ -93,17 +96,17 @@ class ClassicSubsystem(babase.AppSubsystem):
# We include this extra hash with shared input-mapping names so # We include this extra hash with shared input-mapping names so
# that we don't share mappings between differently-configured # that we don't share mappings between differently-configured
# systems. For instance, different android devices may give different # systems. For instance, different android devices may give
# key values for the same controller type so we keep their mappings # different key values for the same controller type so we keep
# distinct. # their mappings distinct.
self.input_map_hash: str | None = None self.input_map_hash: str | None = None
# Maps. # Maps.
self.maps: dict[str, type[bascenev1.Map]] = {} self.maps: dict[str, type[bascenev1.Map]] = {}
# Gameplay. # Gameplay.
self.teams_series_length = 7 # deprecated, left for old mods self.teams_series_length = 7 # Deprecated, left for old mods.
self.ffa_series_length = 24 # deprecated, left for old mods self.ffa_series_length = 24 # Deprecated, left for old mods.
self.coop_session_args: dict = {} self.coop_session_args: dict = {}
# UI. # UI.
@ -111,8 +114,9 @@ class ClassicSubsystem(babase.AppSubsystem):
self.did_menu_intro = False # FIXME: Move to mainmenu class. self.did_menu_intro = False # FIXME: Move to mainmenu class.
self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu. self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu.
self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any. self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any.
self.delegate: AppDelegate | None = None
self.party_window: weakref.ref[PartyWindow] | None = None self.party_window: weakref.ref[PartyWindow] | None = None
self.main_menu_resume_callbacks: list = []
self.saved_ui_state: bauiv1.MainWindowState | None = None
# Store. # Store.
self.store_layout: dict[str, list[dict[str, Any]]] | None = None self.store_layout: dict[str, list[dict[str, Any]]] | None = None
@ -120,6 +124,16 @@ class ClassicSubsystem(babase.AppSubsystem):
self.pro_sale_start_time: int | None = None self.pro_sale_start_time: int | None = None
self.pro_sale_start_val: int | None = None self.pro_sale_start_val: int | None = None
def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
"""(internal)"""
# If there's no main window up, just call immediately.
if not babase.app.ui_v1.has_main_window():
with babase.ContextRef.empty():
call()
else:
self.main_menu_resume_callbacks.append(call)
@property @property
def platform(self) -> str: def platform(self) -> str:
"""Name of the current platform. """Name of the current platform.
@ -154,8 +168,6 @@ class ClassicSubsystem(babase.AppSubsystem):
from bascenev1lib.actor import spazappearance from bascenev1lib.actor import spazappearance
from bascenev1lib import maps as stdmaps from bascenev1lib import maps as stdmaps
from baclassic._appdelegate import AppDelegate
plus = babase.app.plus plus = babase.app.plus
assert plus is not None assert plus is not None
@ -164,34 +176,13 @@ class ClassicSubsystem(babase.AppSubsystem):
self.music.on_app_loading() self.music.on_app_loading()
self.delegate = AppDelegate() # Non-test, non-debug builds should generally be blessed; warn
# if not (so I don't accidentally release a build that can't
# Non-test, non-debug builds should generally be blessed; warn if not. # play tourneys).
# (so I don't accidentally release a build that can't play tourneys)
if not env.debug and not env.test and not plus.is_blessed(): if not env.debug and not env.test and not plus.is_blessed():
babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0)) babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
# FIXME: This should not be hard-coded. stdmaps.register_all_maps()
for maptype in [
stdmaps.HockeyStadium,
stdmaps.FootballStadium,
stdmaps.Bridgit,
stdmaps.BigG,
stdmaps.Roundabout,
stdmaps.MonkeyFace,
stdmaps.ZigZag,
stdmaps.ThePad,
stdmaps.DoomShroom,
stdmaps.LakeFrigid,
stdmaps.TipTop,
stdmaps.CragCastle,
stdmaps.TowerD,
stdmaps.HappyThoughts,
stdmaps.StepRightUp,
stdmaps.Courtyard,
stdmaps.Rampage,
]:
bascenev1.register_map(maptype)
spazappearance.register_appearances() spazappearance.register_appearances()
bascenev1.init_campaigns() bascenev1.init_campaigns()
@ -207,24 +198,6 @@ class ClassicSubsystem(babase.AppSubsystem):
cfg['launchCount'] = launch_count cfg['launchCount'] = launch_count
cfg.commit() cfg.commit()
# Run a test in a few seconds to see if we should pop up an existing
# pending special offer.
def check_special_offer() -> None:
assert plus is not None
from bauiv1lib.specialoffer import show_offer
if (
'pendingSpecialOffer' in cfg
and plus.get_v1_account_public_login_id()
== cfg['pendingSpecialOffer']['a']
):
self.special_offer = cfg['pendingSpecialOffer']['o']
show_offer()
if babase.app.env.gui:
babase.apptimer(3.0, check_special_offer)
# If there's a leftover log file, attempt to upload it to the # If there's a leftover log file, attempt to upload it to the
# master-server and/or get rid of it. # master-server and/or get rid of it.
babase.handle_leftover_v1_cloud_log_file() babase.handle_leftover_v1_cloud_log_file()
@ -261,8 +234,8 @@ class ClassicSubsystem(babase.AppSubsystem):
from babase import Lstr from babase import Lstr
from bascenev1 import NodeActor from bascenev1 import NodeActor
# FIXME: Shouldn't be touching scene stuff here; # FIXME: Shouldn't be touching scene stuff here; should just
# should just pass the request on to the host-session. # pass the request on to the host-session.
with activity.context: with activity.context:
globs = activity.globalsnode globs = activity.globalsnode
if not globs.paused: if not globs.paused:
@ -289,8 +262,8 @@ class ClassicSubsystem(babase.AppSubsystem):
to resume. to resume.
""" """
# FIXME: Shouldn't be touching scene stuff here; # FIXME: Shouldn't be touching scene stuff here; should just
# should just pass the request on to the host-session. # pass the request on to the host-session.
activity = bascenev1.get_foreground_host_activity() activity = bascenev1.get_foreground_host_activity()
if activity is not None: if activity is not None:
with activity.context: with activity.context:
@ -340,6 +313,9 @@ class ClassicSubsystem(babase.AppSubsystem):
) )
return False return False
# Save where we are in the UI to come back to when done.
babase.app.classic.save_ui_state()
# Ok, we're good to go. # Ok, we're good to go.
self.coop_session_args = { self.coop_session_args = {
'campaign': campaignname, 'campaign': campaignname,
@ -374,24 +350,24 @@ class ClassicSubsystem(babase.AppSubsystem):
assert plus is not None assert plus is not None
if reset_ui: if reset_ui:
babase.app.ui_v1.clear_main_menu_window() babase.app.ui_v1.clear_main_window()
if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession): if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
# It may be possible we're on the main menu but the screen is faded # It may be possible we're on the main menu but the screen
# so fade back in. # is faded so fade back in.
babase.fade_screen(True) babase.fade_screen(True)
return return
_benchmark.stop_stress_test() # Stop stress-test if in progress. _benchmark.stop_stress_test() # Stop stress-test if in progress.
# If we're in a host-session, tell them to end. # If we're in a host-session, tell them to end. This lets them
# This lets them tear themselves down gracefully. # tear themselves down gracefully.
host_session: bascenev1.Session | None = ( host_session: bascenev1.Session | None = (
bascenev1.get_foreground_host_session() bascenev1.get_foreground_host_session()
) )
if host_session is not None: if host_session is not None:
# Kick off a little transaction so we'll hopefully have all the # Kick off a little transaction so we'll hopefully have all
# latest account state when we get back to the menu. # the latest account state when we get back to the menu.
plus.add_v1_account_transaction( plus.add_v1_account_transaction(
{'type': 'END_SESSION', 'sType': str(type(host_session))} {'type': 'END_SESSION', 'sType': str(type(host_session))}
) )
@ -518,11 +494,36 @@ class ClassicSubsystem(babase.AppSubsystem):
request, 'post', data, callback, response_type request, 'post', data, callback, response_type
).start() ).start()
def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]: def set_tournament_prize_image(
self, entry: dict[str, Any], index: int, image: bauiv1.Widget
) -> None:
"""Given a tournament entry, return strings for its prize levels.""" """Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament from baclassic import _tournament
return _tournament.get_tournament_prize_strings(entry) return _tournament.set_tournament_prize_chest_image(entry, index, image)
def create_in_game_tournament_prize_image(
self,
entry: dict[str, Any],
index: int,
position: tuple[float, float],
) -> None:
"""Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament
_tournament.create_in_game_tournament_prize_image(
entry, index, position
)
def get_tournament_prize_strings(
self, entry: dict[str, Any], include_tickets: bool
) -> list[str]:
"""Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament
return _tournament.get_tournament_prize_strings(
entry, include_tickets=include_tickets
)
def getcampaign(self, name: str) -> bascenev1.Campaign: def getcampaign(self, name: str) -> bascenev1.Campaign:
"""Return a campaign by name.""" """Return a campaign by name."""
@ -536,26 +537,21 @@ class ClassicSubsystem(babase.AppSubsystem):
tip = self.tips.pop() tip = self.tips.pop()
return tip return tip
def run_gpu_benchmark(self) -> None:
"""Kick off a benchmark to test gpu speeds."""
from baclassic._benchmark import run_gpu_benchmark as run
run()
def run_cpu_benchmark(self) -> None: def run_cpu_benchmark(self) -> None:
"""Kick off a benchmark to test cpu speeds.""" """Kick off a benchmark to test cpu speeds."""
from baclassic._benchmark import run_cpu_benchmark as run from baclassic._benchmark import run_cpu_benchmark
run() run_cpu_benchmark()
def run_media_reload_benchmark(self) -> None: def run_media_reload_benchmark(self) -> None:
"""Kick off a benchmark to test media reloading speeds.""" """Kick off a benchmark to test media reloading speeds."""
from baclassic._benchmark import run_media_reload_benchmark as run from baclassic._benchmark import run_media_reload_benchmark
run() run_media_reload_benchmark()
def run_stress_test( def run_stress_test(
self, self,
*,
playlist_type: str = 'Random', playlist_type: str = 'Random',
playlist_name: str = '__default__', playlist_name: str = '__default__',
player_count: int = 8, player_count: int = 8,
@ -563,9 +559,9 @@ class ClassicSubsystem(babase.AppSubsystem):
attract_mode: bool = False, attract_mode: bool = False,
) -> None: ) -> None:
"""Run a stress test.""" """Run a stress test."""
from baclassic._benchmark import run_stress_test as run from baclassic._benchmark import run_stress_test
run( run_stress_test(
playlist_type=playlist_type, playlist_type=playlist_type,
playlist_name=playlist_name, playlist_name=playlist_name,
player_count=player_count, player_count=player_count,
@ -684,14 +680,6 @@ class ClassicSubsystem(babase.AppSubsystem):
babase.Call(ServerDialogWindow, sddata), babase.Call(ServerDialogWindow, sddata),
) )
def ticket_icon_press(self) -> None:
"""(internal)"""
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
origin_widget=bauiv1.get_special_widget('tickets_info_button')
)
def show_url_window(self, address: str) -> None: def show_url_window(self, address: str) -> None:
"""(internal)""" """(internal)"""
from bauiv1lib.url import ShowURLWindow from bauiv1lib.url import ShowURLWindow
@ -707,6 +695,7 @@ class ClassicSubsystem(babase.AppSubsystem):
def tournament_entry_window( def tournament_entry_window(
self, self,
tournament_id: str, tournament_id: str,
*,
tournament_activity: bascenev1.Activity | None = None, tournament_activity: bascenev1.Activity | None = None,
position: tuple[float, float] = (0.0, 0.0), position: tuple[float, float] = (0.0, 0.0),
delegate: Any = None, delegate: Any = None,
@ -733,30 +722,32 @@ class ClassicSubsystem(babase.AppSubsystem):
return MainMenuSession return MainMenuSession
def continues_window(
self,
activity: bascenev1.Activity,
cost: int,
continue_call: Callable[[], Any],
cancel_call: Callable[[], Any],
) -> None:
"""(internal)"""
from bauiv1lib.continues import ContinuesWindow
ContinuesWindow(activity, cost, continue_call, cancel_call)
def profile_browser_window( def profile_browser_window(
self, self,
transition: str = 'in_right', transition: str = 'in_right',
in_main_menu: bool = True,
selected_profile: str | None = None,
origin_widget: bauiv1.Widget | None = None, origin_widget: bauiv1.Widget | None = None,
selected_profile: str | None = None,
) -> None: ) -> None:
"""(internal)""" """(internal)"""
from bauiv1lib.profile.browser import ProfileBrowserWindow from bauiv1lib.profile.browser import ProfileBrowserWindow
ProfileBrowserWindow( main_window = babase.app.ui_v1.get_main_window()
transition, in_main_menu, selected_profile, origin_widget if main_window is not None:
logging.warning(
'profile_browser_window()'
' called with existing main window; should not happen.'
)
return
babase.app.ui_v1.set_main_window(
ProfileBrowserWindow(
transition=transition,
selected_profile=selected_profile,
origin_widget=origin_widget,
minimal_toolbar=True,
),
is_top_level=True,
suppress_warning=True,
) )
def preload_map_preview_media(self) -> None: def preload_map_preview_media(self) -> None:
@ -781,6 +772,9 @@ class ClassicSubsystem(babase.AppSubsystem):
assert app.env.gui assert app.env.gui
# Play explicit swish sound so it occurs due to keypresses/etc.
# This means we have to disable it for any button or else we get
# double.
bauiv1.getsound('swish').play() bauiv1.getsound('swish').play()
# If it exists, dismiss it; otherwise make a new one. # If it exists, dismiss it; otherwise make a new one.
@ -794,18 +788,182 @@ class ClassicSubsystem(babase.AppSubsystem):
def device_menu_press(self, device_id: int | None) -> None: def device_menu_press(self, device_id: int | None) -> None:
"""(internal)""" """(internal)"""
from bauiv1lib.mainmenu import MainMenuWindow from bauiv1lib.ingamemenu import InGameMenuWindow
from bauiv1 import set_ui_input_device from bauiv1 import set_ui_input_device
assert babase.app is not None assert babase.app is not None
in_main_menu = babase.app.ui_v1.has_main_menu_window() in_main_menu = babase.app.ui_v1.has_main_window()
if not in_main_menu: if not in_main_menu:
set_ui_input_device(device_id) set_ui_input_device(device_id)
# Hack(ish). We play swish sound here so it happens for
# device presses, but this means we need to disable default
# swish sounds for any menu buttons or we'll get double.
if babase.app.env.gui: if babase.app.env.gui:
bauiv1.getsound('swish').play() bauiv1.getsound('swish').play()
babase.app.ui_v1.set_main_menu_window( babase.app.ui_v1.set_main_window(
MainMenuWindow().get_root_widget(), InGameMenuWindow(), is_top_level=True, suppress_warning=True
from_window=False, # Disable check here.
) )
def save_ui_state(self) -> None:
"""Store our current place in the UI."""
ui = babase.app.ui_v1
mainwindow = ui.get_main_window()
if mainwindow is not None:
self.saved_ui_state = ui.save_main_window_state(mainwindow)
else:
self.saved_ui_state = None
def invoke_main_menu_ui(self) -> None:
"""Bring up main menu ui."""
# Bring up the last place we were, or start at the main menu
# otherwise.
app = bauiv1.app
env = app.env
with bascenev1.ContextRef.empty():
assert app.classic is not None
if app.env.headless:
# UI stuff fails now in headless builds; avoid it.
pass
else:
# When coming back from a kiosk-mode game, jump to the
# kiosk start screen.
if env.demo or env.arcade:
# pylint: disable=cyclic-import
from bauiv1lib.kiosk import KioskWindow
app.ui_v1.set_main_window(
KioskWindow(), is_top_level=True, suppress_warning=True
)
else:
# If there's a saved ui state, restore that.
if self.saved_ui_state is not None:
app.ui_v1.restore_main_window_state(self.saved_ui_state)
else:
# Otherwise start fresh at the main menu.
from bauiv1lib.mainmenu import MainMenuWindow
app.ui_v1.set_main_window(
MainMenuWindow(transition=None),
is_top_level=True,
suppress_warning=True,
)
@staticmethod
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
"""Run client effects sent from the master server."""
from baclassic._clienteffect import run_bs_client_effects
run_bs_client_effects(effects)
@staticmethod
def basic_client_ui_button_label_str(
label: bacommon.bs.BasicClientUI.ButtonLabel,
) -> babase.Lstr:
"""Given a client-ui label, return an Lstr."""
import bacommon.bs
cls = bacommon.bs.BasicClientUI.ButtonLabel
if label is cls.UNKNOWN:
# Server should not be sending us unknown stuff; make noise
# if they do.
logging.error(
'Got BasicClientUI.ButtonLabel.UNKNOWN; should not happen.'
)
return babase.Lstr(value='<error>')
rsrc: str | None = None
if label is cls.OK:
rsrc = 'okText'
elif label is cls.APPLY:
rsrc = 'applyText'
elif label is cls.CANCEL:
rsrc = 'cancelText'
elif label is cls.ACCEPT:
rsrc = 'gatherWindow.partyInviteAcceptText'
elif label is cls.DECLINE:
rsrc = 'gatherWindow.partyInviteDeclineText'
elif label is cls.IGNORE:
rsrc = 'gatherWindow.partyInviteIgnoreText'
elif label is cls.CLAIM:
rsrc = 'claimText'
elif label is cls.DISCARD:
rsrc = 'discardText'
else:
assert_never(label)
return babase.Lstr(resource=rsrc)
def required_purchases_for_game(self, game: str) -> list[str]:
"""Return which purchase (if any) is required for a game."""
# pylint: disable=too-many-return-statements
if game in (
'Challenges:Infinite Runaround',
'Challenges:Tournament Infinite Runaround',
):
# Special case: Pro used to unlock this.
return (
[]
if self.accounts.have_pro()
else ['upgrades.infinite_runaround']
)
if game in (
'Challenges:Infinite Onslaught',
'Challenges:Tournament Infinite Onslaught',
):
# Special case: Pro used to unlock this.
return (
[]
if self.accounts.have_pro()
else ['upgrades.infinite_onslaught']
)
if game in (
'Challenges:Meteor Shower',
'Challenges:Epic Meteor Shower',
):
return ['games.meteor_shower']
if game in (
'Challenges:Target Practice',
'Challenges:Target Practice B',
):
return ['games.target_practice']
if game in (
'Challenges:Ninja Fight',
'Challenges:Pro Ninja Fight',
):
return ['games.ninja_fight']
if game in ('Challenges:Race', 'Challenges:Pro Race'):
return ['games.race']
if game in ('Challenges:Lake Frigid Race',):
return ['games.race', 'maps.lake_frigid']
if game in (
'Challenges:Easter Egg Hunt',
'Challenges:Pro Easter Egg Hunt',
):
return ['games.easter_egg_hunt']
return []
def is_game_unlocked(self, game: str) -> bool:
"""Is a particular game unlocked?"""
plus = babase.app.plus
assert plus is not None
purchases = self.required_purchases_for_game(game)
if not purchases:
return True
for purchase in purchases:
if not plus.get_v1_account_product_purchased(purchase):
return False
return True

View file

@ -20,11 +20,14 @@ def run_cpu_benchmark() -> None:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bascenev1lib import tutorial from bascenev1lib import tutorial
# Save our UI state that we'll return to when done.
if babase.app.classic is not None:
babase.app.classic.save_ui_state()
class BenchmarkSession(bascenev1.Session): class BenchmarkSession(bascenev1.Session):
"""Session type for cpu benchmark.""" """Session type for cpu benchmark."""
def __init__(self) -> None: def __init__(self) -> None:
# print('FIXME: BENCHMARK SESSION WOULD CALC DEPS.')
depsets: Sequence[bascenev1.DependencySet] = [] depsets: Sequence[bascenev1.DependencySet] = []
super().__init__(depsets) super().__init__(depsets)
@ -99,7 +102,9 @@ def _start_stress_test(args: _StressTestArgs) -> None:
"""(internal)""" """(internal)"""
from bascenev1 import DualTeamSession, FreeForAllSession from bascenev1 import DualTeamSession, FreeForAllSession
assert babase.app.classic is not None classic = babase.app.classic
assert classic is not None
appconfig = babase.app.config appconfig = babase.app.config
playlist_type = args.playlist_type playlist_type = args.playlist_type
@ -116,6 +121,10 @@ def _start_stress_test(args: _StressTestArgs) -> None:
+ args.playlist_name + args.playlist_name
+ '")...' + '")...'
) )
# Save where we are in the UI so we'll return there when done.
classic.save_ui_state()
if playlist_type == 'Teams': if playlist_type == 'Teams':
appconfig['Team Tournament Playlist Selection'] = args.playlist_name appconfig['Team Tournament Playlist Selection'] = args.playlist_name
appconfig['Team Tournament Playlist Randomize'] = 1 appconfig['Team Tournament Playlist Randomize'] = 1
@ -137,11 +146,11 @@ def _start_stress_test(args: _StressTestArgs) -> None:
), ),
) )
_baclassic.set_stress_testing(True, args.player_count, args.attract_mode) _baclassic.set_stress_testing(True, args.player_count, args.attract_mode)
babase.app.classic.stress_test_update_timer = babase.AppTimer( classic.stress_test_update_timer = babase.AppTimer(
args.round_duration, babase.Call(_reset_stress_test, args) args.round_duration, babase.Call(_reset_stress_test, args)
) )
if args.attract_mode: if args.attract_mode:
babase.app.classic.stress_test_update_timer_2 = babase.AppTimer( classic.stress_test_update_timer_2 = babase.AppTimer(
0.48, babase.Call(_update_attract_mode_test, args), repeat=True 0.48, babase.Call(_update_attract_mode_test, args), repeat=True
) )
@ -170,12 +179,6 @@ def _reset_stress_test(args: _StressTestArgs) -> None:
babase.apptimer(1.0, babase.Call(_start_stress_test, args)) babase.apptimer(1.0, babase.Call(_start_stress_test, args))
def run_gpu_benchmark() -> None:
"""Kick off a benchmark to test gpu speeds."""
# FIXME: Not wired up yet.
babase.screenmessage('Not wired up yet.', color=(1, 0, 0))
def run_media_reload_benchmark() -> None: def run_media_reload_benchmark() -> None:
"""Kick off a benchmark to test media reloading speeds.""" """Kick off a benchmark to test media reloading speeds."""
babase.reload_media() babase.reload_media()
@ -199,6 +202,6 @@ def run_media_reload_benchmark() -> None:
babase.add_clean_frame_callback(babase.Call(doit, start_time)) babase.add_clean_frame_callback(babase.Call(doit, start_time))
# The reload starts (should add a completion callback to the # The reload starts (should add a completion callback to the reload
# reload func to fix this). # func to fix this).
babase.apptimer(0.05, babase.Call(delay_add, babase.apptime())) babase.apptimer(0.05, babase.Call(delay_add, babase.apptime()))

91
dist/ba_data/python/baclassic/_chest.py vendored Normal file
View file

@ -0,0 +1,91 @@
# Released under the MIT License. See LICENSE for details.
#
"""Chest related functionality."""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from bacommon.bs import ClassicChestAppearance
if TYPE_CHECKING:
pass
@dataclass
class ChestAppearanceDisplayInfo:
"""Info about how to locally display chest appearances."""
# NOTE TO SELF: Don't rename these attrs; the C++ layer is hard
# coded to look for them.
texclosed: str
texclosedtint: str
texopen: str
texopentint: str
color: tuple[float, float, float]
tint: tuple[float, float, float]
tint2: tuple[float, float, float]
# Info for chest types we know how to draw. Anything not found in here
# should fall back to the DEFAULT entry.
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT = ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(1, 1, 1),
tint=(1, 1, 1),
tint2=(1, 1, 1),
)
CHEST_APPEARANCE_DISPLAY_INFOS: dict[
ClassicChestAppearance, ChestAppearanceDisplayInfo
] = {
ClassicChestAppearance.L2: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.8, 1.0, 0.93),
tint=(0.65, 1.0, 0.8),
tint2=(0.65, 1.0, 0.8),
),
ClassicChestAppearance.L3: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.75, 0.9, 1.3),
tint=(0.7, 1, 1.9),
tint2=(0.7, 1, 1.9),
),
ClassicChestAppearance.L4: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.7, 1.0, 1.4),
tint=(1.4, 1.6, 2.0),
tint2=(1.4, 1.6, 2.0),
),
ClassicChestAppearance.L5: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(0.75, 0.5, 2.4),
tint=(1.0, 0.8, 0.0),
tint2=(1.0, 0.8, 0.0),
),
ClassicChestAppearance.L6: ChestAppearanceDisplayInfo(
texclosed='chestIcon',
texclosedtint='chestIconTint',
texopen='chestOpenIcon',
texopentint='chestOpenIconTint',
color=(1.1, 0.8, 0.0),
tint=(2, 2, 2),
tint2=(2, 2, 2),
),
}

View file

@ -0,0 +1,77 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to running client-effects from the master server."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, assert_never
from efro.util import strict_partial
import bacommon.bs
import bauiv1
if TYPE_CHECKING:
pass
def run_bs_client_effects(effects: list[bacommon.bs.ClientEffect]) -> None:
"""Run effects."""
# pylint: disable=too-many-branches
delay = 0.0
for effect in effects:
if isinstance(effect, bacommon.bs.ClientEffectScreenMessage):
textfin = bauiv1.Lstr(
translate=('serverResponses', effect.message)
).evaluate()
if effect.subs is not None:
# Should always be even.
assert len(effect.subs) % 2 == 0
for j in range(0, len(effect.subs) - 1, 2):
textfin = textfin.replace(
effect.subs[j],
effect.subs[j + 1],
)
bauiv1.apptimer(
delay,
strict_partial(
bauiv1.screenmessage, textfin, color=effect.color
),
)
elif isinstance(effect, bacommon.bs.ClientEffectSound):
smcls = bacommon.bs.ClientEffectSound.Sound
soundfile: str | None = None
if effect.sound is smcls.UNKNOWN:
# Server should avoid sending us sounds we don't
# support. Make some noise if it happens.
logging.error('Got unrecognized bacommon.bs.ClientEffectSound.')
elif effect.sound is smcls.CASH_REGISTER:
soundfile = 'cashRegister'
elif effect.sound is smcls.ERROR:
soundfile = 'error'
elif effect.sound is smcls.POWER_DOWN:
soundfile = 'powerdown01'
elif effect.sound is smcls.GUN_COCKING:
soundfile = 'gunCocking'
else:
assert_never(effect.sound)
if soundfile is not None:
bauiv1.apptimer(
delay,
strict_partial(
bauiv1.getsound(soundfile).play, volume=effect.volume
),
)
elif isinstance(effect, bacommon.bs.ClientEffectDelay):
delay += effect.seconds
else:
# Server should not send us stuff we can't digest. Make
# some noise if it happens.
logging.error(
'Got unrecognized bacommon.bs.ClientEffect;'
' should not happen.'
)

View file

@ -0,0 +1,109 @@
# Released under the MIT License. See LICENSE for details.
#
"""Display-item related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
from efro.util import pairs_from_flat
import bacommon.bs
import bauiv1
if TYPE_CHECKING:
pass
def show_display_item(
itemwrapper: bacommon.bs.DisplayItemWrapper,
parent: bauiv1.Widget,
pos: tuple[float, float],
width: float,
) -> None:
"""Create ui to depict a display-item."""
height = width * 0.666
# Silent no-op if our parent ui is dead.
if not parent:
return
img: str | None = None
img_y_offs = 0.0
text_y_offs = 0.0
show_text = True
if isinstance(itemwrapper.item, bacommon.bs.TicketsDisplayItem):
img = 'tickets'
img_y_offs = width * 0.11
text_y_offs = width * -0.15
elif isinstance(itemwrapper.item, bacommon.bs.TokensDisplayItem):
img = 'coin'
img_y_offs = width * 0.11
text_y_offs = width * -0.15
elif isinstance(itemwrapper.item, bacommon.bs.ChestDisplayItem):
from baclassic._chest import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
img = None
show_text = False
c_info = CHEST_APPEARANCE_DISPLAY_INFOS.get(
itemwrapper.item.appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
c_size = width * 0.85
bauiv1.imagewidget(
parent=parent,
position=(pos[0] - c_size * 0.5, pos[1] - c_size * 0.5),
color=c_info.color,
size=(c_size, c_size),
texture=bauiv1.gettexture(c_info.texclosed),
tint_texture=bauiv1.gettexture(c_info.texclosedtint),
tint_color=c_info.tint,
tint2_color=c_info.tint2,
)
# Enable this for testing spacing.
if bool(False):
bauiv1.imagewidget(
parent=parent,
position=(
pos[0] - width * 0.5,
pos[1] - height * 0.5,
),
size=(width, height),
texture=bauiv1.gettexture('white'),
color=(0, 1, 0),
opacity=0.1,
)
imgsize = width * 0.33
if img is not None:
bauiv1.imagewidget(
parent=parent,
position=(
pos[0] - imgsize * 0.5,
pos[1] + img_y_offs - imgsize * 0.5,
),
size=(imgsize, imgsize),
texture=bauiv1.gettexture(img),
)
if show_text:
subs = itemwrapper.description_subs
if subs is None:
subs = []
bauiv1.textwidget(
parent=parent,
position=(pos[0], pos[1] + text_y_offs),
scale=width * 0.006,
size=(0, 0),
text=bauiv1.Lstr(
translate=('displayItemNames', itemwrapper.description),
subs=pairs_from_flat(subs),
),
maxwidth=width * 0.9,
color=(0.0, 1.0, 0.0),
h_align='center',
v_align='center',
)

View file

@ -16,6 +16,8 @@ from bascenev1 import MusicType
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Any from typing import Callable, Any
import bauiv1
class MusicPlayMode(Enum): class MusicPlayMode(Enum):
"""Influences behavior when playing music. """Influences behavior when playing music.
@ -389,7 +391,7 @@ class MusicPlayer:
callback: Callable[[Any], None], callback: Callable[[Any], None],
current_entry: Any, current_entry: Any,
selection_target_name: str, selection_target_name: str,
) -> Any: ) -> bauiv1.MainWindow:
"""Summons a UI to select a new soundtrack entry.""" """Summons a UI to select a new soundtrack entry."""
return self.on_select_entry( return self.on_select_entry(
callback, current_entry, selection_target_name callback, current_entry, selection_target_name
@ -432,11 +434,12 @@ class MusicPlayer:
callback: Callable[[Any], None], callback: Callable[[Any], None],
current_entry: Any, current_entry: Any,
selection_target_name: str, selection_target_name: str,
) -> Any: ) -> bauiv1.MainWindow:
"""Present a GUI to select an entry. """Present a GUI to select an entry.
The callback should be called with a valid entry or None to The callback should be called with a valid entry or None to
signify that the default soundtrack should be used..""" signify that the default soundtrack should be used.."""
raise NotImplementedError()
# Subclasses should override the following: # Subclasses should override the following:

View file

@ -35,6 +35,8 @@ class MasterServerV1CallThread(threading.Thread):
callback: MasterServerCallback | None, callback: MasterServerCallback | None,
response_type: MasterServerResponseType, response_type: MasterServerResponseType,
): ):
# pylint: disable=too-many-positional-arguments
# Set daemon=True so long-running requests don't keep us from # Set daemon=True so long-running requests don't keep us from
# quitting the app. # quitting the app.
super().__init__(daemon=True) super().__init__(daemon=True)
@ -52,8 +54,9 @@ class MasterServerV1CallThread(threading.Thread):
self._activity = weakref.ref(activity) if activity is not None else None self._activity = weakref.ref(activity) if activity is not None else None
def _run_callback(self, arg: None | dict[str, Any]) -> None: def _run_callback(self, arg: None | dict[str, Any]) -> None:
# If we were created in an activity context and that activity has # If we were created in an activity context and that activity
# since died, do nothing. # has since died, do nothing.
# FIXME: Should we just be using a ContextCall instead of doing # FIXME: Should we just be using a ContextCall instead of doing
# this check manually? # this check manually?
if self._activity is not None: if self._activity is not None:

View file

@ -390,7 +390,7 @@ class ServerController:
f' ({app.env.engine_build_number})' f' ({app.env.engine_build_number})'
f' entering server-mode {curtimestr}{Clr.RST}' f' entering server-mode {curtimestr}{Clr.RST}'
) )
logging.info(startupmsg) print(startupmsg)
if sessiontype is bascenev1.FreeForAllSession: if sessiontype is bascenev1.FreeForAllSession:
appcfg['Free-for-All Playlist Selection'] = self._playlist_name appcfg['Free-for-All Playlist Selection'] = self._playlist_name

View file

@ -26,6 +26,7 @@ class StoreSubsystem:
def get_store_item_name_translated(self, item_name: str) -> babase.Lstr: def get_store_item_name_translated(self, item_name: str) -> babase.Lstr:
"""Return a babase.Lstr for a store item name.""" """Return a babase.Lstr for a store item name."""
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
# pylint: disable=too-many-return-statements
item_info = self.get_store_item(item_name) item_info = self.get_store_item(item_name)
if item_name.startswith('characters.'): if item_name.startswith('characters.'):
return babase.Lstr( return babase.Lstr(
@ -46,6 +47,14 @@ class StoreSubsystem:
return gametype.get_display_string() return gametype.get_display_string()
if item_name.startswith('icons.'): if item_name.startswith('icons.'):
return babase.Lstr(resource='editProfileWindow.iconText') return babase.Lstr(resource='editProfileWindow.iconText')
if item_name == 'upgrades.infinite_runaround':
return babase.Lstr(
translate=('coopLevelNames', 'Infinite Runaround')
)
if item_name == 'upgrades.infinite_onslaught':
return babase.Lstr(
translate=('coopLevelNames', 'Infinite Onslaught')
)
raise ValueError('unrecognized item: ' + item_name) raise ValueError('unrecognized item: ' + item_name)
def get_store_item_display_size( def get_store_item_display_size(
@ -81,14 +90,17 @@ class StoreSubsystem:
assert babase.app.classic is not None assert babase.app.classic is not None
if babase.app.classic.store_items is None: if babase.app.classic.store_items is None:
from bascenev1lib.game import ninjafight from bascenev1lib.game.race import RaceGame
from bascenev1lib.game import meteorshower from bascenev1lib.game.ninjafight import NinjaFightGame
from bascenev1lib.game import targetpractice from bascenev1lib.game.meteorshower import MeteorShowerGame
from bascenev1lib.game import easteregghunt from bascenev1lib.game.targetpractice import TargetPracticeGame
from bascenev1lib.game.easteregghunt import EasterEggHuntGame
# IMPORTANT - need to keep this synced with the master server. # IMPORTANT - need to keep this synced with the master server.
# (doing so manually for now) # (doing so manually for now)
babase.app.classic.store_items = { babase.app.classic.store_items = {
'upgrades.infinite_runaround': {},
'upgrades.infinite_onslaught': {},
'characters.kronk': {'character': 'Kronk'}, 'characters.kronk': {'character': 'Kronk'},
'characters.zoe': {'character': 'Zoe'}, 'characters.zoe': {'character': 'Zoe'},
'characters.jackmorgan': {'character': 'Jack Morgan'}, 'characters.jackmorgan': {'character': 'Jack Morgan'},
@ -111,20 +123,28 @@ class StoreSubsystem:
'merch': {}, 'merch': {},
'pro': {}, 'pro': {},
'maps.lake_frigid': {'map_type': maps.LakeFrigid}, 'maps.lake_frigid': {'map_type': maps.LakeFrigid},
'games.race': {
'gametype': RaceGame,
'previewTex': 'bigGPreview',
},
'games.ninja_fight': { 'games.ninja_fight': {
'gametype': ninjafight.NinjaFightGame, 'gametype': NinjaFightGame,
'previewTex': 'courtyardPreview', 'previewTex': 'courtyardPreview',
}, },
'games.meteor_shower': { 'games.meteor_shower': {
'gametype': meteorshower.MeteorShowerGame, 'gametype': MeteorShowerGame,
'previewTex': 'rampagePreview',
},
'games.infinite_onslaught': {
'gametype': MeteorShowerGame,
'previewTex': 'rampagePreview', 'previewTex': 'rampagePreview',
}, },
'games.target_practice': { 'games.target_practice': {
'gametype': targetpractice.TargetPracticeGame, 'gametype': TargetPracticeGame,
'previewTex': 'doomShroomPreview', 'previewTex': 'doomShroomPreview',
}, },
'games.easter_egg_hunt': { 'games.easter_egg_hunt': {
'gametype': easteregghunt.EasterEggHuntGame, 'gametype': EasterEggHuntGame,
'previewTex': 'towerDPreview', 'previewTex': 'towerDPreview',
}, },
'icons.flag_us': { 'icons.flag_us': {
@ -365,9 +385,12 @@ class StoreSubsystem:
store_layout['minigames'] = [ store_layout['minigames'] = [
{ {
'items': [ 'items': [
'games.race',
'games.ninja_fight', 'games.ninja_fight',
'games.meteor_shower', 'games.meteor_shower',
'games.target_practice', 'games.target_practice',
'upgrades.infinite_onslaught',
'upgrades.infinite_runaround',
] ]
} }
] ]
@ -446,8 +469,9 @@ class StoreSubsystem:
'price.' + item, None 'price.' + item, None
) )
if ticket_cost is not None: if ticket_cost is not None:
if our_tickets >= ticket_cost and not plus.get_purchased( if (
item our_tickets >= ticket_cost
and not plus.get_v1_account_product_purchased(item)
): ):
count += 1 count += 1
return count return count
@ -522,7 +546,7 @@ class StoreSubsystem:
for section in store_layout[tab]: for section in store_layout[tab]:
for item in section['items']: for item in section['items']:
if item in sales_raw: if item in sales_raw:
if not plus.get_purchased(item): if not plus.get_v1_account_product_purchased(item):
to_end = ( to_end = (
datetime.datetime.fromtimestamp( datetime.datetime.fromtimestamp(
sales_raw[item]['e'], datetime.UTC sales_raw[item]['e'], datetime.UTC
@ -550,7 +574,10 @@ class StoreSubsystem:
if babase.app.env.gui: if babase.app.env.gui:
for map_section in self.get_store_layout()['maps']: for map_section in self.get_store_layout()['maps']:
for mapitem in map_section['items']: for mapitem in map_section['items']:
if plus is None or not plus.get_purchased(mapitem): if (
plus is None
or not plus.get_v1_account_product_purchased(mapitem)
):
m_info = self.get_store_item(mapitem) m_info = self.get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name) unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps) return sorted(unowned_maps)
@ -563,7 +590,14 @@ class StoreSubsystem:
if babase.app.env.gui: if babase.app.env.gui:
for section in self.get_store_layout()['minigames']: for section in self.get_store_layout()['minigames']:
for mname in section['items']: for mname in section['items']:
if plus is None or not plus.get_purchased(mname): if mname.startswith('upgrades.'):
# Ignore things like infinite onslaught which
# aren't actually game types.
continue
if (
plus is None
or not plus.get_v1_account_product_purchased(mname)
):
m_info = self.get_store_item(mname) m_info = self.get_store_item(mname)
unowned_games.add(m_info['gametype']) unowned_games.add(m_info['gametype'])
return unowned_games return unowned_games

View file

@ -6,13 +6,23 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from bacommon.bs import ClassicChestAppearance
import babase import babase
import bauiv1
import bascenev1
from baclassic._chest import (
CHEST_APPEARANCE_DISPLAY_INFOS,
CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]: def get_tournament_prize_strings(
entry: dict[str, Any], include_tickets: bool
) -> list[str]:
"""Given a tournament entry, return strings for its prize levels.""" """Given a tournament entry, return strings for its prize levels."""
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
from bascenev1 import get_trophy_string from bascenev1 import get_trophy_string
@ -27,7 +37,7 @@ def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
trophy_type_2 = entry.get('prizeTrophy2') trophy_type_2 = entry.get('prizeTrophy2')
trophy_type_3 = entry.get('prizeTrophy3') trophy_type_3 = entry.get('prizeTrophy3')
out_vals = [] out_vals = []
for rng, prize, trophy_type in ( for rng, ticket_prize, trophy_type in (
(range1, prize1, trophy_type_1), (range1, prize1, trophy_type_1),
(range2, prize2, trophy_type_2), (range2, prize2, trophy_type_2),
(range3, prize3, trophy_type_3), (range3, prize3, trophy_type_3),
@ -45,14 +55,100 @@ def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
if trophy_type is not None: if trophy_type is not None:
pvval += get_trophy_string(trophy_type) pvval += get_trophy_string(trophy_type)
# If we've got trophies but not for this entry, throw some space if ticket_prize is not None and include_tickets:
# in to compensate so the ticket counts line up.
if prize is not None:
pvval = ( pvval = (
babase.charstr(babase.SpecialChar.TICKET_BACKING) babase.charstr(babase.SpecialChar.TICKET_BACKING)
+ str(prize) + str(ticket_prize)
+ pvval + pvval
) )
out_vals.append(prval) out_vals.append(prval)
out_vals.append(pvval) out_vals.append(pvval)
return out_vals return out_vals
def set_tournament_prize_chest_image(
entry: dict[str, Any], index: int, image: bauiv1.Widget
) -> None:
"""Set image attrs representing a tourney prize chest."""
ranges = [
entry.get('prizeRange1'),
entry.get('prizeRange2'),
entry.get('prizeRange3'),
]
chests = [
entry.get('prizeChest1'),
entry.get('prizeChest2'),
entry.get('prizeChest3'),
]
assert 0 <= index < 3
# If tourney doesn't include this prize, just hide the image.
if ranges[index] is None:
bauiv1.imagewidget(edit=image, opacity=0.0)
return
try:
appearance = ClassicChestAppearance(chests[index])
except ValueError:
appearance = ClassicChestAppearance.DEFAULT
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
bauiv1.imagewidget(
edit=image,
opacity=1.0,
color=chestdisplayinfo.color,
texture=bauiv1.gettexture(chestdisplayinfo.texclosed),
tint_texture=bauiv1.gettexture(chestdisplayinfo.texclosedtint),
tint_color=chestdisplayinfo.tint,
tint2_color=chestdisplayinfo.tint2,
)
def create_in_game_tournament_prize_image(
entry: dict[str, Any], index: int, position: tuple[float, float]
) -> None:
"""Create a display for the prize chest (if any) in-game."""
from bascenev1lib.actor.image import Image
ranges = [
entry.get('prizeRange1'),
entry.get('prizeRange2'),
entry.get('prizeRange3'),
]
chests = [
entry.get('prizeChest1'),
entry.get('prizeChest2'),
entry.get('prizeChest3'),
]
# If tourney doesn't include this prize, no-op.
if ranges[index] is None:
return
try:
appearance = ClassicChestAppearance(chests[index])
except ValueError:
appearance = ClassicChestAppearance.DEFAULT
chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
appearance, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
)
Image(
# Provide magical extended dict version of texture that Image
# actor supports.
texture={
'texture': bascenev1.gettexture(chestdisplayinfo.texclosed),
'tint_texture': bascenev1.gettexture(
chestdisplayinfo.texclosedtint
),
'tint_color': chestdisplayinfo.tint,
'tint2_color': chestdisplayinfo.tint2,
'mask_texture': None,
},
color=chestdisplayinfo.color + (1.0,),
position=position,
scale=(48.0, 48.0),
transition=Image.Transition.FADE_IN,
transition_delay=2.0,
).autoretain()

View file

@ -15,6 +15,8 @@ from baclassic._music import MusicPlayer
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Any from typing import Callable, Any
import bauiv1
class MacMusicAppMusicPlayer(MusicPlayer): class MacMusicAppMusicPlayer(MusicPlayer):
"""A music-player that utilizes the macOS Music.app for playback. """A music-player that utilizes the macOS Music.app for playback.
@ -33,7 +35,7 @@ class MacMusicAppMusicPlayer(MusicPlayer):
callback: Callable[[Any], None], callback: Callable[[Any], None],
current_entry: Any, current_entry: Any,
selection_target_name: str, selection_target_name: str,
) -> Any: ) -> bauiv1.MainWindow:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bauiv1lib.soundtrack import entrytypeselect as etsel from bauiv1lib.soundtrack import entrytypeselect as etsel

View file

@ -16,6 +16,8 @@ from baclassic._music import MusicPlayer
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Any from typing import Callable, Any
import bauiv1
class OSMusicPlayer(MusicPlayer): class OSMusicPlayer(MusicPlayer):
"""Music player that talks to internal C++ layer for functionality. """Music player that talks to internal C++ layer for functionality.
@ -39,7 +41,7 @@ class OSMusicPlayer(MusicPlayer):
callback: Callable[[Any], None], callback: Callable[[Any], None],
current_entry: Any, current_entry: Any,
selection_target_name: str, selection_target_name: str,
) -> Any: ) -> bauiv1.MainWindow:
# pylint: disable=cyclic-import # pylint: disable=cyclic-import
from bauiv1lib.soundtrack.entrytypeselect import ( from bauiv1lib.soundtrack.entrytypeselect import (
SoundtrackEntryTypeSelectWindow, SoundtrackEntryTypeSelectWindow,

View file

@ -31,8 +31,8 @@ class AppInterfaceIdiom(Enum):
class AppExperience(Enum): class AppExperience(Enum):
"""A particular experience that can be provided by a Ballistica app. """A particular experience that can be provided by a Ballistica app.
This is one metric used to isolate different playerbases from This is one metric used to isolate different playerbases from each
each other where there might be no technical barriers doing so. For other where there might be no technical barriers doing so. For
example, a casual one-hand-playable phone game and an augmented example, a casual one-hand-playable phone game and an augmented
reality tabletop game may both use the same scene-versions and reality tabletop game may both use the same scene-versions and
networking-protocols and whatnot, but it would make no sense to networking-protocols and whatnot, but it would make no sense to
@ -75,10 +75,10 @@ class AppArchitecture(Enum):
class AppPlatform(Enum): class AppPlatform(Enum):
"""Overall platform a Ballistica build is targeting. """Overall platform a Ballistica build is targeting.
Each distinct flavor of an app has a unique combination Each distinct flavor of an app has a unique combination of
of AppPlatform and AppVariant. Generally platform describes AppPlatform and AppVariant. Generally platform describes a set of
a set of hardware, while variant describes a destination or hardware, while variant describes a destination or purpose for the
purpose for the build. build.
""" """
MAC = 'mac' MAC = 'mac'
@ -92,10 +92,10 @@ class AppPlatform(Enum):
class AppVariant(Enum): class AppVariant(Enum):
"""A unique Ballistica build type within a single platform. """A unique Ballistica build type within a single platform.
Each distinct flavor of an app has a unique combination Each distinct flavor of an app has a unique combination of
of AppPlatform and AppVariant. Generally platform describes AppPlatform and AppVariant. Generally platform describes a set of
a set of hardware, while variant describes a destination or hardware, while variant describes a destination or purpose for the
purpose for the build. build.
""" """
# Default builds. # Default builds.

887
dist/ba_data/python/bacommon/bs.py vendored Normal file
View file

@ -0,0 +1,887 @@
# Released under the MIT License. See LICENSE for details.
#
"""BombSquad specific bits."""
from __future__ import annotations
import datetime
from enum import Enum
from dataclasses import dataclass, field
from typing import Annotated, override, assert_never
from efro.util import pairs_to_flat
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
from efro.message import Message, Response
@ioprepped
@dataclass
class PrivatePartyMessage(Message):
"""Message asking about info we need for private-party UI."""
need_datacode: Annotated[bool, IOAttrs('d')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [PrivatePartyResponse]
@ioprepped
@dataclass
class PrivatePartyResponse(Response):
"""Here's that private party UI info you asked for, boss."""
success: Annotated[bool, IOAttrs('s')]
tokens: Annotated[int, IOAttrs('t')]
gold_pass: Annotated[bool, IOAttrs('g')]
datacode: Annotated[str | None, IOAttrs('d')]
class ClassicChestAppearance(Enum):
"""Appearances bombsquad classic chests can have."""
UNKNOWN = 'u'
DEFAULT = 'd'
L1 = 'l1'
L2 = 'l2'
L3 = 'l3'
L4 = 'l4'
L5 = 'l5'
L6 = 'l6'
@property
def pretty_name(self) -> str:
"""Pretty name for the chest in English."""
# pylint: disable=too-many-return-statements
cls = type(self)
if self is cls.UNKNOWN:
return 'Unknown Chest'
if self is cls.DEFAULT:
return 'Chest'
if self is cls.L1:
return 'L1 Chest'
if self is cls.L2:
return 'L2 Chest'
if self is cls.L3:
return 'L3 Chest'
if self is cls.L4:
return 'L4 Chest'
if self is cls.L5:
return 'L5 Chest'
if self is cls.L6:
return 'L6 Chest'
assert_never(self)
@ioprepped
@dataclass
class ClassicAccountLiveData:
"""Live account data fed to the client in the bs classic app mode."""
@dataclass
class Chest:
"""A lovely chest."""
appearance: Annotated[
ClassicChestAppearance,
IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN),
]
unlock_time: Annotated[datetime.datetime, IOAttrs('t')]
ad_allow_time: Annotated[datetime.datetime | None, IOAttrs('at')]
class LeagueType(Enum):
"""Type of league we are in."""
BRONZE = 'b'
SILVER = 's'
GOLD = 'g'
DIAMOND = 'd'
tickets: Annotated[int, IOAttrs('ti')]
tokens: Annotated[int, IOAttrs('to')]
gold_pass: Annotated[bool, IOAttrs('g')]
remove_ads: Annotated[bool, IOAttrs('r')]
achievements: Annotated[int, IOAttrs('a')]
achievements_total: Annotated[int, IOAttrs('at')]
league_type: Annotated[LeagueType | None, IOAttrs('lt')]
league_num: Annotated[int | None, IOAttrs('ln')]
league_rank: Annotated[int | None, IOAttrs('lr')]
level: Annotated[int, IOAttrs('lv')]
xp: Annotated[int, IOAttrs('xp')]
xpmax: Annotated[int, IOAttrs('xpm')]
inbox_count: Annotated[int, IOAttrs('ibc')]
inbox_count_is_max: Annotated[bool, IOAttrs('ibcm')]
chests: Annotated[dict[str, Chest], IOAttrs('c')]
class DisplayItemTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
TICKETS = 't'
TOKENS = 'k'
TEST = 's'
CHEST = 'c'
class DisplayItem(IOMultiType[DisplayItemTypeID]):
"""Some amount of something that can be shown or described.
Used to depict chest contents or other rewards or prices.
"""
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(cls, type_id: DisplayItemTypeID) -> type[DisplayItem]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
t = DisplayItemTypeID
if type_id is t.UNKNOWN:
return UnknownDisplayItem
if type_id is t.TICKETS:
return TicketsDisplayItem
if type_id is t.TOKENS:
return TokensDisplayItem
if type_id is t.TEST:
return TestDisplayItem
if type_id is t.CHEST:
return ChestDisplayItem
# Important to make sure we provide all types.
assert_never(type_id)
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
"""Return a string description and subs for the item.
These decriptions are baked into the DisplayItemWrapper and
should be accessed from there when available. This allows
clients to give descriptions even for newer display items they
don't recognize.
"""
raise NotImplementedError()
# Implement fallbacks so client can digest item lists even if they
# contain unrecognized stuff. DisplayItemWrapper contains basic
# baked down info that they can still use in such cases.
@override
@classmethod
def get_unknown_type_fallback(cls) -> DisplayItem:
return UnknownDisplayItem()
@ioprepped
@dataclass
class UnknownDisplayItem(DisplayItem):
"""Something we don't know how to display."""
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.UNKNOWN
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
import logging
# Make noise but don't break.
logging.exception(
'UnknownDisplayItem.get_description() should never be called.'
' Always access descriptions on the DisplayItemWrapper.'
)
return 'Unknown', []
@ioprepped
@dataclass
class TicketsDisplayItem(DisplayItem):
"""Some amount of tickets."""
count: Annotated[int, IOAttrs('c')]
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.TICKETS
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return '${C} Tickets', [('${C}', str(self.count))]
@ioprepped
@dataclass
class TokensDisplayItem(DisplayItem):
"""Some amount of tokens."""
count: Annotated[int, IOAttrs('c')]
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.TOKENS
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return '${C} Tokens', [('${C}', str(self.count))]
@ioprepped
@dataclass
class TestDisplayItem(DisplayItem):
"""Fills usable space for a display-item - good for calibration."""
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.TEST
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return 'Test Display Item Here', []
@ioprepped
@dataclass
class ChestDisplayItem(DisplayItem):
"""Display a chest."""
appearance: Annotated[ClassicChestAppearance, IOAttrs('a')]
@override
@classmethod
def get_type_id(cls) -> DisplayItemTypeID:
return DisplayItemTypeID.CHEST
@override
def get_description(self) -> tuple[str, list[tuple[str, str]]]:
return self.appearance.pretty_name, []
@ioprepped
@dataclass
class DisplayItemWrapper:
"""Wraps a DisplayItem and common info."""
item: Annotated[DisplayItem, IOAttrs('i')]
description: Annotated[str, IOAttrs('d')]
description_subs: Annotated[list[str] | None, IOAttrs('s')]
@classmethod
def for_display_item(cls, item: DisplayItem) -> DisplayItemWrapper:
"""Convenience method to wrap a DisplayItem."""
desc, subs = item.get_description()
return DisplayItemWrapper(item, desc, pairs_to_flat(subs))
@ioprepped
@dataclass
class ChestInfoMessage(Message):
"""Request info about a chest."""
chest_id: Annotated[str, IOAttrs('i')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ChestInfoResponse]
@ioprepped
@dataclass
class ChestInfoResponse(Response):
"""Here's that chest info you asked for, boss."""
@dataclass
class Chest:
"""A lovely chest."""
@dataclass
class PrizeSet:
"""A possible set of prizes for this chest."""
weight: Annotated[float, IOAttrs('w')]
contents: Annotated[list[DisplayItemWrapper], IOAttrs('c')]
appearance: Annotated[
ClassicChestAppearance,
IOAttrs('a', enum_fallback=ClassicChestAppearance.UNKNOWN),
]
# How much it costs to unlock *now*.
unlock_tokens: Annotated[int, IOAttrs('tk')]
# When it unlocks on its own.
unlock_time: Annotated[datetime.datetime, IOAttrs('t')]
# Possible prizes we contain.
prizesets: Annotated[list[PrizeSet], IOAttrs('p')]
# Are ads allowed now?
ad_allow: Annotated[bool, IOAttrs('aa')]
chest: Annotated[Chest | None, IOAttrs('c')]
user_tokens: Annotated[int | None, IOAttrs('t')]
@ioprepped
@dataclass
class ChestActionMessage(Message):
"""Request action about a chest."""
class Action(Enum):
"""Types of actions we can request."""
# Unlocking (for free or with tokens).
UNLOCK = 'u'
# Watched an ad to reduce wait.
AD = 'ad'
action: Annotated[Action, IOAttrs('a')]
# Tokens we are paying (only applies to unlock).
token_payment: Annotated[int, IOAttrs('t')]
chest_id: Annotated[str, IOAttrs('i')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ChestActionResponse]
@ioprepped
@dataclass
class ChestActionResponse(Response):
"""Here's the results of that action you asked for, boss."""
# Tokens that were actually charged.
tokens_charged: Annotated[int, IOAttrs('t')] = 0
# If present, signifies the chest has been opened and we should show
# the user this stuff that was in it.
contents: Annotated[list[DisplayItemWrapper] | None, IOAttrs('c')] = None
# If contents are present, which of the chest's prize-sets they
# represent.
prizeindex: Annotated[int, IOAttrs('i')] = 0
# Printable error if something goes wrong.
error: Annotated[str | None, IOAttrs('e')] = None
# Printable warning. Shown in orange with an error sound. Does not
# mean the action failed; only that there's something to tell the
# users such as 'It looks like you are faking ad views; stop it or
# you won't have ad options anymore.'
warning: Annotated[str | None, IOAttrs('w')] = None
# Printable success message. Shown in green with a cash-register
# sound. Can be used for things like successful wait reductions via
# ad views.
success_msg: Annotated[str | None, IOAttrs('s')] = None
class ClientUITypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
BASIC = 'b'
class ClientUI(IOMultiType[ClientUITypeID]):
"""Defines some user interface on the client."""
@override
@classmethod
def get_type_id(cls) -> ClientUITypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(cls, type_id: ClientUITypeID) -> type[ClientUI]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
out: type[ClientUI]
t = ClientUITypeID
if type_id is t.UNKNOWN:
out = UnknownClientUI
elif type_id is t.BASIC:
out = BasicClientUI
else:
# Important to make sure we provide all types.
assert_never(type_id)
return out
@override
@classmethod
def get_unknown_type_fallback(cls) -> ClientUI:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return UnknownClientUI()
@ioprepped
@dataclass
class UnknownClientUI(ClientUI):
"""Fallback type for unrecognized entries."""
@override
@classmethod
def get_type_id(cls) -> ClientUITypeID:
return ClientUITypeID.UNKNOWN
class BasicClientUIComponentTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
TEXT = 't'
LINK = 'l'
BS_CLASSIC_TOURNEY_RESULT = 'ct'
DISPLAY_ITEMS = 'di'
EXPIRE_TIME = 'd'
class BasicClientUIComponent(IOMultiType[BasicClientUIComponentTypeID]):
"""Top level class for our multitype."""
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(
cls, type_id: BasicClientUIComponentTypeID
) -> type[BasicClientUIComponent]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
t = BasicClientUIComponentTypeID
if type_id is t.UNKNOWN:
return BasicClientUIComponentUnknown
if type_id is t.TEXT:
return BasicClientUIComponentText
if type_id is t.LINK:
return BasicClientUIComponentLink
if type_id is t.BS_CLASSIC_TOURNEY_RESULT:
return BasicClientUIBsClassicTourneyResult
if type_id is t.DISPLAY_ITEMS:
return BasicClientUIDisplayItems
if type_id is t.EXPIRE_TIME:
return BasicClientUIExpireTime
# Important to make sure we provide all types.
assert_never(type_id)
@override
@classmethod
def get_unknown_type_fallback(cls) -> BasicClientUIComponent:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return BasicClientUIComponentUnknown()
@ioprepped
@dataclass
class BasicClientUIComponentUnknown(BasicClientUIComponent):
"""An unknown basic client component type.
In practice these should never show up since the master-server
generates these on the fly for the client and so should not send
clients one they can't digest.
"""
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.UNKNOWN
@ioprepped
@dataclass
class BasicClientUIComponentText(BasicClientUIComponent):
"""Show some text in the inbox message."""
text: Annotated[str, IOAttrs('t')]
subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field(
default_factory=list
)
scale: Annotated[float, IOAttrs('sc', store_default=False)] = 1.0
color: Annotated[
tuple[float, float, float, float], IOAttrs('c', store_default=False)
] = (1.0, 1.0, 1.0, 1.0)
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.TEXT
@ioprepped
@dataclass
class BasicClientUIComponentLink(BasicClientUIComponent):
"""Show a link in the inbox message."""
url: Annotated[str, IOAttrs('u')]
label: Annotated[str, IOAttrs('l')]
subs: Annotated[list[str], IOAttrs('s', store_default=False)] = field(
default_factory=list
)
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.LINK
@ioprepped
@dataclass
class BasicClientUIBsClassicTourneyResult(BasicClientUIComponent):
"""Show info about a classic tourney."""
tournament_id: Annotated[str, IOAttrs('t')]
game: Annotated[str, IOAttrs('g')]
players: Annotated[int, IOAttrs('p')]
rank: Annotated[int, IOAttrs('r')]
trophy: Annotated[str | None, IOAttrs('tr')]
prizes: Annotated[list[DisplayItemWrapper], IOAttrs('pr')]
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.BS_CLASSIC_TOURNEY_RESULT
@ioprepped
@dataclass
class BasicClientUIDisplayItems(BasicClientUIComponent):
"""Show some display-items."""
items: Annotated[list[DisplayItemWrapper], IOAttrs('d')]
width: Annotated[float, IOAttrs('w')] = 100.0
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.DISPLAY_ITEMS
@ioprepped
@dataclass
class BasicClientUIExpireTime(BasicClientUIComponent):
"""Show expire-time."""
time: Annotated[datetime.datetime, IOAttrs('d')]
spacing_top: Annotated[float, IOAttrs('st', store_default=False)] = 0.0
spacing_bottom: Annotated[float, IOAttrs('sb', store_default=False)] = 0.0
@override
@classmethod
def get_type_id(cls) -> BasicClientUIComponentTypeID:
return BasicClientUIComponentTypeID.EXPIRE_TIME
@ioprepped
@dataclass
class BasicClientUI(ClientUI):
"""A basic UI for the client."""
class ButtonLabel(Enum):
"""Distinct button labels we support."""
UNKNOWN = 'u'
OK = 'o'
APPLY = 'a'
CANCEL = 'c'
ACCEPT = 'ac'
DECLINE = 'dn'
IGNORE = 'ig'
CLAIM = 'cl'
DISCARD = 'd'
class InteractionStyle(Enum):
"""Overall interaction styles we support."""
UNKNOWN = 'u'
BUTTON_POSITIVE = 'p'
BUTTON_POSITIVE_NEGATIVE = 'pn'
components: Annotated[list[BasicClientUIComponent], IOAttrs('s')]
interaction_style: Annotated[
InteractionStyle, IOAttrs('i', enum_fallback=InteractionStyle.UNKNOWN)
] = InteractionStyle.BUTTON_POSITIVE
button_label_positive: Annotated[
ButtonLabel, IOAttrs('p', enum_fallback=ButtonLabel.UNKNOWN)
] = ButtonLabel.OK
button_label_negative: Annotated[
ButtonLabel, IOAttrs('n', enum_fallback=ButtonLabel.UNKNOWN)
] = ButtonLabel.CANCEL
@override
@classmethod
def get_type_id(cls) -> ClientUITypeID:
return ClientUITypeID.BASIC
def contains_unknown_elements(self) -> bool:
"""Whether something within us is an unknown type or enum."""
return (
self.interaction_style is self.InteractionStyle.UNKNOWN
or self.button_label_positive is self.ButtonLabel.UNKNOWN
or self.button_label_negative is self.ButtonLabel.UNKNOWN
or any(
c.get_type_id() is BasicClientUIComponentTypeID.UNKNOWN
for c in self.components
)
)
@ioprepped
@dataclass
class ClientUIWrapper:
"""Wrapper for a ClientUI and its common data."""
id: Annotated[str, IOAttrs('i')]
createtime: Annotated[datetime.datetime, IOAttrs('c')]
ui: Annotated[ClientUI, IOAttrs('e')]
@ioprepped
@dataclass
class InboxRequestMessage(Message):
"""Message requesting our inbox."""
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [InboxRequestResponse]
@ioprepped
@dataclass
class InboxRequestResponse(Response):
"""Here's that inbox contents you asked for, boss."""
wrappers: Annotated[list[ClientUIWrapper], IOAttrs('w')]
# Printable error if something goes wrong.
error: Annotated[str | None, IOAttrs('e')] = None
class ClientUIAction(Enum):
"""Types of actions we can run."""
BUTTON_PRESS_POSITIVE = 'p'
BUTTON_PRESS_NEGATIVE = 'n'
class ClientEffectTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
SCREEN_MESSAGE = 'm'
SOUND = 's'
DELAY = 'd'
class ClientEffect(IOMultiType[ClientEffectTypeID]):
"""Something that can happen on the client.
This can include screen messages, sounds, visual effects, etc.
"""
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
# Require child classes to supply this themselves. If we did a
# full type registry/lookup here it would require us to import
# everything and would prevent lazy loading.
raise NotImplementedError()
@override
@classmethod
def get_type(cls, type_id: ClientEffectTypeID) -> type[ClientEffect]:
"""Return the subclass for each of our type-ids."""
# pylint: disable=cyclic-import
t = ClientEffectTypeID
if type_id is t.UNKNOWN:
return ClientEffectUnknown
if type_id is t.SCREEN_MESSAGE:
return ClientEffectScreenMessage
if type_id is t.SOUND:
return ClientEffectSound
if type_id is t.DELAY:
return ClientEffectDelay
# Important to make sure we provide all types.
assert_never(type_id)
@override
@classmethod
def get_unknown_type_fallback(cls) -> ClientEffect:
# If we encounter some future message type we don't know
# anything about, drop in a placeholder.
return ClientEffectUnknown()
@ioprepped
@dataclass
class ClientEffectUnknown(ClientEffect):
"""Fallback substitute for types we don't recognize."""
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.UNKNOWN
@ioprepped
@dataclass
class ClientEffectScreenMessage(ClientEffect):
"""Display a screen-message."""
message: Annotated[str, IOAttrs('m')]
subs: Annotated[list[str], IOAttrs('s')]
color: Annotated[tuple[float, float, float], IOAttrs('c')] = (1.0, 1.0, 1.0)
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.SCREEN_MESSAGE
@ioprepped
@dataclass
class ClientEffectSound(ClientEffect):
"""Play a sound."""
class Sound(Enum):
"""Sounds that can be made alongside the message."""
UNKNOWN = 'u'
CASH_REGISTER = 'c'
ERROR = 'e'
POWER_DOWN = 'p'
GUN_COCKING = 'g'
sound: Annotated[Sound, IOAttrs('s', enum_fallback=Sound.UNKNOWN)]
volume: Annotated[float, IOAttrs('v')] = 1.0
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.SOUND
@ioprepped
@dataclass
class ClientEffectDelay(ClientEffect):
"""Delay effect processing."""
seconds: Annotated[float, IOAttrs('s')]
@override
@classmethod
def get_type_id(cls) -> ClientEffectTypeID:
return ClientEffectTypeID.DELAY
@ioprepped
@dataclass
class ClientUIActionMessage(Message):
"""Do something to a client ui."""
id: Annotated[str, IOAttrs('i')]
action: Annotated[ClientUIAction, IOAttrs('a')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ClientUIActionResponse]
@ioprepped
@dataclass
class ClientUIActionResponse(Response):
"""Did something to that inbox entry, boss."""
class ErrorType(Enum):
"""Types of errors that may have occurred."""
# Probably a future error type we don't recognize.
UNKNOWN = 'u'
# Something went wrong on the server, but specifics are not
# relevant.
INTERNAL = 'i'
# The entry expired on the server. In various cases such as 'ok'
# buttons this can generally be ignored.
EXPIRED = 'e'
error_type: Annotated[
ErrorType | None, IOAttrs('et', enum_fallback=ErrorType.UNKNOWN)
]
# User facing error message in the case of errors.
error_message: Annotated[str | None, IOAttrs('em')]
effects: Annotated[list[ClientEffect], IOAttrs('fx')]
@ioprepped
@dataclass
class ScoreSubmitMessage(Message):
"""Let the server know we got some score in something."""
score_token: Annotated[str, IOAttrs('t')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [ScoreSubmitResponse]
@ioprepped
@dataclass
class ScoreSubmitResponse(Response):
"""Did something to that inbox entry, boss."""
# Things we should show on our end.
effects: Annotated[list[ClientEffect], IOAttrs('fx')]

View file

@ -3,9 +3,10 @@
"""Functionality related to cloud functionality.""" """Functionality related to cloud functionality."""
from __future__ import annotations from __future__ import annotations
from enum import Enum
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, override from typing import TYPE_CHECKING, Annotated, override
from enum import Enum
from efro.message import Message, Response from efro.message import Message, Response
from efro.dataclassio import ioprepped, IOAttrs from efro.dataclassio import ioprepped, IOAttrs
@ -299,27 +300,3 @@ class StoreQueryResponse(Response):
available_purchases: Annotated[list[Purchase], IOAttrs('p')] available_purchases: Annotated[list[Purchase], IOAttrs('p')]
token_info_url: Annotated[str, IOAttrs('tiu')] token_info_url: Annotated[str, IOAttrs('tiu')]
@ioprepped
@dataclass
class BSPrivatePartyMessage(Message):
"""Message asking about info we need for private-party UI."""
need_datacode: Annotated[bool, IOAttrs('d')]
@override
@classmethod
def get_response_types(cls) -> list[type[Response] | None]:
return [BSPrivatePartyResponse]
@ioprepped
@dataclass
class BSPrivatePartyResponse(Response):
"""Here's that private party UI info you asked for, boss."""
success: Annotated[bool, IOAttrs('s')]
tokens: Annotated[int, IOAttrs('t')]
gold_pass: Annotated[bool, IOAttrs('g')]
datacode: Annotated[str | None, IOAttrs('d')]

View file

@ -0,0 +1,212 @@
# Released under the MIT License. See LICENSE for details.
#
"""System for managing loggers."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Annotated
from dataclasses import dataclass, field
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
from typing import Self, Sequence
@ioprepped
@dataclass
class LoggerControlConfig:
"""A logging level configuration that applies to all loggers.
Any loggers not explicitly contained in the configuration will be
set to NOTSET.
"""
# Logger names mapped to log-level values (from system logging
# module).
levels: Annotated[dict[str, int], IOAttrs('l', store_default=False)] = (
field(default_factory=dict)
)
def apply(
self,
*,
warn_unexpected_loggers: bool = False,
warn_missing_loggers: bool = False,
ignore_log_prefixes: list[str] | None = None,
) -> None:
"""Apply the config to all Python loggers.
If 'warn_unexpected_loggers' is True, warnings will be issues for
any loggers not explicitly covered by the config. This is useful
to help ensure controls for all possible loggers are present in
a UI/etc.
If 'warn_missing_loggers' is True, warnings will be issued for
any loggers present in the config that are not found at apply time.
This can be useful for pruning settings for no longer used loggers.
Warnings for any log names beginning with any strings in
'ignore_log_prefixes' will be suppressed. This can allow
ignoring loggers associated with submodules for a given package
and instead presenting only a top level logger (or none at all).
"""
if ignore_log_prefixes is None:
ignore_log_prefixes = []
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
# First issue any warnings they want.
if warn_unexpected_loggers:
for logname in sorted(existinglognames):
if logname not in self.levels and not any(
logname.startswith(pre) for pre in ignore_log_prefixes
):
logging.warning(
'Found a logger not covered by LoggerControlConfig:'
" '%s'.",
logname,
)
if warn_missing_loggers:
for logname in sorted(self.levels.keys()):
if logname not in existinglognames and not any(
logname.startswith(pre) for pre in ignore_log_prefixes
):
logging.warning(
'Logger covered by LoggerControlConfig does not exist:'
' %s.',
logname,
)
# First, update levels for all existing loggers.
for logname in existinglognames:
logger = logging.getLogger(logname)
level = self.levels.get(logname)
if level is None:
level = logging.NOTSET
logger.setLevel(level)
# Next, assign levels to any loggers that don't exist.
for logname, level in self.levels.items():
if logname not in existinglognames:
logging.getLogger(logname).setLevel(level)
def sanity_check_effective_levels(self) -> None:
"""Checks existing loggers to make sure they line up with us.
This can be called periodically to ensure that a control-config
is properly driving log levels and that nothing else is changing
them behind our back.
"""
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
for logname in existinglognames:
logger = logging.getLogger(logname)
if logger.getEffectiveLevel() != self.get_effective_level(logname):
logging.error(
'loggercontrol effective-level sanity check failed;'
' expected logger %s to have effective level %s'
' but it has %s.',
logname,
logging.getLevelName(self.get_effective_level(logname)),
logging.getLevelName(logger.getEffectiveLevel()),
)
def get_effective_level(self, logname: str) -> int:
"""Given a log name, predict its level if this config is applied."""
splits = logname.split('.')
splen = len(splits)
for i in range(splen):
subname = '.'.join(splits[: splen - i])
thisval = self.levels.get(subname)
if thisval is not None and thisval != logging.NOTSET:
return thisval
# Haven't found anything; just return root value.
thisval = self.levels.get('root')
return (
logging.DEBUG
if thisval is None
else logging.DEBUG if thisval == logging.NOTSET else thisval
)
def would_make_changes(self) -> bool:
"""Return whether calling apply would change anything."""
existinglognames = (
set(['root']) | logging.root.manager.loggerDict.keys()
)
# Return True if we contain any nonexistent loggers. Even if
# we wouldn't change their level, the fact that we'd create
# them still counts as a difference.
if any(
logname not in existinglognames for logname in self.levels.keys()
):
return True
# Now go through all existing loggers and return True if we
# would change their level.
for logname in existinglognames:
logger = logging.getLogger(logname)
level = self.levels.get(logname)
if level is None:
level = logging.NOTSET
if logger.level != level:
return True
return False
def diff(self, baseconfig: LoggerControlConfig) -> LoggerControlConfig:
"""Return a config containing only changes compared to a base config.
Note that this omits all NOTSET values that resolve to NOTSET in
the base config.
This diffed config can later be used with apply_diff() against the
base config to recreate the state represented by self.
"""
cls = type(self)
config = cls()
for loggername, level in self.levels.items():
baselevel = baseconfig.levels.get(loggername, logging.NOTSET)
if level != baselevel:
config.levels[loggername] = level
return config
def apply_diff(
self, diffconfig: LoggerControlConfig
) -> LoggerControlConfig:
"""Apply a diff config to ourself.
Note that values that resolve to NOTSET are left intact in the
output config. This is so all loggers expected by either the
base or diff config to exist can be created if desired/etc.
"""
cls = type(self)
# Create a new config (with an indepenent levels dict copy).
config = cls(levels=dict(self.levels))
# Overlay the diff levels dict onto our new one.
config.levels.update(diffconfig.levels)
# Note: we do NOT prune NOTSET values here. This is so all
# loggers mentioned in the base config get created if we are
# applied, even if they are assigned a default level.
return config
@classmethod
def from_current_loggers(cls) -> Self:
"""Build a config from the current set of loggers."""
lognames = ['root'] + sorted(logging.root.manager.loggerDict)
config = cls()
for logname in lognames:
config.levels[logname] = logging.getLogger(logname).level
return config

27
dist/ba_data/python/bacommon/logging.py vendored Normal file
View file

@ -0,0 +1,27 @@
# Released under the MIT License. See LICENSE for details.
#
"""Logging functionality."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from bacommon.loggercontrol import LoggerControlConfig
if TYPE_CHECKING:
pass
def get_base_logger_control_config_client() -> LoggerControlConfig:
"""Return the logger-control-config used by the ballistica client.
This should remain consistent since local logger configurations
are stored relative to this.
"""
# By default, go with WARNING on everything to keep things mostly
# clean but show INFO for ba.app to get basic app startup messages
# and whatnot.
return LoggerControlConfig(
levels={'root': logging.WARNING, 'ba.app': logging.INFO}
)

View file

@ -186,6 +186,11 @@ class ServerConfig:
# involving leaving and rejoining or switching teams rapidly. # involving leaving and rejoining or switching teams rapidly.
player_rejoin_cooldown: float = 10.0 player_rejoin_cooldown: float = 10.0
# Log levels for particular loggers, overriding the engine's
# defaults. Valid values are NOTSET, DEBUG, INFO, WARNING, ERROR, or
# CRITICAL.
log_levels: dict[str, str] | None = None
# NOTE: as much as possible, communication from the server-manager to # NOTE: as much as possible, communication from the server-manager to
# the child-process should go through these and not ad-hoc Python string # the child-process should go through these and not ad-hoc Python string

View file

@ -18,6 +18,7 @@ from __future__ import annotations
import os import os
import sys import sys
import time
import logging import logging
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
@ -27,7 +28,7 @@ import __main__
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any from typing import Any
from efro.log import LogHandler from efro.logging import LogHandler
# IMPORTANT - It is likely (and in some cases expected) that this # IMPORTANT - It is likely (and in some cases expected) that this
# module's code will be exec'ed multiple times. This is because it is # module's code will be exec'ed multiple times. This is because it is
@ -52,7 +53,7 @@ if TYPE_CHECKING:
# Build number and version of the ballistica binary we expect to be # Build number and version of the ballistica binary we expect to be
# using. # using.
TARGET_BALLISTICA_BUILD = 21949 TARGET_BALLISTICA_BUILD = 22278
TARGET_BALLISTICA_VERSION = '1.7.37' TARGET_BALLISTICA_VERSION = '1.7.37'
@ -87,14 +88,13 @@ class EnvConfig:
# stderr into the engine so they show up on in-app consoles, etc. # stderr into the engine so they show up on in-app consoles, etc.
log_handler: LogHandler | None log_handler: LogHandler | None
# Initial data from the ballisticakit-config.json file. This is # Initial data from the config.json file in the config dir. The
# passed mostly as an optimization to avoid reading the same config # config file is parsed by
# file twice, since config data is first needed in baenv and next in
# the engine. It will be cleared after passing it to the app's
# config management subsystem and should not be accessed by any
# other code.
initial_app_config: Any initial_app_config: Any
# Timestamp when we first started doing stuff.
launch_time: float
@dataclass @dataclass
class _EnvGlobals: class _EnvGlobals:
@ -156,6 +156,7 @@ def get_config() -> EnvConfig:
def configure( def configure(
*,
config_dir: str | None = None, config_dir: str | None = None,
data_dir: str | None = None, data_dir: str | None = None,
user_python_dir: str | None = None, user_python_dir: str | None = None,
@ -171,6 +172,11 @@ def configure(
are imported; the environment is locked in as soon as that happens. are imported; the environment is locked in as soon as that happens.
""" """
# Measure when we start doing this stuff. We plug this in to show
# relative times in our log timestamp displays and also pass this to
# the engine to do the same there.
launch_time = time.time()
envglobals = _EnvGlobals.get() envglobals = _EnvGlobals.get()
# Keep track of whether we've been *called*, not whether a config # Keep track of whether we've been *called*, not whether a config
@ -205,11 +211,19 @@ def configure(
config_dir, config_dir,
) )
# The second thing we do is set up our logging system and pipe # Set up our log-handler and pipe Python's stdout/stderr into it.
# Python's stdout/stderr into it. At this point we can at least # Later, once the engine comes up, the handler will feed its logs
# debug problems on systems where native stdout/stderr is not easily # (including cached history) to the os-specific output location.
# accessible such as Android. # This means anything printed or logged at this point forward should
log_handler = _setup_logging() if setup_logging else None # be visible on all platforms.
log_handler = _create_log_handler(launch_time) if setup_logging else None
# Load the raw app-config dict.
app_config = _read_app_config(os.path.join(config_dir, 'config.json'))
# Set logging levels to stored values or defaults.
if setup_logging:
_set_log_levels(app_config)
# We want to always be run in UTF-8 mode; complain if we're not. # We want to always be run in UTF-8 mode; complain if we're not.
if sys.flags.utf8_mode != 1: if sys.flags.utf8_mode != 1:
@ -234,10 +248,46 @@ def configure(
site_python_dir=site_python_dir, site_python_dir=site_python_dir,
log_handler=log_handler, log_handler=log_handler,
is_user_app_python_dir=is_user_app_python_dir, is_user_app_python_dir=is_user_app_python_dir,
initial_app_config=None, initial_app_config=app_config,
launch_time=launch_time,
) )
def _read_app_config(config_file_path: str) -> dict:
"""Read the app config."""
import json
config: dict | Any
config_contents = ''
try:
if os.path.exists(config_file_path):
with open(config_file_path, encoding='utf-8') as infile:
config_contents = infile.read()
config = json.loads(config_contents)
if not isinstance(config, dict):
raise RuntimeError('Got non-dict for config root.')
else:
config = {}
except Exception:
logging.exception(
"Error reading config file '%s'.\n"
"Backing up broken config to'%s.broken'.",
config_file_path,
config_file_path,
)
try:
import shutil
shutil.copyfile(config_file_path, config_file_path + '.broken')
except Exception:
logging.exception('Error copying broken config.')
config = {}
return config
def _calc_data_dir(data_dir: str | None) -> str: def _calc_data_dir(data_dir: str | None) -> str:
if data_dir is None: if data_dir is None:
# To calc default data_dir, we assume this module was imported # To calc default data_dir, we assume this module was imported
@ -261,23 +311,58 @@ def _calc_data_dir(data_dir: str | None) -> str:
return data_dir return data_dir
def _setup_logging() -> LogHandler: def _create_log_handler(launch_time: float) -> LogHandler:
from efro.log import setup_logging, LogLevel from efro.logging import setup_logging, LogLevel
# TODO: should set this up with individual loggers under a top level
# 'ba' logger, and at that point we can kill off the
# suppress_non_root_debug option since we'll only ever need to set
# 'ba' to DEBUG at most.
log_handler = setup_logging( log_handler = setup_logging(
log_path=None, log_path=None,
level=LogLevel.DEBUG, level=LogLevel.INFO,
suppress_non_root_debug=True,
log_stdout_stderr=True, log_stdout_stderr=True,
cache_size_limit=1024 * 1024, cache_size_limit=1024 * 1024,
launch_time=launch_time,
) )
return log_handler return log_handler
def _set_log_levels(app_config: dict) -> None:
from bacommon.logging import get_base_logger_control_config_client
from bacommon.loggercontrol import LoggerControlConfig
try:
config = app_config.get('Log Levels', None)
if config is None:
get_base_logger_control_config_client().apply()
return
# Make sure data is expected types/values since this is user
# editable.
valid_levels = {
logging.NOTSET,
logging.DEBUG,
logging.INFO,
logging.WARNING,
logging.ERROR,
logging.CRITICAL,
}
for logname, loglevel in config.items():
if (
not isinstance(logname, str)
or not logname
or not isinstance(loglevel, int)
or not loglevel in valid_levels
):
raise ValueError("Invalid 'Log Levels' data read from config.")
get_base_logger_control_config_client().apply_diff(
LoggerControlConfig(levels=config)
).apply()
except Exception:
logging.exception('Error setting log levels.')
def _setup_certs(contains_python_dist: bool) -> None: def _setup_certs(contains_python_dist: bool) -> None:
# In situations where we're bringing our own Python, let's also # In situations where we're bringing our own Python, let's also
# provide our own root certs so ssl works. We can consider # provide our own root certs so ssl works. We can consider

View file

@ -5,23 +5,23 @@
This code concerns sensitive things like accounts and master-server This code concerns sensitive things like accounts and master-server
communication so the native C++ parts of it remain closed. Native communication so the native C++ parts of it remain closed. Native
precompiled static libraries of this portion are provided for those who precompiled static libraries of this portion are provided for those who
want to compile the rest of the engine, and a fully open-source engine want to compile the rest of the engine, or a fully open-source app
can also be built by removing this 'plus' feature-set. can also be built by removing this feature-set.
""" """
from __future__ import annotations from __future__ import annotations
# Note: there's not much here. # Note: there's not much here. Most interaction with this feature-set
# All comms with this feature-set should go through app.plus. # should go through ba*.app.plus.
import logging import logging
from baplus._cloud import CloudSubsystem from baplus._cloud import CloudSubsystem
from baplus._subsystem import PlusSubsystem from baplus._appsubsystem import PlusAppSubsystem
__all__ = [ __all__ = [
'CloudSubsystem', 'CloudSubsystem',
'PlusSubsystem', 'PlusAppSubsystem',
] ]
# Sanity check: we want to keep ballistica's dependencies and # Sanity check: we want to keep ballistica's dependencies and

View file

@ -12,12 +12,13 @@ import _baplus
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Callable, Any from typing import Callable, Any
import bacommon.bs
from babase import AccountV2Subsystem from babase import AccountV2Subsystem
from baplus._cloud import CloudSubsystem from baplus._cloud import CloudSubsystem
class PlusSubsystem(AppSubsystem): class PlusAppSubsystem(AppSubsystem):
"""Subsystem for plus functionality in the app. """Subsystem for plus functionality in the app.
The single shared instance of this app can be accessed at The single shared instance of this app can be accessed at
@ -40,7 +41,6 @@ class PlusSubsystem(AppSubsystem):
_baplus.on_app_loading() _baplus.on_app_loading()
self.accounts.on_app_loading() self.accounts.on_app_loading()
# noinspection PyUnresolvedReferences
@staticmethod @staticmethod
def add_v1_account_transaction( def add_v1_account_transaction(
transaction: dict, callback: Callable | None = None transaction: dict, callback: Callable | None = None
@ -66,9 +66,9 @@ class PlusSubsystem(AppSubsystem):
return _baplus.get_master_server_address(source, version) return _baplus.get_master_server_address(source, version)
@staticmethod @staticmethod
def get_news_show() -> str: def get_classic_news_show() -> str:
"""(internal)""" """(internal)"""
return _baplus.get_news_show() return _baplus.get_classic_news_show()
@staticmethod @staticmethod
def get_price(item: str) -> str | None: def get_price(item: str) -> str | None:
@ -76,14 +76,14 @@ class PlusSubsystem(AppSubsystem):
return _baplus.get_price(item) return _baplus.get_price(item)
@staticmethod @staticmethod
def get_purchased(item: str) -> bool: def get_v1_account_product_purchased(item: str) -> bool:
"""(internal)""" """(internal)"""
return _baplus.get_purchased(item) return _baplus.get_v1_account_product_purchased(item)
@staticmethod @staticmethod
def get_purchases_state() -> int: def get_v1_account_product_purchases_state() -> int:
"""(internal)""" """(internal)"""
return _baplus.get_purchases_state() return _baplus.get_v1_account_product_purchases_state()
@staticmethod @staticmethod
def get_v1_account_display_string(full: bool = True) -> str: def get_v1_account_display_string(full: bool = True) -> str:
@ -129,7 +129,7 @@ class PlusSubsystem(AppSubsystem):
def get_v1_account_ticket_count() -> int: def get_v1_account_ticket_count() -> int:
"""(internal) """(internal)
Returns the number of tickets for the current account. Return the number of tickets for the current account.
""" """
return _baplus.get_v1_account_ticket_count() return _baplus.get_v1_account_ticket_count()
@ -221,6 +221,7 @@ class PlusSubsystem(AppSubsystem):
name: Any, name: Any,
score: int | None, score: int | None,
callback: Callable, callback: Callable,
*,
order: str = 'increasing', order: str = 'increasing',
tournament_id: str | None = None, tournament_id: str | None = None,
score_type: str = 'points', score_type: str = 'points',

View file

@ -7,6 +7,7 @@ from __future__ import annotations
import logging import logging
from typing import TYPE_CHECKING, overload from typing import TYPE_CHECKING, overload
from efro.call import CallbackSet
import babase import babase
if TYPE_CHECKING: if TYPE_CHECKING:
@ -14,8 +15,8 @@ if TYPE_CHECKING:
from efro.message import Message, Response from efro.message import Message, Response
import bacommon.cloud import bacommon.cloud
import bacommon.bs
DEBUG_LOG = False
# TODO: Should make it possible to define a protocol in bacommon.cloud and # TODO: Should make it possible to define a protocol in bacommon.cloud and
# autogenerate this. That would give us type safety between this and # autogenerate this. That would give us type safety between this and
@ -25,6 +26,12 @@ DEBUG_LOG = False
class CloudSubsystem(babase.AppSubsystem): class CloudSubsystem(babase.AppSubsystem):
"""Manages communication with cloud components.""" """Manages communication with cloud components."""
def __init__(self) -> None:
super().__init__()
self.on_connectivity_changed_callbacks: CallbackSet[
Callable[[bool], None]
] = CallbackSet()
@property @property
def connected(self) -> bool: def connected(self) -> bool:
"""Property equivalent of CloudSubsystem.is_connected().""" """Property equivalent of CloudSubsystem.is_connected()."""
@ -40,15 +47,17 @@ class CloudSubsystem(babase.AppSubsystem):
def on_connectivity_changed(self, connected: bool) -> None: def on_connectivity_changed(self, connected: bool) -> None:
"""Called when cloud connectivity state changes.""" """Called when cloud connectivity state changes."""
if DEBUG_LOG: babase.balog.debug('Connectivity is now %s.', connected)
logging.debug('CloudSubsystem: Connectivity is now %s.', connected)
plus = babase.app.plus plus = babase.app.plus
assert plus is not None assert plus is not None
# Inform things that use this. # Fire any registered callbacks for this.
# (TODO: should generalize this into some sort of registration system) for call in self.on_connectivity_changed_callbacks.getcalls():
plus.accounts.on_cloud_connectivity_changed(connected) try:
call(connected)
except Exception:
logging.exception('Error in connectivity-changed callback.')
@overload @overload
def send_message_cb( def send_message_cb(
@ -112,9 +121,54 @@ class CloudSubsystem(babase.AppSubsystem):
@overload @overload
def send_message_cb( def send_message_cb(
self, self,
msg: bacommon.cloud.BSPrivatePartyMessage, msg: bacommon.bs.PrivatePartyMessage,
on_response: Callable[ on_response: Callable[
[bacommon.cloud.BSPrivatePartyResponse | Exception], None [bacommon.bs.PrivatePartyResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.InboxRequestMessage,
on_response: Callable[
[bacommon.bs.InboxRequestResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ClientUIActionMessage,
on_response: Callable[
[bacommon.bs.ClientUIActionResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ChestInfoMessage,
on_response: Callable[
[bacommon.bs.ChestInfoResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ChestActionMessage,
on_response: Callable[
[bacommon.bs.ChestActionResponse | Exception], None
],
) -> None: ...
@overload
def send_message_cb(
self,
msg: bacommon.bs.ScoreSubmitMessage,
on_response: Callable[
[bacommon.bs.ScoreSubmitResponse | Exception], None
], ],
) -> None: ... ) -> None: ...
@ -128,14 +182,8 @@ class CloudSubsystem(babase.AppSubsystem):
The provided on_response call will be run in the logic thread The provided on_response call will be run in the logic thread
and passed either the response or the error that occurred. and passed either the response or the error that occurred.
""" """
raise NotImplementedError(
del msg # Unused. 'Cloud functionality is not present in this build.'
babase.pushcall(
babase.Call(
on_response,
RuntimeError('Cloud functionality is not available.'),
)
) )
@overload @overload
@ -158,7 +206,9 @@ class CloudSubsystem(babase.AppSubsystem):
Must be called from a background thread. Must be called from a background thread.
""" """
raise RuntimeError('Cloud functionality is not available.') raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
@overload @overload
async def send_message_async( async def send_message_async(
@ -175,7 +225,35 @@ class CloudSubsystem(babase.AppSubsystem):
Must be called from the logic thread. Must be called from the logic thread.
""" """
raise RuntimeError('Cloud functionality is not available.') raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def subscribe_test(
self, updatecall: Callable[[int | None], None]
) -> babase.CloudSubscription:
"""Subscribe to some test data."""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def subscribe_classic_account_data(
self,
updatecall: Callable[[bacommon.bs.ClassicAccountLiveData], None],
) -> babase.CloudSubscription:
"""Subscribe to classic account data."""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def unsubscribe(self, subscription_id: int) -> None:
"""Unsubscribe from some subscription.
Do not call this manually; it is called by CloudSubscription.
"""
raise NotImplementedError(
'Cloud functionality is not present in this build.'
)
def cloud_console_exec(code: str) -> None: def cloud_console_exec(code: str) -> None:

View file

@ -1,8 +1,8 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
"""Ballistica scene api version 1. Basically all gameplay related code.""" """Gameplay-centric api for classic BombSquad."""
# ba_meta require api 8 # ba_meta require api 9
# The stuff we expose here at the top level is our 'public' api for use # The stuff we expose here at the top level is our 'public' api for use
# from other modules/packages. Code *within* this package should import # from other modules/packages. Code *within* this package should import
@ -18,6 +18,7 @@ import logging
from efro.util import set_canonical_module_names from efro.util import set_canonical_module_names
from babase import ( from babase import (
add_clean_frame_callback,
app, app,
AppIntent, AppIntent,
AppIntentDefault, AppIntentDefault,
@ -149,7 +150,6 @@ from _bascenev1 import (
from bascenev1._activity import Activity from bascenev1._activity import Activity
from bascenev1._activitytypes import JoinActivity, ScoreScreenActivity from bascenev1._activitytypes import JoinActivity, ScoreScreenActivity
from bascenev1._actor import Actor from bascenev1._actor import Actor
from bascenev1._appmode import SceneV1AppMode
from bascenev1._campaign import init_campaigns, Campaign from bascenev1._campaign import init_campaigns, Campaign
from bascenev1._collision import Collision, getcollision from bascenev1._collision import Collision, getcollision
from bascenev1._coopgame import CoopGameActivity from bascenev1._coopgame import CoopGameActivity
@ -249,6 +249,7 @@ __all__ = [
'Actor', 'Actor',
'animate', 'animate',
'animate_array', 'animate_array',
'add_clean_frame_callback',
'app', 'app',
'AppIntent', 'AppIntent',
'AppIntentDefault', 'AppIntentDefault',
@ -410,7 +411,6 @@ __all__ = [
'seek_replay', 'seek_replay',
'safecolor', 'safecolor',
'screenmessage', 'screenmessage',
'SceneV1AppMode',
'ScoreConfig', 'ScoreConfig',
'ScoreScreenActivity', 'ScoreScreenActivity',
'ScoreType', 'ScoreType',

View file

@ -1,60 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING, override
from bacommon.app import AppExperience
from babase import (
app,
AppMode,
AppIntentExec,
AppIntentDefault,
invoke_main_menu,
)
import _bascenev1
if TYPE_CHECKING:
from babase import AppIntent
class SceneV1AppMode(AppMode):
"""Our app-mode."""
@override
@classmethod
def get_app_experience(cls) -> AppExperience:
return AppExperience.MELEE
@override
@classmethod
def _supports_intent(cls, intent: AppIntent) -> bool:
# We support default and exec intents currently.
return isinstance(intent, AppIntentExec | AppIntentDefault)
@override
def handle_intent(self, intent: AppIntent) -> None:
if isinstance(intent, AppIntentExec):
_bascenev1.handle_app_intent_exec(intent.code)
return
assert isinstance(intent, AppIntentDefault)
_bascenev1.handle_app_intent_default()
@override
def on_activate(self) -> None:
# Let the native layer do its thing.
_bascenev1.on_app_mode_activate()
@override
def on_deactivate(self) -> None:
# Let the native layer do its thing.
_bascenev1.on_app_mode_deactivate()
@override
def on_app_active_changed(self) -> None:
# If we've gone inactive, bring up the main menu, which has the
# side effect of pausing the action (when possible).
if not app.active:
invoke_main_menu()

View file

@ -64,36 +64,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
# (unless overridden by the map). # (unless overridden by the map).
default_music: bascenev1.MusicType | None = None default_music: bascenev1.MusicType | None = None
@classmethod
def create_settings_ui(
cls,
sessiontype: type[bascenev1.Session],
settings: dict | None,
completion_call: Callable[[dict | None], None],
) -> None:
"""Launch an in-game UI to configure settings for a game type.
'sessiontype' should be the bascenev1.Session class the game will
be used in.
'settings' should be an existing settings dict (implies 'edit'
ui mode) or None (implies 'add' ui mode).
'completion_call' will be called with a filled-out settings dict on
success or None on cancel.
Generally subclasses don't need to override this; if they override
bascenev1.GameActivity.get_available_settings() and
bascenev1.GameActivity.get_supported_maps() they can just rely on
the default implementation here which calls those methods.
"""
assert babase.app.classic is not None
delegate = babase.app.classic.delegate
assert delegate is not None
delegate.create_default_game_settings_ui(
cls, sessiontype, settings, completion_call
)
@classmethod @classmethod
def getscoreconfig(cls) -> bascenev1.ScoreConfig: def getscoreconfig(cls) -> bascenev1.ScoreConfig:
"""Return info about game scoring setup; can be overridden by games.""" """Return info about game scoring setup; can be overridden by games."""
@ -238,8 +208,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
"""Instantiate the Activity.""" """Instantiate the Activity."""
super().__init__(settings) super().__init__(settings)
plus = babase.app.plus
# Holds some flattened info about the player set at the point # Holds some flattened info about the player set at the point
# when on_begin() is called. # when on_begin() is called.
self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None self.initialplayerinfos: list[bascenev1.PlayerInfo] | None = None
@ -269,23 +237,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
None None
) )
self._zoom_message_times: dict[int, float] = {} self._zoom_message_times: dict[int, float] = {}
self._is_waiting_for_continue = False
self._continue_cost = (
25
if plus is None
else plus.get_v1_account_misc_read_val('continueStartCost', 25)
)
self._continue_cost_mult = (
2
if plus is None
else plus.get_v1_account_misc_read_val('continuesMult', 2)
)
self._continue_cost_offset = (
0
if plus is None
else plus.get_v1_account_misc_read_val('continuesOffset', 0)
)
@property @property
def map(self) -> _map.Map: def map(self) -> _map.Map:
@ -392,103 +343,6 @@ class GameActivity(Activity[PlayerT, TeamT]):
if music is not None: if music is not None:
_music.setmusic(music) _music.setmusic(music)
def on_continue(self) -> None:
"""
This is called if a game supports and offers a continue and the player
accepts. In this case the player should be given an extra life or
whatever is relevant to keep the game going.
"""
def _continue_choice(self, do_continue: bool) -> None:
plus = babase.app.plus
assert plus is not None
self._is_waiting_for_continue = False
if self.has_ended():
return
with self.context:
if do_continue:
_bascenev1.getsound('shieldUp').play()
_bascenev1.getsound('cashRegister').play()
plus.add_v1_account_transaction(
{'type': 'CONTINUE', 'cost': self._continue_cost}
)
if plus is not None:
plus.run_v1_account_transactions()
self._continue_cost = (
self._continue_cost * self._continue_cost_mult
+ self._continue_cost_offset
)
self.on_continue()
else:
self.end_game()
def is_waiting_for_continue(self) -> bool:
"""Returns whether or not this activity is currently waiting for the
player to continue (or timeout)"""
return self._is_waiting_for_continue
def continue_or_end_game(self) -> None:
"""If continues are allowed, prompts the player to purchase a continue
and calls either end_game or continue_game depending on the result
"""
# pylint: disable=too-many-nested-blocks
# pylint: disable=cyclic-import
from bascenev1._coopsession import CoopSession
classic = babase.app.classic
assert classic is not None
continues_window = classic.continues_window
# Turning these off. I want to migrate towards monetization that
# feels less pay-to-win-ish.
allow_continues = False
plus = babase.app.plus
try:
if (
plus is not None
and plus.get_v1_account_misc_read_val('enableContinues', False)
and allow_continues
):
session = self.session
# We only support continuing in non-tournament games.
tournament_id = session.tournament_id
if tournament_id is None:
# We currently only support continuing in sequential
# co-op campaigns.
if isinstance(session, CoopSession):
assert session.campaign is not None
if session.campaign.sequential:
gnode = self.globalsnode
# Only attempt this if we're not currently paused
# and there appears to be no UI.
assert babase.app.classic is not None
hmmw = babase.app.ui_v1.has_main_menu_window()
if not gnode.paused and not hmmw:
self._is_waiting_for_continue = True
with babase.ContextRef.empty():
babase.apptimer(
0.5,
lambda: continues_window(
self,
self._continue_cost,
continue_call=babase.WeakCall(
self._continue_choice, True
),
cancel_call=babase.WeakCall(
self._continue_choice, False
),
),
)
return
except Exception:
logging.exception('Error handling continues.')
self.end_game()
@override @override
def on_begin(self) -> None: def on_begin(self) -> None:
super().on_begin() super().on_begin()
@ -1277,6 +1131,7 @@ class GameActivity(Activity[PlayerT, TeamT]):
def show_zoom_message( def show_zoom_message(
self, self,
message: babase.Lstr, message: babase.Lstr,
*,
color: Sequence[float] = (0.9, 0.4, 0.0), color: Sequence[float] = (0.9, 0.4, 0.0),
scale: float = 0.8, scale: float = 0.8,
duration: float = 2.0, duration: float = 2.0,

View file

@ -4,6 +4,7 @@
from __future__ import annotations from __future__ import annotations
import random
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING, NewType from typing import TYPE_CHECKING, NewType
@ -111,6 +112,7 @@ def animate_array(
attr: str, attr: str,
size: int, size: int,
keys: dict[float, Sequence[float]], keys: dict[float, Sequence[float]],
*,
loop: bool = False, loop: bool = False,
offset: float = 0, offset: float = 0,
) -> None: ) -> None:
@ -243,7 +245,6 @@ def cameraflash(duration: float = 999.0) -> None:
Duration is in seconds. Duration is in seconds.
""" """
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
import random
from bascenev1._nodeactor import NodeActor from bascenev1._nodeactor import NodeActor
x_spread = 10 x_spread = 10

View file

@ -27,6 +27,7 @@ class Level:
gametype: type[bascenev1.GameActivity], gametype: type[bascenev1.GameActivity],
settings: dict, settings: dict,
preview_texture_name: str, preview_texture_name: str,
*,
displayname: str | None = None, displayname: str | None = None,
): ):
self._name = name self._name = name

View file

@ -588,10 +588,11 @@ class Chooser:
# Handle '_edit' as a special case. # Handle '_edit' as a special case.
if profilename == '_edit' and ready: if profilename == '_edit' and ready:
with babase.ContextRef.empty(): with babase.ContextRef.empty():
classic.profile_browser_window(in_main_menu=False)
# Give their input-device UI ownership too classic.profile_browser_window()
# (prevent someone else from snatching it in crowded games)
# Give their input-device UI ownership too (prevent
# someone else from snatching it in crowded games).
babase.set_ui_input_device(self._sessionplayer.inputdevice.id) babase.set_ui_input_device(self._sessionplayer.inputdevice.id)
return return

View file

@ -241,6 +241,7 @@ class HitMessage:
def __init__( def __init__(
self, self,
*,
srcnode: bascenev1.Node | None = None, srcnode: bascenev1.Node | None = None,
pos: Sequence[float] | None = None, pos: Sequence[float] | None = None,
velocity: Sequence[float] | None = None, velocity: Sequence[float] | None = None,

View file

@ -21,6 +21,7 @@ PlaylistType = list[dict[str, Any]]
def filter_playlist( def filter_playlist(
playlist: PlaylistType, playlist: PlaylistType,
sessiontype: type[Session], sessiontype: type[Session],
*,
add_resolved_type: bool = False, add_resolved_type: bool = False,
remove_unowned: bool = True, remove_unowned: bool = True,
mark_unowned: bool = False, mark_unowned: bool = False,

View file

@ -96,6 +96,7 @@ class Session:
def __init__( def __init__(
self, self,
depsets: Sequence[bascenev1.DependencySet], depsets: Sequence[bascenev1.DependencySet],
*,
team_names: Sequence[str] | None = None, team_names: Sequence[str] | None = None,
team_colors: Sequence[Sequence[float]] | None = None, team_colors: Sequence[Sequence[float]] | None = None,
min_players: int = 1, min_players: int = 1,

View file

@ -196,6 +196,7 @@ class PlayerRecord:
scale2: float, scale2: float,
sound2: bascenev1.Sound | None, sound2: bascenev1.Sound | None,
) -> None: ) -> None:
# pylint: disable=too-many-positional-arguments
from bascenev1lib.actor.popuptext import PopupText from bascenev1lib.actor.popuptext import PopupText
# Only award this if they're still alive and we can get # Only award this if they're still alive and we can get
@ -341,6 +342,7 @@ class Stats:
self, self,
player: bascenev1.Player, player: bascenev1.Player,
base_points: int = 1, base_points: int = 1,
*,
target: Sequence[float] | None = None, target: Sequence[float] | None = None,
kill: bool = False, kill: bool = False,
victim_player: bascenev1.Player | None = None, victim_player: bascenev1.Player | None = None,

View file

@ -2,4 +2,4 @@
# #
"""Library of stuff using the bascenev1 api: games, actors, etc.""" """Library of stuff using the bascenev1 api: games, actors, etc."""
# ba_meta require api 8 # ba_meta require api 9

View file

@ -9,6 +9,8 @@ import random
import logging import logging
from typing import TYPE_CHECKING, override from typing import TYPE_CHECKING, override
from efro.util import strict_partial
import bacommon.bs
from bacommon.login import LoginType from bacommon.login import LoginType
import bascenev1 as bs import bascenev1 as bs
import bauiv1 as bui import bauiv1 as bui
@ -19,9 +21,6 @@ from bascenev1lib.actor.zoomtext import ZoomText
if TYPE_CHECKING: if TYPE_CHECKING:
from typing import Any, Sequence from typing import Any, Sequence
from bauiv1lib.store.button import StoreButton
from bauiv1lib.league.rankbutton import LeagueRankButton
class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]): class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
"""Score screen showing the results of a cooperative game.""" """Score screen showing the results of a cooperative game."""
@ -105,10 +104,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
# Ui bits. # Ui bits.
self._corner_button_offs: tuple[float, float] | None = None self._corner_button_offs: tuple[float, float] | None = None
self._league_rank_button: LeagueRankButton | None = None
self._store_button_instance: StoreButton | None = None
self._restart_button: bui.Widget | None = None self._restart_button: bui.Widget | None = None
self._update_corner_button_positions_timer: bui.AppTimer | None = None
self._next_level_error: bs.Actor | None = None self._next_level_error: bs.Actor | None = None
# Score/gameplay bits. # Score/gameplay bits.
@ -207,20 +203,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
) )
def _ui_menu(self) -> None: def _ui_menu(self) -> None:
from bauiv1lib import specialoffer
if specialoffer.show_offer():
return
bui.containerwidget(edit=self._root_ui, transition='out_left') bui.containerwidget(edit=self._root_ui, transition='out_left')
with self.context: with self.context:
bs.timer(0.1, bs.Call(bs.WeakCall(self.session.end))) bs.timer(0.1, bs.Call(bs.WeakCall(self.session.end)))
def _ui_restart(self) -> None: def _ui_restart(self) -> None:
from bauiv1lib.tournamententry import TournamentEntryWindow from bauiv1lib.tournamententry import TournamentEntryWindow
from bauiv1lib import specialoffer
if specialoffer.show_offer():
return
# If we're in a tournament and it looks like there's no time left, # If we're in a tournament and it looks like there's no time left,
# disallow. # disallow.
@ -268,10 +256,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
self.end({'outcome': 'restart'}) self.end({'outcome': 'restart'})
def _ui_next(self) -> None: def _ui_next(self) -> None:
from bauiv1lib.specialoffer import show_offer
if show_offer():
return
# If we didn't just complete this level but are choosing to play the # If we didn't just complete this level but are choosing to play the
# next one, set it as current (this won't happen otherwise). # next one, set it as current (this won't happen otherwise).
@ -331,23 +315,30 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
) )
def _should_show_worlds_best_button(self) -> bool: def _should_show_worlds_best_button(self) -> bool:
# Old high score lists webpage for tourneys seems broken
# (looking at meteor shower at least).
if self.session.tournament_id is not None:
return False
# Link is too complicated to display with no browser. # Link is too complicated to display with no browser.
return bui.is_browser_likely_available() return bui.is_browser_likely_available()
def request_ui(self) -> None: def request_ui(self) -> None:
"""Set up a callback to show our UI at the next opportune time.""" """Set up a callback to show our UI at the next opportune time."""
assert bui.app.classic is not None classic = bui.app.classic
assert classic is not None
# We don't want to just show our UI in case the user already has the # We don't want to just show our UI in case the user already has the
# main menu up, so instead we add a callback for when the menu # main menu up, so instead we add a callback for when the menu
# closes; if we're still alive, we'll come up then. # closes; if we're still alive, we'll come up then.
# If there's no main menu this gets called immediately. # If there's no main menu this gets called immediately.
bui.app.ui_v1.add_main_menu_close_callback(bui.WeakCall(self.show_ui)) classic.add_main_menu_close_callback(bui.WeakCall(self.show_ui))
def show_ui(self) -> None: def show_ui(self) -> None:
"""Show the UI for restarting, playing the next Level, etc.""" """Show the UI for restarting, playing the next Level, etc."""
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
from bauiv1lib.store.button import StoreButton # pylint: disable=too-many-statements
from bauiv1lib.league.rankbutton import LeagueRankButton # pylint: disable=too-many-branches
assert bui.app.classic is not None assert bui.app.classic is not None
@ -361,11 +352,14 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
return return
rootc = self._root_ui = bui.containerwidget( rootc = self._root_ui = bui.containerwidget(
size=(0, 0), transition='in_right' size=(0, 0),
transition='in_right',
toolbar_visibility='no_menu_minimal',
) )
h_offs = 7.0 h_offs = 7.0
v_offs = -280.0 v_offs = -280.0
v_offs2 = -236.0
# We wanna prevent controllers users from popping up browsers # We wanna prevent controllers users from popping up browsers
# or game-center widgets in cases where they can't easily get back # or game-center widgets in cases where they can't easily get back
@ -393,7 +387,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
bui.buttonwidget( bui.buttonwidget(
parent=rootc, parent=rootc,
color=(0.45, 0.4, 0.5), color=(0.45, 0.4, 0.5),
position=(160, v_offs + 480), position=(240, v_offs2 + 439),
size=(350, 62), size=(350, 62),
label=( label=(
bui.Lstr(resource='tournamentStandingsText') bui.Lstr(resource='tournamentStandingsText')
@ -415,40 +409,85 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
show_next_button = self._is_more_levels and not (env.demo or env.arcade) show_next_button = self._is_more_levels and not (env.demo or env.arcade)
if not show_next_button: if not show_next_button:
h_offs += 70 h_offs += 60
menu_button = bui.buttonwidget( # Due to virtual-bounds changes, have to squish buttons a bit to
parent=rootc, # avoid overlapping with tips at bottom. Could look nicer to
autoselect=True, # rework things in the middle to get more space, but would
position=(h_offs - 130 - 60, v_offs), # rather not touch this old code more than necessary.
size=(110, 85), small_buttons = False
label='',
on_activate_call=bui.WeakCall(self._ui_menu), if small_buttons:
) menu_button = bui.buttonwidget(
bui.imagewidget( parent=rootc,
parent=rootc, autoselect=True,
draw_controller=menu_button, position=(h_offs - 130 - 45, v_offs + 40),
position=(h_offs - 130 - 60 + 22, v_offs + 14), size=(100, 50),
size=(60, 60), label='',
texture=self._menu_icon_texture, button_type='square',
opacity=0.8, on_activate_call=bui.WeakCall(self._ui_menu),
) )
self._restart_button = restart_button = bui.buttonwidget( bui.imagewidget(
parent=rootc, parent=rootc,
autoselect=True, draw_controller=menu_button,
position=(h_offs - 60, v_offs), position=(h_offs - 130 - 60 + 43, v_offs + 43),
size=(110, 85), size=(45, 45),
label='', texture=self._menu_icon_texture,
on_activate_call=bui.WeakCall(self._ui_restart), opacity=0.8,
) )
bui.imagewidget( else:
parent=rootc, menu_button = bui.buttonwidget(
draw_controller=restart_button, parent=rootc,
position=(h_offs - 60 + 19, v_offs + 7), autoselect=True,
size=(70, 70), position=(h_offs - 130 - 60, v_offs),
texture=self._replay_icon_texture, size=(110, 85),
opacity=0.8, label='',
) on_activate_call=bui.WeakCall(self._ui_menu),
)
bui.imagewidget(
parent=rootc,
draw_controller=menu_button,
position=(h_offs - 130 - 60 + 22, v_offs + 14),
size=(60, 60),
texture=self._menu_icon_texture,
opacity=0.8,
)
if small_buttons:
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs + 40),
size=(100, 50),
label='',
button_type='square',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 25, v_offs + 42),
size=(47, 47),
texture=self._replay_icon_texture,
opacity=0.8,
)
else:
self._restart_button = restart_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=bui.WeakCall(self._ui_restart),
)
bui.imagewidget(
parent=rootc,
draw_controller=restart_button,
position=(h_offs - 60 + 19, v_offs + 7),
size=(70, 70),
texture=self._replay_icon_texture,
opacity=0.8,
)
next_button: bui.Widget | None = None next_button: bui.Widget | None = None
@ -465,58 +504,53 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
button_sound = False button_sound = False
image_opacity = 0.2 image_opacity = 0.2
color = (0.3, 0.3, 0.3) color = (0.3, 0.3, 0.3)
next_button = bui.buttonwidget(
parent=rootc, if small_buttons:
autoselect=True, next_button = bui.buttonwidget(
position=(h_offs + 130 - 60, v_offs), parent=rootc,
size=(110, 85), autoselect=True,
label='', position=(h_offs + 130 - 75, v_offs + 40),
on_activate_call=call, size=(100, 50),
color=color, label='',
enable_sound=button_sound, button_type='square',
) on_activate_call=call,
bui.imagewidget( color=color,
parent=rootc, enable_sound=button_sound,
draw_controller=next_button, )
position=(h_offs + 130 - 60 + 12, v_offs + 5), bui.imagewidget(
size=(80, 80), parent=rootc,
texture=self._next_level_icon_texture, draw_controller=next_button,
opacity=image_opacity, position=(h_offs + 130 - 60 + 12, v_offs + 40),
) size=(50, 50),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
else:
next_button = bui.buttonwidget(
parent=rootc,
autoselect=True,
position=(h_offs + 130 - 60, v_offs),
size=(110, 85),
label='',
on_activate_call=call,
color=color,
enable_sound=button_sound,
)
bui.imagewidget(
parent=rootc,
draw_controller=next_button,
position=(h_offs + 130 - 60 + 12, v_offs + 5),
size=(80, 80),
texture=self._next_level_icon_texture,
opacity=image_opacity,
)
x_offs_extra = 0 if show_next_button else -100 x_offs_extra = 0 if show_next_button else -100
self._corner_button_offs = ( self._corner_button_offs = (
h_offs + 300.0 + 100.0 + x_offs_extra, h_offs + 300.0 + x_offs_extra,
v_offs + 560.0, v_offs + 519.0,
) )
if env.demo or env.arcade:
self._league_rank_button = None
self._store_button_instance = None
else:
self._league_rank_button = LeagueRankButton(
parent=rootc,
position=(h_offs + 300 + 100 + x_offs_extra, v_offs + 560),
size=(100, 60),
scale=0.9,
color=(0.4, 0.4, 0.9),
textcolor=(0.9, 0.9, 2.0),
transition_delay=0.0,
smooth_update_delay=5.0,
)
self._store_button_instance = StoreButton(
parent=rootc,
position=(h_offs + 400 + 100 + x_offs_extra, v_offs + 560),
show_tickets=True,
sale_scale=0.85,
size=(100, 60),
scale=0.9,
button_type='square',
color=(0.35, 0.25, 0.45),
textcolor=(0.9, 0.7, 1.0),
transition_delay=0.0,
)
bui.containerwidget( bui.containerwidget(
edit=rootc, edit=rootc,
selected_child=( selected_child=(
@ -527,26 +561,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
on_cancel_call=menu_button.activate, on_cancel_call=menu_button.activate,
) )
self._update_corner_button_positions()
self._update_corner_button_positions_timer = bui.AppTimer(
1.0, bui.WeakCall(self._update_corner_button_positions), repeat=True
)
def _update_corner_button_positions(self) -> None:
offs = -55 if bui.is_party_icon_visible() else 0
assert self._corner_button_offs is not None
pos_x = self._corner_button_offs[0] + offs
pos_y = self._corner_button_offs[1]
if self._league_rank_button is not None:
self._league_rank_button.set_position((pos_x, pos_y))
if self._store_button_instance is not None:
self._store_button_instance.set_position((pos_x + 100, pos_y))
def _player_press(self) -> None: def _player_press(self) -> None:
# (Only for headless builds). # (Only for headless builds).
# If this activity is a good 'end point', ask server-mode just once if # If this activity is a good 'end point', ask server-mode just
# it wants to do anything special like switch sessions or kill the app. # once if it wants to do anything special like switch sessions
# or kill the app.
if ( if (
self._allow_server_transition self._allow_server_transition
and bs.app.classic is not None and bs.app.classic is not None
@ -597,7 +617,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
@override @override
def on_begin(self) -> None: def on_begin(self) -> None:
# FIXME: Clean this up.
# pylint: disable=too-many-statements # pylint: disable=too-many-statements
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
@ -865,7 +884,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
# If we're not doing the world's-best button, just show a title # If we're not doing the world's-best button, just show a title
# instead. # instead.
ts_height = 300 ts_height = 300
ts_h_offs = 210 ts_h_offs = 290
v_offs = 40 v_offs = 40
txt = Text( txt = Text(
( (
@ -939,7 +958,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
if display_scores[i][1] is None: if display_scores[i][1] is None:
name_str = '-' name_str = '-'
else: else:
# noinspection PyUnresolvedReferences
name_str = ', '.join( name_str = ', '.join(
[p['name'] for p in display_scores[i][1]['players']] [p['name'] for p in display_scores[i][1]['players']]
) )
@ -1008,9 +1026,8 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
ts_h_offs = -480 ts_h_offs = -480
v_offs = 40 v_offs = 40
# Only make this if we don't have the button # Only make this if we don't have the button (never want clients
# (never want clients to see it so no need for client-only # to see it so no need for client-only version, etc).
# version, etc).
if self._have_achievements: if self._have_achievements:
if not self._account_has_achievements: if not self._account_has_achievements:
Text( Text(
@ -1052,7 +1069,6 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
).autoretain() ).autoretain()
def _got_friend_score_results(self, results: list[Any] | None) -> None: def _got_friend_score_results(self, results: list[Any] | None) -> None:
# FIXME: tidy this up
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
# pylint: disable=too-many-statements # pylint: disable=too-many-statements
@ -1187,35 +1203,77 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=tdelay2, transition_delay=tdelay2,
).autoretain() ).autoretain()
def _on_v2_score_results(
self, response: bacommon.bs.ScoreSubmitResponse | Exception
) -> None:
if isinstance(response, Exception):
logging.debug('Got error score-submit response: %s', response)
return
assert isinstance(response, bacommon.bs.ScoreSubmitResponse)
# Aim to have these effects run shortly after the final rating
# hit happens.
with self.context:
assert self._begin_time is not None
delay = max(0, 5.5 - (bs.time() - self._begin_time))
assert bui.app.classic is not None
bs.timer(
delay,
strict_partial(
bui.app.classic.run_bs_client_effects, response.effects
),
)
def _got_score_results(self, results: dict[str, Any] | None) -> None: def _got_score_results(self, results: dict[str, Any] | None) -> None:
# FIXME: tidy this up
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
# pylint: disable=too-many-branches # pylint: disable=too-many-branches
# pylint: disable=too-many-statements # pylint: disable=too-many-statements
plus = bs.app.plus plus = bs.app.plus
assert plus is not None assert plus is not None
classic = bs.app.classic
assert classic is not None
# We need to manually run this in the context of our activity # We need to manually run this in the context of our activity
# and only if we aren't shutting down. # and only if we aren't shutting down.
# (really should make the submit_score call handle that stuff itself) # (really should make the submit_score call handle that stuff itself)
if self.expired: if self.expired:
return return
with self.context: with self.context:
# Delay a bit if results come in too fast. # Delay a bit if results come in too fast.
assert self._begin_time is not None assert self._begin_time is not None
base_delay = max(0, 2.7 - (bs.time() - self._begin_time)) base_delay = max(0, 2.7 - (bs.time() - self._begin_time))
v_offs = 20 # v_offs = 20
v_offs = 64
if results is None: if results is None:
self._score_loading_status = Text( self._score_loading_status = Text(
bs.Lstr(resource='worldScoresUnavailableText'), bs.Lstr(resource='worldScoresUnavailableText'),
position=(230, 150 + v_offs), position=(280, 130 + v_offs),
color=(1, 1, 1, 0.4), color=(1, 1, 1, 0.4),
transition=Text.Transition.FADE_IN, transition=Text.Transition.FADE_IN,
transition_delay=base_delay + 0.3, transition_delay=base_delay + 0.3,
scale=0.7, scale=0.7,
) )
else: else:
# If there's a score-uuid bundled, ship it along to the
# v2 master server to ask about any rewards from that
# end.
score_token = results.get('token')
if (
isinstance(score_token, str)
and plus.accounts.primary is not None
):
with plus.accounts.primary:
plus.cloud.send_message_cb(
bacommon.bs.ScoreSubmitMessage(score_token),
on_response=bui.WeakCall(self._on_v2_score_results),
)
self._score_link = results['link'] self._score_link = results['link']
assert self._score_link is not None assert self._score_link is not None
# Prepend our master-server addr if its a relative addr. # Prepend our master-server addr if its a relative addr.
@ -1254,7 +1312,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
(1.5 + base_delay), (1.5 + base_delay),
bs.WeakCall(self._show_world_rank, offs_x), bs.WeakCall(self._show_world_rank, offs_x),
) )
ts_h_offs = 200 ts_h_offs = 280
ts_height = 300 ts_height = 300
# Show world tops. # Show world tops.
@ -1274,7 +1332,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
), ),
position=( position=(
ts_h_offs - 35 + 95, ts_h_offs - 35 + 95,
ts_height / 2 + 6 + v_offs, ts_height / 2 + 6 + v_offs - 41,
), ),
color=(0.4, 0.4, 0.4, 1.0), color=(0.4, 0.4, 0.4, 1.0),
scale=0.7, scale=0.7,
@ -1282,7 +1340,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=base_delay + 0.3, transition_delay=base_delay + 0.3,
).autoretain() ).autoretain()
else: else:
v_offs += 20 v_offs += 40
h_offs_extra = 0 h_offs_extra = 0
v_offs_names = 0 v_offs_names = 0
@ -1309,6 +1367,37 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
random.randrange(0, len(times) + 1), random.randrange(0, len(times) + 1),
(base_delay + i * 0.05, base_delay + 0.4 + i * 0.05), (base_delay + i * 0.05, base_delay + 0.4 + i * 0.05),
) )
# Conundrum: We want to place line numbers to the
# left of our score column based on the largest
# score width. However scores may use Lstrs and thus
# may have different widths in different languages.
# We don't want to bake down the Lstrs we display
# because then clients can't view scores in their
# own language. So as a compromise lets measure
# max-width based on baked down Lstrs but then
# display regular Lstrs with max-width set based on
# that. Hopefully that'll look reasonable for most
# languages.
max_score_width = 10.0
for tval in self._show_info['tops']:
score = int(tval[0])
name_str = tval[1]
if name_str != '-':
max_score_width = max(
max_score_width,
bui.get_string_width(
(
str(score)
if self._score_type == 'points'
else bs.timestring(
(score * 10) / 1000.0
).evaluate()
),
suppress_warning=True,
),
)
for i, tval in enumerate(self._show_info['tops']): for i, tval in enumerate(self._show_info['tops']):
score = int(tval[0]) score = int(tval[0])
name_str = tval[1] name_str = tval[1]
@ -1330,19 +1419,45 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
tdelay2 = times[i][1] tdelay2 = times[i][1]
if name_str != '-': if name_str != '-':
sstr = (
str(score)
if self._score_type == 'points'
else bs.timestring((score * 10) / 1000.0)
)
# Line number.
Text( Text(
( str(i + 1),
str(score) position=(
if self._score_type == 'points' ts_h_offs
else bs.timestring((score * 10) / 1000.0) + 20
+ h_offs_extra
- max_score_width
- 8.0,
ts_height / 2
+ -ts_height * (i + 1) / 10
+ v_offs
- 30.0,
), ),
scale=0.5,
h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER,
color=(0.3, 0.3, 0.3),
transition=Text.Transition.IN_LEFT,
transition_delay=tdelay1,
).autoretain()
# Score.
Text(
sstr,
position=( position=(
ts_h_offs + 20 + h_offs_extra, ts_h_offs + 20 + h_offs_extra,
ts_height / 2 ts_height / 2
+ -ts_height * (i + 1) / 10 + -ts_height * (i + 1) / 10
+ v_offs + v_offs
+ 11.0, - 30.0,
), ),
maxwidth=max_score_width,
h_align=Text.HAlign.RIGHT, h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER, v_align=Text.VAlign.CENTER,
color=color0, color=color0,
@ -1350,6 +1465,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition=Text.Transition.IN_LEFT, transition=Text.Transition.IN_LEFT,
transition_delay=tdelay1, transition_delay=tdelay1,
).autoretain() ).autoretain()
# Player name.
Text( Text(
bs.Lstr(value=name_str), bs.Lstr(value=name_str),
position=( position=(
@ -1358,7 +1474,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
+ -ts_height * (i + 1) / 10 + -ts_height * (i + 1) / 10
+ v_offs_names + v_offs_names
+ v_offs + v_offs
+ 11.0, - 30.0,
), ),
maxwidth=80.0 + 100.0 * len(self._playerinfos), maxwidth=80.0 + 100.0 * len(self._playerinfos),
v_align=Text.VAlign.CENTER, v_align=Text.VAlign.CENTER,
@ -1453,16 +1569,12 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
] ]
# pylint: disable=useless-suppression # pylint: disable=useless-suppression
# pylint: disable=unbalanced-tuple-unpacking # pylint: disable=unbalanced-tuple-unpacking
( (pr1, pv1, pr2, pv2, pr3, pv3) = (
pr1, bs.app.classic.get_tournament_prize_strings(
pv1, tourney_info, include_tickets=False
pr2, )
pv2,
pr3,
pv3,
) = bs.app.classic.get_tournament_prize_strings(
tourney_info
) )
# pylint: enable=unbalanced-tuple-unpacking # pylint: enable=unbalanced-tuple-unpacking
# pylint: enable=useless-suppression # pylint: enable=useless-suppression
@ -1478,10 +1590,14 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=2.0, transition_delay=2.0,
).autoretain() ).autoretain()
vval = -107 + 70 vval = -107 + 70
for rng, val in ((pr1, pv1), (pr2, pv2), (pr3, pv3)): for i, rng, val in (
(0, pr1, pv1),
(1, pr2, pv2),
(2, pr3, pv3),
):
Text( Text(
rng, rng,
position=(-410 + 10, vval), position=(-430 + 10, vval),
color=(1, 1, 1, 0.7), color=(1, 1, 1, 0.7),
h_align=Text.HAlign.RIGHT, h_align=Text.HAlign.RIGHT,
v_align=Text.VAlign.CENTER, v_align=Text.VAlign.CENTER,
@ -1492,7 +1608,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
).autoretain() ).autoretain()
Text( Text(
val, val,
position=(-390 + 10, vval), position=(-410 + 10, vval),
color=(0.7, 0.7, 0.7, 1.0), color=(0.7, 0.7, 0.7, 1.0),
h_align=Text.HAlign.LEFT, h_align=Text.HAlign.LEFT,
v_align=Text.VAlign.CENTER, v_align=Text.VAlign.CENTER,
@ -1501,6 +1617,9 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
maxwidth=300, maxwidth=300,
transition_delay=2.0, transition_delay=2.0,
).autoretain() ).autoretain()
bs.app.classic.create_in_game_tournament_prize_image(
tourney_info, i, (-410 + 70, vval)
)
vval -= 35 vval -= 35
except Exception: except Exception:
logging.exception('Error showing prize ranges.') logging.exception('Error showing prize ranges.')
@ -1573,6 +1692,7 @@ class CoopScoreScreen(bs.Activity[bs.Player, bs.Team]):
transition_delay=1.0, transition_delay=1.0,
).autoretain() ).autoretain()
else: else:
assert rating is not None
ZoomText( ZoomText(
( (
f'{rating:.1f}' f'{rating:.1f}'

View file

@ -145,6 +145,7 @@ class TeamVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
kill_delay: float, kill_delay: float,
shiftdelay: float, shiftdelay: float,
) -> None: ) -> None:
# pylint: disable=too-many-positional-arguments
del kill_delay # Unused arg. del kill_delay # Unused arg.
ZoomText( ZoomText(
str(sessionteam.customdata['score']), str(sessionteam.customdata['score']),

View file

@ -87,6 +87,7 @@ class FreeForAllVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
extrascale: float, extrascale: float,
flash: bool = False, flash: bool = False,
) -> Text: ) -> Text:
# pylint: disable=too-many-positional-arguments
return Text( return Text(
text, text,
position=( position=(

View file

@ -57,6 +57,7 @@ class MultiTeamScoreScreenActivity(bs.ScoreScreenActivity):
def show_player_scores( def show_player_scores(
self, self,
*,
delay: float = 2.5, delay: float = 2.5,
results: bs.GameResults | None = None, results: bs.GameResults | None = None,
scale: float = 1.0, scale: float = 1.0,
@ -134,6 +135,7 @@ class MultiTeamScoreScreenActivity(bs.ScoreScreenActivity):
xoffs: float, xoffs: float,
yoffs: float, yoffs: float,
text: bs.Lstr, text: bs.Lstr,
*,
h_align: Text.HAlign = Text.HAlign.RIGHT, h_align: Text.HAlign = Text.HAlign.RIGHT,
extrascale: float = 1.0, extrascale: float = 1.0,
maxwidth: float | None = 120.0, maxwidth: float | None = 120.0,

View file

@ -4,12 +4,15 @@
from __future__ import annotations from __future__ import annotations
from typing import override from typing import override, TYPE_CHECKING
import bascenev1 as bs import bascenev1 as bs
from bascenev1lib.activity.multiteamscore import MultiTeamScoreScreenActivity from bascenev1lib.activity.multiteamscore import MultiTeamScoreScreenActivity
if TYPE_CHECKING:
from typing import Any
class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity): class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
"""Final score screen for a team series.""" """Final score screen for a team series."""
@ -24,6 +27,8 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
self._allow_server_transition = True self._allow_server_transition = True
self._tips_text = None self._tips_text = None
self._default_show_tips = False self._default_show_tips = False
self._ffa_top_player_info: list[Any] | None = None
self._ffa_top_player_rec: bs.PlayerRecord | None = None
@override @override
def on_begin(self) -> None: def on_begin(self) -> None:
@ -70,6 +75,16 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
) )
) )
player_entries.sort(reverse=True, key=lambda x: x[0]) player_entries.sort(reverse=True, key=lambda x: x[0])
if len(player_entries) > 0:
# Store some info for the top ffa player so we can
# show winner info even if they leave.
self._ffa_top_player_info = list(player_entries[0])
self._ffa_top_player_info[1] = self._ffa_top_player_info[
2
].getname()
self._ffa_top_player_info[2] = self._ffa_top_player_info[
2
].get_icon()
else: else:
for _pkey, prec in self.stats.get_records().items(): for _pkey, prec in self.stats.get_records().items():
player_entries.append((prec.score, prec.name_full, prec)) player_entries.append((prec.score, prec.name_full, prec))
@ -308,7 +323,7 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
most_killed = entry[2].killed_count most_killed = entry[2].killed_count
if mkp is not None: if mkp is not None:
Text( Text(
bs.Lstr(resource='mostViolatedPlayerText'), bs.Lstr(resource='mostDestroyedPlayerText'),
color=(0.5, 0.5, 0.5, 1.0), color=(0.5, 0.5, 0.5, 1.0),
v_align=Text.VAlign.CENTER, v_align=Text.VAlign.CENTER,
maxwidth=300, maxwidth=300,
@ -433,25 +448,42 @@ class TeamSeriesVictoryScoreScreenActivity(MultiTeamScoreScreenActivity):
maxwidth=250, maxwidth=250,
).autoretain() ).autoretain()
else: else:
offs_v = -80.0 offs_v = -80
assert isinstance(self.session, bs.MultiTeamSession)
series_length = self.session.get_ffa_series_length()
icon: dict | None
# Pull live player info if they're still around.
if len(team.players) == 1: if len(team.players) == 1:
icon = team.players[0].get_icon()
player_name = team.players[0].getname(full=True, icon=False)
# Otherwise use the special info we stored when we came in.
elif (
self._ffa_top_player_info is not None
and self._ffa_top_player_info[0] >= series_length
):
icon = self._ffa_top_player_info[2]
player_name = self._ffa_top_player_info[1]
else:
icon = None
player_name = 'Player Not Found'
if icon is not None:
i = Image( i = Image(
team.players[0].get_icon(), icon,
position=(0, 143), position=(0, 143),
scale=(100, 100), scale=(100, 100),
).autoretain() ).autoretain()
assert i.node assert i.node
bs.animate(i.node, 'opacity', {0.0: 0.0, 0.25: 1.0}) bs.animate(i.node, 'opacity', {0.0: 0.0, 0.25: 1.0})
ZoomText(
bs.Lstr( ZoomText(
value=team.players[0].getname(full=True, icon=False) bs.Lstr(value=player_name),
), position=(0, 97 + offs_v + (0 if icon is not None else 60)),
position=(0, 97 + offs_v), color=team.color,
color=team.color, scale=1.15,
scale=1.15, jitter=1.0,
jitter=1.0, maxwidth=250,
maxwidth=250, ).autoretain()
).autoretain()
s_extra = 1.0 if self._is_ffa else 1.0 s_extra = 1.0 if self._is_ffa else 1.0

View file

@ -333,6 +333,7 @@ class Blast(bs.Actor):
def __init__( def __init__(
self, self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0), position: Sequence[float] = (0.0, 1.0, 0.0),
velocity: Sequence[float] = (0.0, 0.0, 0.0), velocity: Sequence[float] = (0.0, 0.0, 0.0),
blast_radius: float = 2.0, blast_radius: float = 2.0,
@ -715,6 +716,7 @@ class Bomb(bs.Actor):
def __init__( def __init__(
self, self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0), position: Sequence[float] = (0.0, 1.0, 0.0),
velocity: Sequence[float] = (0.0, 0.0, 0.0), velocity: Sequence[float] = (0.0, 0.0, 0.0),
bomb_type: str = 'normal', bomb_type: str = 'normal',

View file

@ -24,6 +24,7 @@ class ControlsGuide(bs.Actor):
def __init__( def __init__(
self, self,
*,
position: tuple[float, float] = (390.0, 120.0), position: tuple[float, float] = (390.0, 120.0),
scale: float = 1.0, scale: float = 1.0,
delay: float = 0.0, delay: float = 0.0,

View file

@ -144,6 +144,9 @@ class FlagDiedMessage:
flag: Flag flag: Flag
"""The `Flag` that died.""" """The `Flag` that died."""
self_kill: bool = False
"""If the `Flag` killed itself or not."""
@dataclass @dataclass
class FlagDroppedMessage: class FlagDroppedMessage:
@ -169,6 +172,7 @@ class Flag(bs.Actor):
def __init__( def __init__(
self, self,
*,
position: Sequence[float] = (0.0, 1.0, 0.0), position: Sequence[float] = (0.0, 1.0, 0.0),
color: Sequence[float] = (1.0, 1.0, 1.0), color: Sequence[float] = (1.0, 1.0, 1.0),
materials: Sequence[bs.Material] | None = None, materials: Sequence[bs.Material] | None = None,
@ -282,7 +286,9 @@ class Flag(bs.Actor):
) )
self._counter.text = str(self._count) self._counter.text = str(self._count)
if self._count < 1: if self._count < 1:
self.handlemessage(bs.DieMessage()) self.handlemessage(
bs.DieMessage(how=bs.DeathType.LEFT_GAME)
)
else: else:
assert self._counter assert self._counter
self._counter.text = '' self._counter.text = ''
@ -336,7 +342,11 @@ class Flag(bs.Actor):
if self.node: if self.node:
self.node.delete() self.node.delete()
if not msg.immediate: if not msg.immediate:
self.activity.handlemessage(FlagDiedMessage(self)) self.activity.handlemessage(
FlagDiedMessage(
self, (msg.how is bs.DeathType.LEFT_GAME)
)
)
elif isinstance(msg, bs.HitMessage): elif isinstance(msg, bs.HitMessage):
assert self.node assert self.node
assert msg.force_direction is not None assert msg.force_direction is not None

View file

@ -37,6 +37,7 @@ class Image(bs.Actor):
def __init__( def __init__(
self, self,
texture: bs.Texture | dict[str, Any], texture: bs.Texture | dict[str, Any],
*,
position: tuple[float, float] = (0, 0), position: tuple[float, float] = (0, 0),
transition: Transition | None = None, transition: Transition | None = None,
transition_delay: float = 0.0, transition_delay: float = 0.0,
@ -55,15 +56,21 @@ class Image(bs.Actor):
# pylint: disable=too-many-locals # pylint: disable=too-many-locals
super().__init__() super().__init__()
# If they provided a dict as texture, assume its an icon. # If they provided a dict as texture, use it to wire up extended
# otherwise its just a texture value itself. # stuff like tints and masks.
mask_texture: bs.Texture | None mask_texture: bs.Texture | None
if isinstance(texture, dict): if isinstance(texture, dict):
tint_color = texture['tint_color'] tint_color = texture['tint_color']
tint2_color = texture['tint2_color'] tint2_color = texture['tint2_color']
tint_texture = texture['tint_texture'] tint_texture = texture['tint_texture']
# Assume we're dealing with a character icon but allow
# overriding.
mask_tex_name = texture.get('mask_texture', 'characterIconMask')
mask_texture = (
None if mask_tex_name is None else bs.gettexture(mask_tex_name)
)
texture = texture['texture'] texture = texture['texture']
mask_texture = bs.gettexture('characterIconMask')
else: else:
tint_color = (1, 1, 1) tint_color = (1, 1, 1)
tint2_color = None tint2_color = None

View file

@ -46,6 +46,7 @@ class PlayerSpaz(Spaz):
def __init__( def __init__(
self, self,
player: bs.Player, player: bs.Player,
*,
color: Sequence[float] = (1.0, 1.0, 1.0), color: Sequence[float] = (1.0, 1.0, 1.0),
highlight: Sequence[float] = (0.5, 0.5, 0.5), highlight: Sequence[float] = (0.5, 0.5, 0.5),
character: str = 'Spaz', character: str = 'Spaz',
@ -102,6 +103,7 @@ class PlayerSpaz(Spaz):
def connect_controls_to_player( def connect_controls_to_player(
self, self,
*,
enable_jump: bool = True, enable_jump: bool = True,
enable_punch: bool = True, enable_punch: bool = True,
enable_pickup: bool = True, enable_pickup: bool = True,

View file

@ -22,6 +22,7 @@ class PopupText(bs.Actor):
def __init__( def __init__(
self, self,
text: str | bs.Lstr, text: str | bs.Lstr,
*,
position: Sequence[float] = (0.0, 0.0, 0.0), position: Sequence[float] = (0.0, 0.0, 0.0),
color: Sequence[float] = (1.0, 1.0, 1.0, 1.0), color: Sequence[float] = (1.0, 1.0, 1.0, 1.0),
random_offset: float = 0.5, random_offset: float = 0.5,

View file

@ -22,14 +22,18 @@ class _Entry:
scale: float, scale: float,
label: bs.Lstr | None, label: bs.Lstr | None,
flash_length: float, flash_length: float,
width: float | None = None,
height: float | None = None,
): ):
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements # pylint: disable=too-many-statements
# pylint: disable=too-many-positional-arguments
self._scoreboard = weakref.ref(scoreboard) self._scoreboard = weakref.ref(scoreboard)
self._do_cover = do_cover self._do_cover = do_cover
self._scale = scale self._scale = scale
self._flash_length = flash_length self._flash_length = flash_length
self._width = 140.0 * self._scale self._width = (140.0 if width is None else width) * self._scale
self._height = 32.0 * self._scale self._height = (32.0 if height is None else height) * self._scale
self._bar_width = 2.0 * self._scale self._bar_width = 2.0 * self._scale
self._bar_height = 32.0 * self._scale self._bar_height = 32.0 * self._scale
self._bar_tex = self._backing_tex = bs.gettexture('bar') self._bar_tex = self._backing_tex = bs.gettexture('bar')
@ -277,6 +281,7 @@ class _Entry:
def set_value( def set_value(
self, self,
score: float, score: float,
*,
max_score: float | None = None, max_score: float | None = None,
countdown: bool = False, countdown: bool = False,
flash: bool = True, flash: bool = True,
@ -368,16 +373,26 @@ class Scoreboard:
_ENTRYSTORENAME = bs.storagename('entry') _ENTRYSTORENAME = bs.storagename('entry')
def __init__(self, label: bs.Lstr | None = None, score_split: float = 0.7): def __init__(
self,
label: bs.Lstr | None = None,
score_split: float = 0.7,
pos: Sequence[float] | None = None,
width: float | None = None,
height: float | None = None,
):
"""Instantiate a scoreboard. """Instantiate a scoreboard.
Label can be something like 'points' and will Label can be something like 'points' and will
show up on boards if provided. show up on boards if provided.
""" """
# pylint: disable=too-many-positional-arguments
self._flat_tex = bs.gettexture('null') self._flat_tex = bs.gettexture('null')
self._entries: dict[int, _Entry] = {} self._entries: dict[int, _Entry] = {}
self._label = label self._label = label
self.score_split = score_split self.score_split = score_split
self._width = width
self._height = height
# For free-for-all we go simpler since we have one per player. # For free-for-all we go simpler since we have one per player.
self._pos: Sequence[float] self._pos: Sequence[float]
@ -393,12 +408,14 @@ class Scoreboard:
self._pos = (20.0, -70.0) self._pos = (20.0, -70.0)
self._scale = 1.0 self._scale = 1.0
self._flash_length = 1.0 self._flash_length = 1.0
self._pos = self._pos if pos is None else pos
def set_team_value( def set_team_value(
self, self,
team: bs.Team, team: bs.Team,
score: float, score: float,
max_score: float | None = None, max_score: float | None = None,
*,
countdown: bool = False, countdown: bool = False,
flash: bool = True, flash: bool = True,
show_value: bool = True, show_value: bool = True,
@ -430,6 +447,8 @@ class Scoreboard:
do_cover=self._do_cover, do_cover=self._do_cover,
scale=self._scale, scale=self._scale,
label=self._label, label=self._label,
width=self._width,
height=self._height,
flash_length=self._flash_length, flash_length=self._flash_length,
) )
self._update_teams() self._update_teams()

View file

@ -50,6 +50,7 @@ class Spawner:
def __init__( def __init__(
self, self,
*,
data: Any = None, data: Any = None,
pt: Sequence[float] = (0, 0, 0), pt: Sequence[float] = (0, 0, 0),
spawn_time: float = 1.0, spawn_time: float = 1.0,

View file

@ -71,6 +71,7 @@ class Spaz(bs.Actor):
def __init__( def __init__(
self, self,
*,
color: Sequence[float] = (1.0, 1.0, 1.0), color: Sequence[float] = (1.0, 1.0, 1.0),
highlight: Sequence[float] = (0.5, 0.5, 0.5), highlight: Sequence[float] = (0.5, 0.5, 0.5),
character: str = 'Spaz', character: str = 'Spaz',
@ -1195,11 +1196,12 @@ class Spaz(bs.Actor):
if self.node: if self.node:
self.node.delete() self.node.delete()
elif self.node: elif self.node:
self.node.hurt = 1.0 if not wasdead:
if self.play_big_death_sound and not wasdead: self.node.hurt = 1.0
SpazFactory.get().single_player_death_sound.play() if self.play_big_death_sound:
self.node.dead = True SpazFactory.get().single_player_death_sound.play()
bs.timer(2.0, self.node.delete) self.node.dead = True
bs.timer(2.0, self.node.delete)
elif isinstance(msg, bs.OutOfBoundsMessage): elif isinstance(msg, bs.OutOfBoundsMessage):
# By default we just die here. # By default we just die here.

View file

@ -14,7 +14,7 @@ def get_appearances(include_locked: bool = False) -> list[str]:
assert plus is not None assert plus is not None
assert bs.app.classic is not None assert bs.app.classic is not None
get_purchased = plus.get_purchased get_purchased = plus.get_v1_account_product_purchased
disallowed = [] disallowed = []
if not include_locked: if not include_locked:
# Hmm yeah this'll be tough to hack... # Hmm yeah this'll be tough to hack...

View file

@ -947,9 +947,9 @@ class SpazBotSet:
on_spawn_call: Callable[[SpazBot], Any] | None = None, on_spawn_call: Callable[[SpazBot], Any] | None = None,
) -> None: ) -> None:
"""Spawn a bot from this set.""" """Spawn a bot from this set."""
from bascenev1lib.actor import spawner from bascenev1lib.actor.spawner import Spawner
spawner.Spawner( Spawner(
pt=pos, pt=pos,
spawn_time=spawn_time, spawn_time=spawn_time,
send_spawn_message=False, send_spawn_message=False,

View file

@ -56,6 +56,7 @@ class Text(bs.Actor):
def __init__( def __init__(
self, self,
text: str | bs.Lstr, text: str | bs.Lstr,
*,
position: tuple[float, float] = (0.0, 0.0), position: tuple[float, float] = (0.0, 0.0),
h_align: HAlign = HAlign.LEFT, h_align: HAlign = HAlign.LEFT,
v_align: VAlign = VAlign.NONE, v_align: VAlign = VAlign.NONE,

View file

@ -26,6 +26,7 @@ class ZoomText(bs.Actor):
self, self,
text: str | bs.Lstr, text: str | bs.Lstr,
position: tuple[float, float] = (0.0, 0.0), position: tuple[float, float] = (0.0, 0.0),
*,
shiftposition: tuple[float, float] | None = None, shiftposition: tuple[float, float] | None = None,
shiftdelay: float | None = None, shiftdelay: float | None = None,
lifespan: float | None = None, lifespan: float | None = None,

View file

@ -2,7 +2,7 @@
# #
"""Defines assault minigame.""" """Defines assault minigame."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Defines a capture-the-flag game.""" """Defines a capture-the-flag game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations
@ -32,6 +32,7 @@ class CTFFlag(Flag):
activity: CaptureTheFlagGame activity: CaptureTheFlagGame
def __init__(self, team: Team): def __init__(self, team: Team):
assert team.flagmaterial is not None assert team.flagmaterial is not None
super().__init__( super().__init__(
materials=[team.flagmaterial], materials=[team.flagmaterial],
@ -73,6 +74,7 @@ class Team(bs.Team[Player]):
def __init__( def __init__(
self, self,
*,
base_pos: Sequence[float], base_pos: Sequence[float],
base_region_material: bs.Material, base_region_material: bs.Material,
base_region: bs.Node, base_region: bs.Node,

View file

@ -2,7 +2,7 @@
# #
"""Provides the chosen-one mini-game.""" """Provides the chosen-one mini-game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Provides the Conquest game.""" """Provides the Conquest game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""DeathMatch game and support classes.""" """DeathMatch game and support classes."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Provides an easter egg hunt game.""" """Provides an easter egg hunt game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Elimination mini-game.""" """Elimination mini-game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations
@ -27,6 +27,7 @@ class Icon(bs.Actor):
player: Player, player: Player,
position: tuple[float, float], position: tuple[float, float],
scale: float, scale: float,
*,
show_lives: bool = True, show_lives: bool = True,
show_death: bool = True, show_death: bool = True,
name_scale: float = 1.0, name_scale: float = 1.0,

View file

@ -1,9 +1,8 @@
# Released under the MIT License. See LICENSE for details. # Released under the MIT License. See LICENSE for details.
# #
# pylint: disable=too-many-lines
"""Implements football games (both co-op and teams varieties).""" """Implements football games (both co-op and teams varieties)."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations
@ -332,7 +331,10 @@ class FootballTeamGame(bs.TeamGameActivity[Player, Team]):
# Respawn dead flags. # Respawn dead flags.
elif isinstance(msg, FlagDiedMessage): elif isinstance(msg, FlagDiedMessage):
if not self.has_ended(): if not self.has_ended():
self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag) if msg.self_kill:
self._spawn_flag()
else:
self._flag_respawn_timer = bs.Timer(3.0, self._spawn_flag)
self._flag_respawn_light = bs.NodeActor( self._flag_respawn_light = bs.NodeActor(
bs.newnode( bs.newnode(
'light', 'light',
@ -655,11 +657,6 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
for bot in bots: for bot in bots:
bot.target_flag = None bot.target_flag = None
# If we're waiting on a continue, stop here so they don't keep scoring.
if self.is_waiting_for_continue():
self._bots.stop_moving()
return
# If we've got a flag and no player are holding it, find the closest # If we've got a flag and no player are holding it, find the closest
# bot to it, and make them the designated flag-bearer. # bot to it, and make them the designated flag-bearer.
assert self._flag is not None assert self._flag is not None
@ -816,14 +813,6 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
self._bots.final_celebrate() self._bots.final_celebrate()
bs.timer(0.001, bs.Call(self.do_end, 'defeat')) bs.timer(0.001, bs.Call(self.do_end, 'defeat'))
@override
def on_continue(self) -> None:
# Subtract one touchdown from the bots and get them moving again.
assert self._bot_team is not None
self._bot_team.score -= 7
self._bots.start_moving()
self.update_scores()
def update_scores(self) -> None: def update_scores(self) -> None:
"""update scoreboard and check for winners""" """update scoreboard and check for winners"""
# FIXME: tidy this up # FIXME: tidy this up
@ -838,7 +827,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
if not have_scoring_team: if not have_scoring_team:
self._scoring_team = team self._scoring_team = team
if team is self._bot_team: if team is self._bot_team:
self.continue_or_end_game() self.end_game()
else: else:
bs.setmusic(bs.MusicType.VICTORY) bs.setmusic(bs.MusicType.VICTORY)
@ -893,8 +882,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
) )
self._time_text_input.node.timemax = self._final_time_ms self._time_text_input.node.timemax = self._final_time_ms
# FIXME: Does this still need to be deferred? self.do_end('victory')
bs.pushcall(bs.Call(self.do_end, 'victory'))
def do_end(self, outcome: str) -> None: def do_end(self, outcome: str) -> None:
"""End the game with the specified outcome.""" """End the game with the specified outcome."""
@ -945,7 +933,10 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
# Respawn dead flags. # Respawn dead flags.
elif isinstance(msg, FlagDiedMessage): elif isinstance(msg, FlagDiedMessage):
assert isinstance(msg.flag, FootballFlag) assert isinstance(msg.flag, FootballFlag)
msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag) if msg.self_kill:
self._spawn_flag()
else:
msg.flag.respawn_timer = bs.Timer(3.0, self._spawn_flag)
self._flag_respawn_light = bs.NodeActor( self._flag_respawn_light = bs.NodeActor(
bs.newnode( bs.newnode(
'light', 'light',
@ -962,7 +953,7 @@ class FootballCoopGame(bs.CoopGameActivity[Player, Team]):
self._flag_respawn_light.node, self._flag_respawn_light.node,
'intensity', 'intensity',
{0: 0, 0.25: 0.15, 0.5: 0}, {0: 0, 0.25: 0.15, 0.5: 0},
loop=True, loop=(not msg.self_kill),
) )
bs.timer(3.0, self._flag_respawn_light.node.delete) bs.timer(3.0, self._flag_respawn_light.node.delete)
else: else:

View file

@ -2,7 +2,7 @@
# #
"""Hockey game and support classes.""" """Hockey game and support classes."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Defines a keep-away game type.""" """Defines a keep-away game type."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Defines the King of the Hill game.""" """Defines the King of the Hill game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Defines a bomb-dodging mini-game.""" """Defines a bomb-dodging mini-game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -2,7 +2,7 @@
# #
"""Provides Ninja Fight mini-game.""" """Provides Ninja Fight mini-game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

View file

@ -5,7 +5,7 @@
# Yes this is a long one.. # Yes this is a long one..
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations
@ -805,6 +805,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
max_level: int, max_level: int,
) -> list[list[tuple[int, int]]]: ) -> list[list[tuple[int, int]]]:
"""Calculate a distribution of bad guys given some params.""" """Calculate a distribution of bad guys given some params."""
# pylint: disable=too-many-positional-arguments
max_iterations = 10 + max_dudes * 2 max_iterations = 10 + max_dudes * 2
groups: list[list[tuple[int, int]]] = [] groups: list[list[tuple[int, int]]] = []
@ -1194,7 +1195,7 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
def _respawn_players_for_wave(self) -> None: def _respawn_players_for_wave(self) -> None:
# Respawn applicable players. # Respawn applicable players.
if self._wavenum > 1 and not self.is_waiting_for_continue(): if self._wavenum > 1:
for player in self.players: for player in self.players:
if ( if (
not player.is_alive() not player.is_alive()
@ -1641,19 +1642,9 @@ class OnslaughtGame(bs.CoopGameActivity[Player, Team]):
self.do_end('defeat', delay=2.0) self.do_end('defeat', delay=2.0)
bs.setmusic(None) bs.setmusic(None)
@override
def on_continue(self) -> None:
for player in self.players:
if not player.is_alive():
self.spawn_player(player)
def _checkroundover(self) -> None: def _checkroundover(self) -> None:
"""Potentially end the round based on the state of the game.""" """Potentially end the round based on the state of the game."""
if self.has_ended(): if self.has_ended():
return return
if not any(player.is_alive() for player in self.teams[0].players): if not any(player.is_alive() for player in self.teams[0].players):
# Allow continuing after wave 1. self.end_game()
if self._wavenum > 1:
self.continue_or_end_game()
else:
self.end_game()

View file

@ -2,7 +2,7 @@
# #
"""Defines Race mini-game.""" """Defines Race mini-game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations
@ -138,7 +138,9 @@ class RaceGame(bs.TeamGameActivity[Player, Team]):
@override @override
@classmethod @classmethod
def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool: def supports_session_type(cls, sessiontype: type[bs.Session]) -> bool:
return issubclass(sessiontype, bs.MultiTeamSession) return issubclass(sessiontype, bs.MultiTeamSession) or issubclass(
sessiontype, bs.CoopSession
)
@override @override
@classmethod @classmethod

View file

@ -5,7 +5,7 @@
# We wear the cone of shame. # We wear the cone of shame.
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations
@ -487,9 +487,9 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]):
assert bs.app.classic is not None assert bs.app.classic is not None
uiscale = bs.app.ui_v1.uiscale uiscale = bs.app.ui_v1.uiscale
l_offs = ( l_offs = (
-80 -120
if uiscale is bs.UIScale.SMALL if uiscale is bs.UIScale.SMALL
else -40 if uiscale is bs.UIScale.MEDIUM else 0 else -60 if uiscale is bs.UIScale.MEDIUM else -30
) )
self._lives_bg = bs.NodeActor( self._lives_bg = bs.NodeActor(
@ -550,7 +550,7 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]):
self._lives -= 1 self._lives -= 1
if self._lives == 0: if self._lives == 0:
self._bots.stop_moving() self._bots.stop_moving()
self.continue_or_end_game() self.end_game()
# Heartbeat behavior # Heartbeat behavior
if self._lives < 5: if self._lives < 5:
@ -613,14 +613,6 @@ class RunaroundGame(bs.CoopGameActivity[Player, Team]):
), ),
) )
@override
def on_continue(self) -> None:
self._lives = 3
assert self._lives_text is not None
assert self._lives_text.node
self._lives_text.node.text = str(self._lives)
self._bots.start_moving()
@override @override
def spawn_player(self, player: Player) -> bs.Actor: def spawn_player(self, player: Player) -> bs.Actor:
pos = ( pos = (

View file

@ -2,7 +2,7 @@
# #
"""Implements Target Practice game.""" """Implements Target Practice game."""
# ba_meta require api 8 # ba_meta require api 9
# (see https://ballistica.net/wiki/meta-tag-system) # (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations from __future__ import annotations

Some files were not shown because too many files have changed in this diff Show more