hello API 8 !

This commit is contained in:
Ayush Saini 2023-08-13 17:21:49 +05:30
parent 3a2b6ade68
commit 0284fee95c
1166 changed files with 26061 additions and 375100 deletions

307
dist/ba_data/python/babase/__init__.py vendored Normal file
View file

@ -0,0 +1,307 @@
# Released under the MIT License. See LICENSE for details.
#
"""Common shared Ballistica components.
For modding purposes, this package should generally not be used directly.
Instead one should use purpose-built packages such as bascenev1 or bauiv1
which themselves import various functionality from here and reexpose it in
a more focused way.
"""
# pylint: disable=redefined-builtin
# 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
# things from this package's submodules directly to reduce the chance of
# dependency loops. The exception is TYPE_CHECKING blocks and
# annotations since those aren't evaluated at runtime.
from efro.util import set_canonical_module_names
import _babase
from _babase import (
add_clean_frame_callback,
android_get_external_files_dir,
appname,
appnameupper,
apptime,
apptimer,
AppTimer,
charstr,
clipboard_get_text,
clipboard_has_text,
clipboard_is_supported,
clipboard_set_text,
ContextCall,
ContextRef,
displaytime,
displaytimer,
DisplayTimer,
do_once,
env,
fade_screen,
fatal_error,
get_display_resolution,
get_immediate_return_code,
get_low_level_config_value,
get_max_graphics_quality,
get_replays_dir,
get_string_height,
get_string_width,
getsimplesound,
has_gamma_control,
have_chars,
have_permission,
in_logic_thread,
increment_analytics_count,
is_os_playing_music,
is_running_on_fire_tv,
is_xcode_build,
lock_all_input,
mac_music_app_get_library_source,
mac_music_app_get_playlists,
mac_music_app_get_volume,
mac_music_app_init,
mac_music_app_play_playlist,
mac_music_app_set_volume,
mac_music_app_stop,
music_player_play,
music_player_set_volume,
music_player_shutdown,
music_player_stop,
native_stack_trace,
print_load_info,
pushcall,
quit,
reload_media,
request_permission,
safecolor,
screenmessage,
set_analytics_screen,
set_low_level_config_value,
set_stress_testing,
set_thread_name,
set_ui_input_device,
show_progress_bar,
SimpleSound,
unlock_all_input,
user_agent_string,
Vec3,
workspaces_in_use,
)
from babase._accountv2 import AccountV2Handle, AccountV2Subsystem
from babase._app import App
from babase._appconfig import commit_app_config
from babase._appintent import AppIntent, AppIntentDefault, AppIntentExec
from babase._appmode import AppMode
from babase._appsubsystem import AppSubsystem
from babase._appconfig import AppConfig
from babase._apputils import (
handle_leftover_v1_cloud_log_file,
is_browser_likely_available,
garbage_collect,
get_remote_app_name,
)
from babase._cloud import CloudSubsystem
from babase._emptyappmode import EmptyAppMode
from babase._error import (
print_exception,
print_error,
ContextError,
NotFoundError,
PlayerNotFoundError,
SessionPlayerNotFoundError,
NodeNotFoundError,
ActorNotFoundError,
InputDeviceNotFoundError,
WidgetNotFoundError,
ActivityNotFoundError,
TeamNotFoundError,
MapNotFoundError,
SessionTeamNotFoundError,
SessionNotFoundError,
DelegateNotFoundError,
)
from babase._general import (
utf8_all,
DisplayTime,
AppTime,
WeakCall,
Call,
existing,
Existable,
verify_object_death,
storagename,
getclass,
get_type_name,
json_prep,
)
from babase._keyboard import Keyboard
from babase._language import Lstr, LanguageSubsystem
from babase._login import LoginAdapter
# noinspection PyProtectedMember
# (PyCharm inspection bug?)
from babase._mgen.enums import (
Permission,
SpecialChar,
InputType,
UIScale,
)
from babase._math import normalized_color, is_point_in_box, vec3validate
from babase._meta import MetadataSubsystem
from babase._net import get_ip_address_type, DEFAULT_REQUEST_TIMEOUT_SECONDS
from babase._plugin import PluginSpec, Plugin, PluginSubsystem
from babase._text import timestring
_babase.app = app = App()
app.postinit()
__all__ = [
'AccountV2Handle',
'AccountV2Subsystem',
'ActivityNotFoundError',
'ActorNotFoundError',
'add_clean_frame_callback',
'android_get_external_files_dir',
'app',
'app',
'App',
'AppConfig',
'AppIntent',
'AppIntentDefault',
'AppIntentExec',
'AppMode',
'appname',
'appnameupper',
'AppSubsystem',
'apptime',
'AppTime',
'apptime',
'apptimer',
'AppTimer',
'Call',
'charstr',
'clipboard_get_text',
'clipboard_has_text',
'clipboard_is_supported',
'clipboard_set_text',
'CloudSubsystem',
'commit_app_config',
'ContextCall',
'ContextError',
'ContextRef',
'DelegateNotFoundError',
'DisplayTime',
'displaytime',
'displaytimer',
'DisplayTimer',
'do_once',
'EmptyAppMode',
'env',
'Existable',
'existing',
'fade_screen',
'fatal_error',
'garbage_collect',
'get_display_resolution',
'get_immediate_return_code',
'get_ip_address_type',
'get_low_level_config_value',
'get_max_graphics_quality',
'get_remote_app_name',
'get_replays_dir',
'get_string_height',
'get_string_width',
'get_type_name',
'getclass',
'getsimplesound',
'handle_leftover_v1_cloud_log_file',
'has_gamma_control',
'have_chars',
'have_permission',
'in_logic_thread',
'increment_analytics_count',
'InputDeviceNotFoundError',
'InputType',
'is_browser_likely_available',
'is_browser_likely_available',
'is_os_playing_music',
'is_point_in_box',
'is_running_on_fire_tv',
'is_xcode_build',
'json_prep',
'Keyboard',
'LanguageSubsystem',
'lock_all_input',
'LoginAdapter',
'Lstr',
'mac_music_app_get_library_source',
'mac_music_app_get_playlists',
'mac_music_app_get_volume',
'mac_music_app_init',
'mac_music_app_play_playlist',
'mac_music_app_set_volume',
'mac_music_app_stop',
'MapNotFoundError',
'MetadataSubsystem',
'music_player_play',
'music_player_set_volume',
'music_player_shutdown',
'music_player_stop',
'native_stack_trace',
'NodeNotFoundError',
'normalized_color',
'NotFoundError',
'Permission',
'PlayerNotFoundError',
'Plugin',
'PluginSubsystem',
'PluginSpec',
'print_error',
'print_exception',
'print_load_info',
'pushcall',
'quit',
'reload_media',
'request_permission',
'safecolor',
'screenmessage',
'SessionNotFoundError',
'SessionPlayerNotFoundError',
'SessionTeamNotFoundError',
'set_analytics_screen',
'set_low_level_config_value',
'set_stress_testing',
'set_thread_name',
'set_ui_input_device',
'show_progress_bar',
'SimpleSound',
'SpecialChar',
'storagename',
'TeamNotFoundError',
'timestring',
'UIScale',
'unlock_all_input',
'user_agent_string',
'utf8_all',
'Vec3',
'vec3validate',
'verify_object_death',
'WeakCall',
'WidgetNotFoundError',
'workspaces_in_use',
'DEFAULT_REQUEST_TIMEOUT_SECONDS',
]
# We want stuff to show up as babase.Foo instead of babase._sub.Foo.
set_canonical_module_names(globals())
# Allow the native layer to wrap a few things up.
_babase.reached_end_of_babase()
# Marker we pop down at the very end so other modules can run sanity
# checks to make sure we aren't importing them reciprocally when they
# import us.
_REACHED_END_OF_MODULE = True

441
dist/ba_data/python/babase/_accountv2.py vendored Normal file
View file

@ -0,0 +1,441 @@
# Released under the MIT License. See LICENSE for details.
#
"""Account related functionality."""
from __future__ import annotations
import hashlib
import logging
from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.error import CommunicationError
from bacommon.login import LoginType
import _babase
if TYPE_CHECKING:
from typing import Any
from babase._login import LoginAdapter
DEBUG_LOG = False
class AccountV2Subsystem:
"""Subsystem for modern account handling in the app.
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.accounts'.
"""
def __init__(self) -> None:
# Whether or not everything related to an initial login
# (or lack thereof) has completed. This includes things like
# workspace syncing. Completion of this is what flips the app
# into 'running' state.
self._initial_sign_in_completed = False
self._kicked_off_workspace_load = False
self.login_adapters: dict[LoginType, LoginAdapter] = {}
self._implicit_signed_in_adapter: LoginAdapter | None = None
self._implicit_state_changed = False
self._can_do_auto_sign_in = True
if _babase.app.classic is None:
raise RuntimeError('Needs updating for no-classic case.')
if (
_babase.app.classic.platform == 'android'
and _babase.app.classic.subplatform == 'google'
):
from babase._login import LoginAdapterGPGS
self.login_adapters[LoginType.GPGS] = LoginAdapterGPGS()
def on_app_loading(self) -> None:
"""Should be called at standard on_app_loading time."""
for adapter in self.login_adapters.values():
adapter.on_app_loading()
def set_primary_credentials(self, credentials: str | None) -> None:
"""Set credentials for the primary app account."""
raise RuntimeError('This should be overridden.')
def have_primary_credentials(self) -> bool:
"""Are credentials currently set for the primary app account?
Note that this does not mean these credentials are currently valid;
only that they exist. If/when credentials are validated, the 'primary'
account handle will be set.
"""
raise RuntimeError('This should be overridden.')
@property
def primary(self) -> AccountV2Handle | None:
"""The primary account for the app, or None if not logged in."""
return self.do_get_primary()
def do_get_primary(self) -> AccountV2Handle | None:
"""Internal - should be overridden by subclass."""
return None
def on_primary_account_changed(
self, account: AccountV2Handle | None
) -> None:
"""Callback run after the primary account changes.
Will be called with None on log-outs and when new credentials
are set but have not yet been verified.
"""
assert _babase.in_logic_thread()
# Currently don't do anything special on sign-outs.
if account is None:
return
# If this new account has a workspace, update it and ask to be
# informed when that process completes.
if account.workspaceid is not None:
assert account.workspacename is not None
if (
not self._initial_sign_in_completed
and not self._kicked_off_workspace_load
):
self._kicked_off_workspace_load = True
_babase.app.workspaces.set_active_workspace(
account=account,
workspaceid=account.workspaceid,
workspacename=account.workspacename,
on_completed=self._on_set_active_workspace_completed,
)
else:
# Don't activate workspaces if we've already told the game
# that initial-log-in is done or if we've already kicked
# off a workspace load.
_babase.screenmessage(
f'\'{account.workspacename}\''
f' will be activated at next app launch.',
color=(1, 1, 0),
)
_babase.getsimplesound('error').play()
return
# Ok; no workspace to worry about; carry on.
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_babase.app.on_initial_sign_in_completed()
def on_active_logins_changed(self, logins: dict[LoginType, str]) -> None:
"""Should be called when logins for the active account change."""
for adapter in self.login_adapters.values():
adapter.set_active_logins(logins)
def on_implicit_sign_in(
self, login_type: LoginType, login_id: str, display_name: str
) -> None:
"""An implicit sign-in happened (called by native layer)."""
from babase._login import LoginAdapter
with _babase.ContextRef.empty():
self.login_adapters[login_type].set_implicit_login_state(
LoginAdapter.ImplicitLoginState(
login_id=login_id, display_name=display_name
)
)
def on_implicit_sign_out(self, login_type: LoginType) -> None:
"""An implicit sign-out happened (called by native layer)."""
with _babase.ContextRef.empty():
self.login_adapters[login_type].set_implicit_login_state(None)
def on_no_initial_primary_account(self) -> None:
"""Callback run if the app has no primary account after launch.
Either this callback or on_primary_account_changed will be called
within a few seconds of app launch; the app can move forward
with the startup sequence at that point.
"""
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_babase.app.on_initial_sign_in_completed()
@staticmethod
def _hashstr(val: str) -> str:
md5 = hashlib.md5()
md5.update(val.encode())
return md5.hexdigest()
def on_implicit_login_state_changed(
self,
login_type: LoginType,
state: LoginAdapter.ImplicitLoginState | None,
) -> None:
"""Called when implicit login state changes.
Login systems that tend to sign themselves in/out in the
background are considered implicit. We may choose to honor or
ignore their states, allowing the user to opt for other login
types even if the default implicit one can't be explicitly
logged out or otherwise controlled.
"""
from babase._language import Lstr
assert _babase.in_logic_thread()
cfg = _babase.app.config
cfgkey = 'ImplicitLoginStates'
cfgdict = _babase.app.config.setdefault(cfgkey, {})
# Store which (if any) adapter is currently implicitly signed in.
# Making the assumption there will only ever be one implicit
# adapter at a time; may need to update this if that changes.
prev_state = cfgdict.get(login_type.value)
if state is None:
self._implicit_signed_in_adapter = None
new_state = cfgdict[login_type.value] = None
else:
self._implicit_signed_in_adapter = self.login_adapters[login_type]
new_state = cfgdict[login_type.value] = self._hashstr(
state.login_id
)
# Special case: if the user is already signed in but not with
# this implicit login, we may want to let them know that the
# 'Welcome back FOO' they likely just saw is not actually
# accurate.
if (
self.primary is not None
and not self.login_adapters[login_type].is_back_end_active()
):
if login_type is LoginType.GPGS:
service_str = Lstr(resource='googlePlayText')
else:
service_str = None
if service_str is not None:
_babase.apptimer(
2.0,
tpartial(
_babase.screenmessage,
Lstr(
resource='notUsingAccountText',
subs=[
('${ACCOUNT}', state.display_name),
('${SERVICE}', service_str),
],
),
(1, 0.5, 0),
),
)
cfg.commit()
# We want to respond any time the implicit state changes;
# generally this means the user has explicitly signed in/out or
# switched accounts within that back-end.
if prev_state != new_state:
if DEBUG_LOG:
logging.debug(
'AccountV2: Implicit state changed (%s -> %s);'
' will update app sign-in state accordingly.',
prev_state,
new_state,
)
self._implicit_state_changed = True
# We may want to auto-sign-in based on this new state.
self._update_auto_sign_in()
def on_cloud_connectivity_changed(self, connected: bool) -> None:
"""Should be called with cloud connectivity changes."""
del connected # Unused.
assert _babase.in_logic_thread()
# We may want to auto-sign-in based on this new state.
self._update_auto_sign_in()
def _update_auto_sign_in(self) -> None:
plus = _babase.app.plus
assert plus is not None
# If implicit state has changed, try to respond.
if self._implicit_state_changed:
if self._implicit_signed_in_adapter is None:
# If implicit back-end is signed out, follow suit
# immediately; no need to wait for network connectivity.
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing out as result'
' of implicit state change...',
)
plus.accounts.set_primary_credentials(None)
self._implicit_state_changed = False
# Once we've made a move here we don't want to
# do any more automatic stuff.
self._can_do_auto_sign_in = False
else:
# Ok; we've got a new implicit state. If we've got
# connectivity, let's attempt to sign in with it.
# Consider this an 'explicit' sign in because the
# implicit-login state change presumably was triggered
# by some user action (signing in, signing out, or
# switching accounts via the back-end).
# NOTE: should test case where we don't have
# connectivity here.
if plus.cloud.is_connected():
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing in as result'
' of implicit state change...',
)
self._implicit_signed_in_adapter.sign_in(
self._on_explicit_sign_in_completed,
description='implicit state change',
)
self._implicit_state_changed = False
# Once we've made a move here we don't want to
# do any more automatic stuff.
self._can_do_auto_sign_in = False
if not self._can_do_auto_sign_in:
return
# If we're not currently signed in, we have connectivity, and
# we have an available implicit login, auto-sign-in with it once.
# The implicit-state-change logic above should keep things
# mostly in-sync, but that might not always be the case due to
# connectivity or other issues. We prefer to keep people signed
# in as a rule, even if there are corner cases where this might
# not be what they want (A user signing out and then restarting
# may be auto-signed back in).
connected = plus.cloud.is_connected()
signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
signed_in_v2 = plus.accounts.have_primary_credentials()
if (
connected
and not signed_in_v1
and not signed_in_v2
and self._implicit_signed_in_adapter is not None
):
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing in due to on-launch-auto-sign-in...',
)
self._can_do_auto_sign_in = False # Only ATTEMPT once
self._implicit_signed_in_adapter.sign_in(
self._on_implicit_sign_in_completed, description='auto-sign-in'
)
def _on_explicit_sign_in_completed(
self,
adapter: LoginAdapter,
result: LoginAdapter.SignInResult | Exception,
) -> None:
"""A sign-in has completed that the user asked for explicitly."""
from babase._language import Lstr
del adapter # Unused.
plus = _babase.app.plus
assert plus is not None
# Make some noise on errors since the user knows a
# sign-in attempt is happening in this case (the 'explicit' part).
if isinstance(result, Exception):
# We expect the occasional communication errors;
# Log a full exception for anything else though.
if not isinstance(result, CommunicationError):
logging.warning(
'Error on explicit accountv2 sign in attempt.',
exc_info=result,
)
# For now just show 'error'. Should do better than this.
_babase.screenmessage(
Lstr(resource='internal.signInErrorText'),
color=(1, 0, 0),
)
_babase.getsimplesound('error').play()
# Also I suppose we should sign them out in this case since
# it could be misleading to be still signed in with the old
# account.
plus.accounts.set_primary_credentials(None)
return
plus.accounts.set_primary_credentials(result.credentials)
def _on_implicit_sign_in_completed(
self,
adapter: LoginAdapter,
result: LoginAdapter.SignInResult | Exception,
) -> None:
"""A sign-in has completed that the user didn't ask for explicitly."""
plus = _babase.app.plus
assert plus is not None
del adapter # Unused.
# Log errors but don't inform the user; they're not aware of this
# attempt and ignorance is bliss.
if isinstance(result, Exception):
# We expect the occasional communication errors;
# Log a full exception for anything else though.
if not isinstance(result, CommunicationError):
logging.warning(
'Error on implicit accountv2 sign in attempt.',
exc_info=result,
)
return
# If we're still connected and still not signed in,
# plug in the credentials we got. We want to be extra cautious
# in case the user has since explicitly signed in since we
# kicked off.
connected = plus.cloud.is_connected()
signed_in_v1 = plus.get_v1_account_state() == 'signed_in'
signed_in_v2 = plus.accounts.have_primary_credentials()
if connected and not signed_in_v1 and not signed_in_v2:
plus.accounts.set_primary_credentials(result.credentials)
def _on_set_active_workspace_completed(self) -> None:
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_babase.app.on_initial_sign_in_completed()
class AccountV2Handle:
"""Handle for interacting with a V2 account.
This class supports the 'with' statement, which is how it is
used with some operations such as cloud messaging.
"""
def __init__(self) -> None:
self.tag = '?'
self.workspacename: str | None = None
self.workspaceid: str | None = None
# Login types and their display-names associated with this account.
self.logins: dict[LoginType, str] = {}
def __enter__(self) -> None:
"""Support for "with" statement.
This allows cloud messages to be sent on our behalf.
"""
def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> Any:
"""Support for "with" statement.
This allows cloud messages to be sent on our behalf.
"""

837
dist/ba_data/python/babase/_app.py vendored Normal file
View file

@ -0,0 +1,837 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the high level state of the app."""
from __future__ import annotations
import os
from enum import Enum
import logging
from typing import TYPE_CHECKING
from concurrent.futures import ThreadPoolExecutor
from functools import cached_property
from efro.call import tpartial
import _babase
from babase._language import LanguageSubsystem
from babase._plugin import PluginSubsystem
from babase._meta import MetadataSubsystem
from babase._net import NetworkSubsystem
from babase._workspace import WorkspaceSubsystem
from babase._appcomponent import AppComponentSubsystem
from babase._appmodeselector import AppModeSelector
from babase._appintent import AppIntentDefault, AppIntentExec
if TYPE_CHECKING:
import asyncio
from typing import Any, Callable
from concurrent.futures import Future
import babase
from babase import AppIntent, AppMode, AppSubsystem
from babase._apputils import AppHealthMonitor
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_BEGIN__
# This section generated by batools.appmodule; do not edit.
from baclassic import ClassicSubsystem
from baplus import PlusSubsystem
from bauiv1 import UIV1Subsystem
# __FEATURESET_APP_SUBSYSTEM_IMPORTS_END__
class App:
"""A class for high level app functionality and state.
Category: **App Classes**
Use babase.app to access the single shared instance of this class.
Note that properties not documented here should be considered internal
and subject to change without warning.
"""
# pylint: disable=too-many-public-methods
plugins: PluginSubsystem
lang: LanguageSubsystem
health_monitor: AppHealthMonitor
class State(Enum):
"""High level state the app can be in."""
# The app launch process has not yet begun.
INITIAL = 0
# Our app subsystems are being inited but should not yet interact.
LAUNCHING = 1
# App subsystems are inited and interacting, but the app has not
# yet embarked on a high level course of action. It is doing initial
# account logins, workspace & asset downloads, etc. in order to
# prepare for this.
LOADING = 2
# All pieces are in place and the app is now doing its thing.
RUNNING = 3
# The app is backgrounded or otherwise suspended.
PAUSED = 4
# The app is shutting down.
SHUTTING_DOWN = 5
@property
def aioloop(self) -> asyncio.AbstractEventLoop:
"""The logic thread's asyncio event loop.
This allow async tasks to be run in the logic thread.
Note that, at this time, the asyncio loop is encapsulated
and explicitly stepped by the engine's logic thread loop and
thus things like asyncio.get_running_loop() will not return this
loop from most places in the logic thread; only from within a
task explicitly created in this loop.
"""
assert self._aioloop is not None
return self._aioloop
@property
def build_number(self) -> int:
"""Integer build number.
This value increases by at least 1 with each release of the game.
It is independent of the human readable babase.App.version string.
"""
assert isinstance(self._env['build_number'], int)
return self._env['build_number']
@property
def device_name(self) -> str:
"""Name of the device running the game."""
assert isinstance(self._env['device_name'], str)
return self._env['device_name']
@property
def config_file_path(self) -> str:
"""Where the game's config file is stored on disk."""
assert isinstance(self._env['config_file_path'], str)
return self._env['config_file_path']
@property
def version(self) -> str:
"""Human-readable version string; something like '1.3.24'.
This should not be interpreted as a number; it may contain
string elements such as 'alpha', 'beta', 'test', etc.
If a numeric version is needed, use 'babase.App.build_number'.
"""
assert isinstance(self._env['version'], str)
return self._env['version']
@property
def debug_build(self) -> bool:
"""Whether the app was compiled in debug mode.
Debug builds generally run substantially slower than non-debug
builds due to compiler optimizations being disabled and extra
checks being run.
"""
assert isinstance(self._env['debug_build'], bool)
return self._env['debug_build']
@property
def test_build(self) -> bool:
"""Whether the game was compiled in test mode.
Test mode enables extra checks and features that are useful for
release testing but which do not slow the game down significantly.
"""
assert isinstance(self._env['test_build'], bool)
return self._env['test_build']
@property
def data_directory(self) -> str:
"""Path where static app data lives."""
assert isinstance(self._env['data_directory'], str)
return self._env['data_directory']
@property
def python_directory_user(self) -> str | None:
"""Path where ballistica expects its custom user scripts (mods) to live.
Be aware that this value may be None if ballistica is running in
a non-standard environment, and that python-path modifications may
cause modules to be loaded from other locations.
"""
assert isinstance(self._env['python_directory_user'], (str, type(None)))
return self._env['python_directory_user']
@property
def python_directory_app(self) -> str | None:
"""Path where ballistica expects its bundled modules to live.
Be aware that this value may be None if ballistica is running in
a non-standard environment, and that python-path modifications may
cause modules to be loaded from other locations.
"""
assert isinstance(self._env['python_directory_app'], (str, type(None)))
return self._env['python_directory_app']
@property
def python_directory_app_site(self) -> str | None:
"""Path where ballistica expects its bundled pip modules to live.
Be aware that this value may be None if ballistica is running in
a non-standard environment, and that python-path modifications may
cause modules to be loaded from other locations.
"""
assert isinstance(
self._env['python_directory_app_site'], (str, type(None))
)
return self._env['python_directory_app_site']
@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
def api_version(self) -> int:
"""The app's api version.
Only Python modules and packages associated with the current API
version number will be detected by the game (see the ba_meta tag).
This value will change whenever substantial backward-incompatible
changes are introduced to ballistica APIs. When that happens,
modules/packages should be updated accordingly and set to target
the newer API version number.
"""
from babase._meta import CURRENT_API_VERSION
return CURRENT_API_VERSION
@property
def on_tv(self) -> bool:
"""Whether the game is currently running on a TV."""
assert isinstance(self._env['on_tv'], bool)
return self._env['on_tv']
@property
def vr_mode(self) -> bool:
"""Whether the game is currently running in VR."""
assert isinstance(self._env['vr_mode'], bool)
return self._env['vr_mode']
def __init__(self) -> None:
"""(internal)
Do not instantiate this class; use babase.app to access
the single shared instance.
"""
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
return
self.state = self.State.INITIAL
self._subsystems: list[AppSubsystem] = []
self._app_bootstrapping_complete = False
self._called_on_app_launching = False
self._launch_completed = False
self._initial_sign_in_completed = False
self._meta_scan_completed = False
self._called_on_app_loading = False
self._called_on_app_running = False
self._app_paused = False
self._subsystem_registration_ended = False
self._pending_apply_app_config = False
# Config.
self.config_file_healthy = False
# This is incremented any time the app is backgrounded/foregrounded;
# can be a simple way to determine if network data should be
# refreshed/etc.
self.fg_state = 0
self._aioloop: asyncio.AbstractEventLoop | None = None
self._env = _babase.env()
self.protocol_version: int = self._env['protocol_version']
assert isinstance(self.protocol_version, int)
self.toolbar_test: bool = self._env['toolbar_test']
assert isinstance(self.toolbar_test, bool)
self.demo_mode: bool = self._env['demo_mode']
assert isinstance(self.demo_mode, bool)
self.arcade_mode: bool = self._env['arcade_mode']
assert isinstance(self.arcade_mode, bool)
self.headless_mode: bool = self._env['headless_mode']
assert isinstance(self.headless_mode, bool)
self.iircade_mode: bool = self._env['iircade_mode']
assert isinstance(self.iircade_mode, bool)
# Default executor which can be used for misc background processing.
# It should also be passed to any additional asyncio loops we create
# so that everything shares the same single set of worker threads.
self.threadpool = ThreadPoolExecutor(thread_name_prefix='baworker')
self._config: babase.AppConfig | None = None
self.components = AppComponentSubsystem()
self.meta = MetadataSubsystem()
self.net = NetworkSubsystem()
self.workspaces = WorkspaceSubsystem()
self._pending_intent: AppIntent | None = None
self._intent: AppIntent | None = None
self._mode: AppMode | None = None
# Controls which app-modes we use for handling given app-intents.
# Plugins can override this to change high level app behavior and
# spinoff projects can change the default implementation for the
# same effect.
self.mode_selector: AppModeSelector | None = None
self._asyncio_timer: babase.AppTimer | None = None
def postinit(self) -> None:
"""Called after we've been inited and assigned to babase.app."""
if os.environ.get('BA_RUNNING_WITH_DUMMY_MODULES') == '1':
return
# NOTE: the reason we need a postinit here is that
# some of this stuff accesses babase.app and that doesn't
# exist yet as of our __init__() call.
self.lang = LanguageSubsystem()
self.plugins = PluginSubsystem()
def register_subsystem(self, subsystem: AppSubsystem) -> None:
"""Called by the AppSubsystem class. Do not use directly."""
# We only allow registering new subsystems if we've not yet
# reached the 'running' state. This ensures that all subsystems
# receive a consistent set of callbacks starting with
# on_app_running().
if self._subsystem_registration_ended:
raise RuntimeError(
'Subsystems can no longer be registered at this point.'
)
self._subsystems.append(subsystem)
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 threadpool_submit_no_wait(self, call: Callable[[], Any]) -> None:
"""Submit work to our threadpool and log any errors.
Use this when you want to run something asynchronously but don't
intend to call result() on it to handle errors/etc.
"""
fut = self.threadpool.submit(call)
fut.add_done_callback(self._threadpool_no_wait_done)
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_BEGIN__
# This section generated by batools.appmodule; do not edit.
@cached_property
def classic(self) -> ClassicSubsystem | None:
"""Our classic subsystem (if available)."""
# pylint: disable=cyclic-import
try:
from baclassic import ClassicSubsystem
return ClassicSubsystem()
except ImportError:
return None
except Exception:
logging.exception('Error importing baclassic.')
return None
@cached_property
def plus(self) -> PlusSubsystem | None:
"""Our plus subsystem (if available)."""
# pylint: disable=cyclic-import
try:
from baplus import PlusSubsystem
return PlusSubsystem()
except ImportError:
return None
except Exception:
logging.exception('Error importing baplus.')
return None
@cached_property
def ui_v1(self) -> UIV1Subsystem:
"""Our ui_v1 subsystem (always available)."""
# pylint: disable=cyclic-import
from bauiv1 import UIV1Subsystem
return UIV1Subsystem()
# __FEATURESET_APP_SUBSYSTEM_PROPERTIES_END__
def set_intent(self, intent: AppIntent) -> None:
"""Set the intent for the app.
Intent defines what the app is trying to do at a given time.
This call is asynchronous; the intent switch will happen in the
logic thread in the near future. If set_intent is called
repeatedly before the change takes place, the final intent to be
set will be used.
"""
# Mark this one as pending. We do this synchronously so that the
# last one marked actually takes effect if there is overlap
# (doing this in the bg thread could result in race conditions).
self._pending_intent = intent
# 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.
self.threadpool_submit_no_wait(tpartial(self._set_intent, intent))
def _set_intent(self, intent: AppIntent) -> None:
# This should be running in a bg thread.
assert not _babase.in_logic_thread()
try:
# Ask the selector what app-mode to use for this intent.
if self.mode_selector is None:
raise RuntimeError('No AppModeSelector set.')
modetype = self.mode_selector.app_mode_for_intent(intent)
# Make sure the app-mode they return *actually* supports the
# intent.
if not modetype.supports_intent(intent):
raise RuntimeError(
f'Intent {intent} is not supported by AppMode class'
f' {modetype}'
)
# Kick back to the logic thread to apply.
mode = modetype()
_babase.pushcall(
tpartial(self._apply_intent, intent, mode),
from_other_thread=True,
)
except Exception:
logging.exception('Error setting app intent to %s.', intent)
_babase.pushcall(
tpartial(self._apply_intent_error, intent),
from_other_thread=True,
)
def _apply_intent(self, intent: AppIntent, mode: AppMode) -> None:
assert _babase.in_logic_thread()
# ONLY apply this intent if it is still the most recent one
# submitted.
if intent is not self._pending_intent:
return
# If the app-mode for this intent is different than the active
# one, switch.
if type(mode) is not type(self._mode):
if self._mode is None:
is_initial_mode = True
else:
is_initial_mode = False
try:
self._mode.on_deactivate()
except Exception:
logging.exception(
'Error deactivating app-mode %s.', self._mode
)
self._mode = mode
try:
mode.on_activate()
except Exception:
# Hmm; what should we do in this case?...
logging.exception('Error activating app-mode %s.', mode)
if is_initial_mode:
_babase.on_initial_app_mode_set()
try:
mode.handle_intent(intent)
except Exception:
logging.exception(
'Error handling intent %s in app-mode %s.', intent, mode
)
def _apply_intent_error(self, intent: AppIntent) -> None:
from babase._language import Lstr
del intent # Unused.
_babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
_babase.getsimplesound('error').play()
def run(self) -> None:
"""Run the app to completion.
Note that this only works on platforms where ballistica
manages its own event loop.
"""
_babase.run_app()
def on_app_bootstrapping_complete(self) -> None:
"""Called by the C++ layer once its ready to rock."""
assert _babase.in_logic_thread()
assert not self._app_bootstrapping_complete
self._app_bootstrapping_complete = True
self._update_state()
def on_app_launching(self) -> None:
"""Called when the app enters the launching state.
Here we can put together subsystems and other pieces for the
app, but most things should not be doing any work yet.
"""
# pylint: disable=cyclic-import
from babase import _asyncio
from babase import _appconfig
from babase._apputils import AppHealthMonitor
from babase import _env
assert _babase.in_logic_thread()
_env.on_app_launching()
self._aioloop = _asyncio.setup_asyncio()
self.health_monitor = AppHealthMonitor()
# Only proceed if our config file is healthy so we don't
# overwrite a broken one or whatnot and wipe out data.
if not self.config_file_healthy:
if self.classic is not None:
handled = self.classic.show_config_error_window()
if handled:
return
# For now on other systems we just overwrite the bum config.
# At this point settings are already set; lets just commit them
# to disk.
_appconfig.commit_app_config(force=True)
# __FEATURESET_APP_SUBSYSTEM_CREATE_BEGIN__
# This section generated by batools.appmodule; do not edit.
# Poke these attrs to create all our subsystems.
_ = self.plus
_ = self.classic
_ = self.ui_v1
# __FEATURESET_APP_SUBSYSTEM_CREATE_END__
self._launch_completed = True
self._update_state()
def on_app_loading(self) -> None:
"""Called when the app enters the loading state.
At this point, all built-in pieces of the app should be in place
and can start doing 'work'. Though at a high level, the goal of
the app at this point is only to sign in to initial accounts,
download workspaces, and otherwise prepare itself to really
'run'.
"""
from babase._apputils import log_dumped_app_state
assert _babase.in_logic_thread()
# Get meta-system scanning built-in stuff in the bg.
self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete)
# If any traceback dumps happened last run, log and clear them.
log_dumped_app_state()
# Inform all app subsystems in the same order they were inited.
# Operate on a copy here because subsystems can still be added
# at this point.
for subsystem in self._subsystems.copy():
try:
subsystem.on_app_loading()
except Exception:
logging.exception(
'Error in on_app_loading for subsystem %s.', subsystem
)
# Normally plus tells us when initial sign-in is done. If it's
# not present, however, we just do that ourself so we can
# proceed on to the running state.
if self.plus is None:
_babase.pushcall(self.on_initial_sign_in_completed)
def on_meta_scan_complete(self) -> None:
"""Called when meta-scan is done doing its thing."""
assert _babase.in_logic_thread()
# Now that we know what's out there, build our final plugin set.
self.plugins.on_meta_scan_complete()
assert not self._meta_scan_completed
self._meta_scan_completed = True
self._update_state()
def on_app_running(self) -> None:
"""Called when the app enters the running state.
At this point, all workspaces, initial accounts, etc. are in place
and we can actually get started doing whatever we're gonna do.
"""
assert _babase.in_logic_thread()
# Let our native layer know.
_babase.on_app_running()
# Set a default app-mode-selector. Plugins can then override
# this if they want in the on_app_running callback below.
self.mode_selector = self.DefaultAppModeSelector()
# Inform all app subsystems in the same order they were inited.
# Operate on a copy here because subsystems can still be added
# at this point.
for subsystem in self._subsystems.copy():
try:
subsystem.on_app_running()
except Exception:
logging.exception(
'Error in on_app_running for subsystem %s.', subsystem
)
# Cut off new subsystem additions at this point.
self._subsystem_registration_ended = True
# If 'exec' code was provided to the app, always kick that off
# here as an intent.
exec_cmd = _babase.exec_arg()
if exec_cmd is not None:
self.set_intent(AppIntentExec(exec_cmd))
elif self._pending_intent is None:
# Otherwise tell the app to do its default thing *only* if a
# plugin hasn't already told it to do something.
self.set_intent(AppIntentDefault())
def push_apply_app_config(self) -> None:
"""Internal. Use app.config.apply() to apply app config changes."""
# To be safe, let's run this by itself in the event loop.
# This avoids potential trouble if this gets called mid-draw or
# something like that.
self._pending_apply_app_config = True
_babase.pushcall(self._apply_app_config, raw=True)
def _apply_app_config(self) -> None:
assert _babase.in_logic_thread()
_babase.lifecyclelog('apply-app-config')
# If multiple apply calls have been made, only actually apply once.
if not self._pending_apply_app_config:
return
_pending_apply_app_config = False
# Inform all app subsystems in the same order they were inited.
# Operate on a copy here because subsystems may still be able to
# be added at this point.
for subsystem in self._subsystems.copy():
try:
subsystem.do_apply_app_config()
except Exception:
logging.exception(
'Error in do_apply_app_config for subsystem %s.', subsystem
)
# Let the native layer do its thing.
_babase.do_apply_app_config()
class DefaultAppModeSelector(AppModeSelector):
"""Decides which app modes to use to handle intents.
The behavior here is generated by the project updater based on
the set of feature-sets in the project. Spinoff projects can
also inject their own behavior by replacing the text
'__GOOD_PLACE_FOR_CUSTOM_SPINOFF_LOGIC__' with their own code
through spinoff filtering.
It is also possible to modify mode selection behavior by writing
a custom AppModeSelector class and replacing app.mode_selector
with an instance of it. This is a good way to go if you are
modifying app behavior with a plugin instead of in a spinoff
project.
"""
def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode]:
# pylint: disable=cyclic-import
# __GOOD_PLACE_FOR_CUSTOM_SPINOFF_LOGIC__
# __DEFAULT_APP_MODE_SELECTION_BEGIN__
# This section generated by batools.appmodule; do not edit.
# Hmm; need to think about how we auto-construct this; how
# should we determine which app modes to check and in what
# order?
import bascenev1
import babase
if bascenev1.SceneV1AppMode.supports_intent(intent):
return bascenev1.SceneV1AppMode
if babase.EmptyAppMode.supports_intent(intent):
return babase.EmptyAppMode
raise RuntimeError(f'No handler found for intent {type(intent)}.')
# __DEFAULT_APP_MODE_SELECTION_END__
def _update_state(self) -> None:
# pylint: disable=too-many-branches
assert _babase.in_logic_thread()
if self._app_paused:
# Entering paused state:
if self.state is not self.State.PAUSED:
self.state = self.State.PAUSED
self.on_app_pause()
else:
# Leaving paused state:
if self.state is self.State.PAUSED:
self.on_app_resume()
# Handle initially entering or returning to other states.
if self._initial_sign_in_completed and self._meta_scan_completed:
if self.state != self.State.RUNNING:
self.state = self.State.RUNNING
_babase.lifecyclelog('app state running')
if not self._called_on_app_running:
self._called_on_app_running = True
self.on_app_running()
elif self._launch_completed:
if self.state is not self.State.LOADING:
self.state = self.State.LOADING
_babase.lifecyclelog('app state loading')
if not self._called_on_app_loading:
self._called_on_app_loading = True
self.on_app_loading()
else:
# Only thing left is launching. We shouldn't be getting
# called before at least that is complete.
assert self._app_bootstrapping_complete
if self.state is not self.State.LAUNCHING:
self.state = self.State.LAUNCHING
_babase.lifecyclelog('app state launching')
if not self._called_on_app_launching:
self._called_on_app_launching = True
self.on_app_launching()
def pause(self) -> None:
"""Should be called by the native layer when the app pauses."""
assert not self._app_paused # Should avoid redundant calls.
self._app_paused = True
self._update_state()
def resume(self) -> None:
"""Should be called by the native layer when the app resumes."""
assert self._app_paused # Should avoid redundant calls.
self._app_paused = False
self._update_state()
def on_app_pause(self) -> None:
"""Called when the app goes to a paused state."""
assert _babase.in_logic_thread()
# Pause all app subsystems in the opposite order they were inited.
for subsystem in reversed(self._subsystems):
try:
subsystem.on_app_pause()
except Exception:
logging.exception(
'Error in on_app_pause for subsystem %s.', subsystem
)
def on_app_resume(self) -> None:
"""Called when resuming."""
assert _babase.in_logic_thread()
self.fg_state += 1
# Resume all app subsystems in the same order they were inited.
for subsystem in self._subsystems:
try:
subsystem.on_app_resume()
except Exception:
logging.exception(
'Error in on_app_resume for subsystem %s.', subsystem
)
def on_app_shutdown(self) -> None:
"""(internal)"""
assert _babase.in_logic_thread()
self.state = self.State.SHUTTING_DOWN
# Shutdown all app subsystems in the opposite order they were
# inited.
for subsystem in reversed(self._subsystems):
try:
subsystem.on_app_shutdown()
except Exception:
logging.exception(
'Error in on_app_shutdown for subsystem %s.', subsystem
)
def read_config(self) -> None:
"""(internal)"""
from babase._appconfig import read_app_config
self._config, self.config_file_healthy = read_app_config()
def handle_deep_link(self, url: str) -> None:
"""Handle a deep link URL."""
from babase._language import Lstr
assert _babase.in_logic_thread()
appname = _babase.appname()
if url.startswith(f'{appname}://code/'):
code = url.replace(f'{appname}://code/', '')
if self.classic is not None:
self.classic.accounts.add_pending_promo_code(code)
else:
try:
_babase.screenmessage(
Lstr(resource='errorText'), color=(1, 0, 0)
)
_babase.getsimplesound('error').play()
except ImportError:
pass
def on_initial_sign_in_completed(self) -> None:
"""Callback to be run after initial sign-in (or lack thereof).
This normally gets called by the plus subsystem.
This period includes things such as syncing account workspaces
or other data so it may take a substantial amount of time.
This should also run after a short amount of time if no login
has occurred.
"""
assert _babase.in_logic_thread()
assert not self._initial_sign_in_completed
# Tell meta it can start scanning extra stuff that just showed up
# (namely account workspaces).
self.meta.start_extra_scan()
self._initial_sign_in_completed = True
self._update_state()

View file

@ -0,0 +1,94 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides the AppComponent class."""
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar, cast
import _babase
if TYPE_CHECKING:
from typing import Callable, Any
T = TypeVar('T', bound=type)
class AppComponentSubsystem:
"""Subsystem for wrangling AppComponents.
Category: **App Classes**
This subsystem acts as a registry for classes providing particular
functionality for the app, and allows plugins or other custom code
to easily override said functionality.
Access the single shared instance of this class at
babase.app.components.
The general idea with this setup is that a base-class Foo is defined
to provide some functionality and then anyone wanting that
functionality calls getclass(Foo) to return the current registered
implementation. The user should not know or care whether they are
getting Foo itself or some subclass of it.
Change-callbacks can also be requested for base classes which will
fire in a deferred manner when particular base-classes are
overridden.
"""
def __init__(self) -> None:
self._implementations: dict[type, type] = {}
self._prev_implementations: dict[type, type] = {}
self._dirty_base_classes: set[type] = set()
self._change_callbacks: dict[type, list[Callable[[Any], None]]] = {}
def setclass(self, baseclass: type, implementation: type) -> None:
"""Set the class providing an implementation of some base-class.
The provided implementation class must be a subclass of baseclass.
"""
# Currently limiting this to logic-thread use; can revisit if
# needed (would need to guard access to our implementations
# dict).
assert _babase.in_logic_thread()
if not issubclass(implementation, baseclass):
raise TypeError(
f'Implementation {implementation}'
f' is not a subclass of baseclass {baseclass}.'
)
self._implementations[baseclass] = implementation
# If we're the first thing getting dirtied, set up a callback to
# clean everything. And add ourself to the dirty list
# regardless.
if not self._dirty_base_classes:
_babase.pushcall(self._run_change_callbacks)
self._dirty_base_classes.add(baseclass)
def getclass(self, baseclass: T) -> T:
"""Given a base-class, return the current implementation class.
If no custom implementation has been set, the provided
base-class is returned.
"""
assert _babase.in_logic_thread()
del baseclass # Unused.
return cast(T, None)
def register_change_callback(
self, baseclass: T, callback: Callable[[T], None]
) -> None:
"""Register a callback to fire on class implementation changes.
The callback will be scheduled to run in the logic thread event
loop. Note that any further setclass calls before the callback
runs will not result in additional callbacks.
"""
assert _babase.in_logic_thread()
self._change_callbacks.setdefault(baseclass, []).append(callback)
def _run_change_callbacks(self) -> None:
pass

184
dist/ba_data/python/babase/_appconfig.py vendored Normal file
View file

@ -0,0 +1,184 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides the AppConfig class."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING:
from typing import Any
_g_pending_apply = False # pylint: disable=invalid-name
class AppConfig(dict):
"""A special dict that holds the game's persistent configuration values.
Category: **App Classes**
It also provides methods for fetching values with app-defined fallback
defaults, applying contained values to the game, and committing the
config to storage.
Call babase.appconfig() to get the single shared instance of this class.
AppConfig data is stored as json on disk on so make sure to only place
json-friendly values in it (dict, list, str, float, int, bool).
Be aware that tuples will be quietly converted to lists when stored.
"""
def resolve(self, key: str) -> Any:
"""Given a string key, return a config value (type varies).
This will substitute application defaults for values not present in
the config dict, filter some invalid values, etc. Note that these
values do not represent the state of the app; simply the state of its
config. Use babase.App to access actual live state.
Raises an Exception for unrecognized key names. To get the list of keys
supported by this method, use babase.AppConfig.builtin_keys(). Note
that it is perfectly legal to store other data in the config; it just
needs to be accessed through standard dict methods and missing values
handled manually.
"""
return _babase.resolve_appconfig_value(key)
def default_value(self, key: str) -> Any:
"""Given a string key, return its predefined default value.
This is the value that will be returned by babase.AppConfig.resolve()
if the key is not present in the config dict or of an incompatible
type.
Raises an Exception for unrecognized key names. To get the list of keys
supported by this method, use babase.AppConfig.builtin_keys(). Note
that it is perfectly legal to store other data in the config; it just
needs to be accessed through standard dict methods and missing values
handled manually.
"""
return _babase.get_appconfig_default_value(key)
def builtin_keys(self) -> list[str]:
"""Return the list of valid key names recognized by babase.AppConfig.
This set of keys can be used with resolve(), default_value(), etc.
It does not vary across platforms and may include keys that are
obsolete or not relevant on the current running version. (for instance,
VR related keys on non-VR platforms). This is to minimize the amount
of platform checking necessary)
Note that it is perfectly legal to store arbitrary named data in the
config, but in that case it is up to the user to test for the existence
of the key in the config dict, fall back to consistent defaults, etc.
"""
return _babase.get_appconfig_builtin_keys()
def apply(self) -> None:
"""Apply config values to the running app.
This call is thread-safe and asynchronous; changes will happen
in the next logic event loop cycle.
"""
_babase.app.push_apply_app_config()
def commit(self) -> None:
"""Commits the config to local storage.
Note that this call is asynchronous so the actual write to disk may not
occur immediately.
"""
commit_app_config()
def apply_and_commit(self) -> None:
"""Run apply() followed by commit(); for convenience.
(This way the commit() will not occur if apply() hits invalid data)
"""
self.apply()
self.commit()
def read_app_config() -> tuple[AppConfig, bool]:
"""Read the app config."""
import os
import json
config_file_healthy = False
# 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.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()
config_file_healthy = True
except Exception as exc:
print(
(
'error reading config file at time '
+ str(_babase.apptime())
+ ': \''
+ config_file_path
+ '\':\n'
),
exc,
)
# Whenever this happens lets back up the broken one just in case it
# gets overwritten accidentally.
print(
(
'backing up current config file to \''
+ config_file_path
+ ".broken\'"
)
)
try:
import shutil
shutil.copyfile(config_file_path, config_file_path + '.broken')
except Exception as exc2:
print('EXC copying broken config:', exc2)
config = AppConfig()
# Now attempt to read one of our 'prev' backup copies.
prev_path = config_file_path + '.prev'
try:
if os.path.exists(prev_path):
with open(prev_path, encoding='utf-8') as infile:
config_contents = infile.read()
config = AppConfig(json.loads(config_contents))
else:
config = AppConfig()
config_file_healthy = True
print('successfully read backup config.')
except Exception as exc2:
print('EXC reading prev backup config:', exc2)
return config, config_file_healthy
def commit_app_config(force: bool = False) -> None:
"""Commit the config to persistent storage.
Category: **General Utility Functions**
(internal)
"""
plus = _babase.app.plus
assert plus is not None
if not _babase.app.config_file_healthy and not force:
print(
'Current config file is broken; '
'skipping write to avoid losing settings.'
)
return
plus.mark_config_dirty()

View file

@ -0,0 +1,27 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppIntent functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class AppIntent:
"""A high level directive given to the app.
Category: **App Classes**
"""
class AppIntentDefault(AppIntent):
"""Tells the app to simply run in its default mode."""
class AppIntentExec(AppIntent):
"""Tells the app to exec some Python code."""
def __init__(self, code: str):
self.code = code

35
dist/ba_data/python/babase/_appmode.py vendored Normal file
View file

@ -0,0 +1,35 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from babase._appintent import AppIntent
class AppMode:
"""A high level mode for the app.
Category: **App Classes**
"""
@classmethod
def supports_intent(cls, intent: AppIntent) -> bool:
"""Return whether our mode can handle the provided intent."""
del intent
# Say no to everything by default. Let's make mode explicitly
# lay out everything they *do* support.
return False
def handle_intent(self, intent: AppIntent) -> None:
"""Handle an intent."""
def on_activate(self) -> None:
"""Called when the mode is being activated."""
def on_deactivate(self) -> None:
"""Called when the mode is being deactivated."""

View file

@ -0,0 +1,32 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from babase._appintent import AppIntent
from babase._appmode import AppMode
class AppModeSelector:
"""Defines which AppModes to use to handle given AppIntents.
Category: **App Classes**
The app calls an instance of this class when passed an AppIntent to
determine which AppMode to use to handle the intent. Plugins or
spinoff projects can modify high level app behavior by replacing or
modifying this.
"""
def app_mode_for_intent(self, intent: AppIntent) -> type[AppMode]:
"""Given an AppIntent, return the AppMode that should handle it.
If None is returned, the AppIntent will be ignored.
This is called in a background thread, so avoid any calls
limited to logic thread use/etc.
"""
raise RuntimeError('app_mode_for_intent() should be overridden.')

View file

@ -0,0 +1,52 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides the AppSubsystem base class."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING:
pass
class AppSubsystem:
"""Base class for an app subsystem.
Category: **App Classes**
An app 'subsystem' is a bit of a vague term, as pieces of the app
can technically be any class and are not required to use this, but
building one out of this base class provides some conveniences such
as predefined callbacks during app state changes.
Subsystems must be registered with the app before it completes its
transition to the 'running' state.
"""
def __init__(self) -> None:
_babase.app.register_subsystem(self)
def on_app_loading(self) -> None:
"""Called when the app reaches the loading state.
Note that subsystems created after the app switches to the
loading state will not receive this callback. Subsystems created
by plugins are an example of this.
"""
def on_app_running(self) -> None:
"""Called when the app reaches the running state."""
def on_app_pause(self) -> None:
"""Called when the app enters the paused state."""
def on_app_resume(self) -> None:
"""Called when the app exits the paused state."""
def on_app_shutdown(self) -> None:
"""Called when the app is shutting down."""
def do_apply_app_config(self) -> None:
"""Called when the app config should be applied."""

445
dist/ba_data/python/babase/_apputils.py vendored Normal file
View file

@ -0,0 +1,445 @@
# Released under the MIT License. See LICENSE for details.
#
"""Utility functionality related to the overall operation of the app."""
from __future__ import annotations
import gc
import os
import logging
from threading import Thread
from dataclasses import dataclass
from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.log import LogLevel
from efro.dataclassio import ioprepped, dataclass_to_json, dataclass_from_json
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Any, TextIO
import babase
def is_browser_likely_available() -> bool:
"""Return whether a browser likely exists on the current device.
category: General Utility Functions
If this returns False you may want to avoid calling babase.show_url()
with any lengthy addresses. (ba.show_url() will display an address
as a string in a window if unable to bring up a browser, but that
is only useful for simple URLs.)
"""
app = _babase.app
if app.classic is None:
logging.warning(
'is_browser_likely_available() needs to be updated'
' to work without classic.'
)
return True
platform = app.classic.platform
hastouchscreen = _babase.hastouchscreen()
# If we're on a vr device or an android device with no touchscreen,
# assume no browser.
# FIXME: Might not be the case anymore; should make this definable
# at the platform level.
if app.vr_mode or (platform == 'android' and not hastouchscreen):
return False
# Anywhere else assume we've got one.
return True
def get_remote_app_name() -> babase.Lstr:
"""(internal)"""
from babase import _language
return _language.Lstr(resource='remote_app.app_name')
def should_submit_debug_info() -> bool:
"""(internal)"""
return _babase.app.config.get('Submit Debug Info', True)
def handle_v1_cloud_log() -> None:
"""Called when new messages have been added to v1-cloud-log.
When this happens, we can upload our log to the server after a short
bit if desired.
"""
app = _babase.app
classic = app.classic
plus = app.plus
if classic is None or plus is None:
if _babase.do_once():
logging.warning(
'handle_v1_cloud_log should not be getting called'
' without classic and plus present.'
)
return
classic.log_have_new = True
if not classic.log_upload_timer_started:
def _put_log() -> None:
assert plus is not None
assert classic is not None
try:
sessionname = str(classic.get_foreground_host_session())
except Exception:
sessionname = 'unavailable'
try:
activityname = str(classic.get_foreground_host_activity())
except Exception:
activityname = 'unavailable'
info = {
'log': _babase.get_v1_cloud_log(),
'version': app.version,
'build': app.build_number,
'userAgentString': classic.legacy_user_agent_string,
'session': sessionname,
'activity': activityname,
'fatal': 0,
'userRanCommands': _babase.has_user_run_commands(),
'time': _babase.apptime(),
'userModded': _babase.workspaces_in_use(),
'newsShow': plus.get_news_show(),
}
def response(data: Any) -> None:
assert classic is not None
# A non-None response means success; lets
# take note that we don't need to report further
# log info this run
if data is not None:
classic.log_have_new = False
_babase.mark_log_sent()
classic.master_server_v1_post('bsLog', info, response)
classic.log_upload_timer_started = True
# Delay our log upload slightly in case other
# pertinent info gets printed between now and then.
with _babase.ContextRef.empty():
_babase.apptimer(3.0, _put_log)
# After a while, allow another log-put.
def _reset() -> None:
assert classic is not None
classic.log_upload_timer_started = False
if classic.log_have_new:
handle_v1_cloud_log()
if not _babase.is_log_full():
with _babase.ContextRef.empty():
_babase.apptimer(600.0, _reset)
def handle_leftover_v1_cloud_log_file() -> None:
"""Handle an un-uploaded v1-cloud-log from a previous run."""
# Only applies with classic present.
if _babase.app.classic is None:
return
try:
import json
if os.path.exists(_babase.get_v1_cloud_log_file_path()):
with open(
_babase.get_v1_cloud_log_file_path(), encoding='utf-8'
) as infile:
info = json.loads(infile.read())
infile.close()
do_send = should_submit_debug_info()
if do_send:
def response(data: Any) -> None:
# Non-None response means we were successful;
# lets kill it.
if data is not None:
try:
os.remove(_babase.get_v1_cloud_log_file_path())
except FileNotFoundError:
# Saw this in the wild. The file just existed
# a moment ago but I suppose something could have
# killed it since. ¯\_(ツ)_/¯
pass
_babase.app.classic.master_server_v1_post(
'bsLog', info, response
)
else:
# If they don't want logs uploaded just kill it.
os.remove(_babase.get_v1_cloud_log_file_path())
except Exception:
from babase import _error
_error.print_exception('Error handling leftover log file.')
def garbage_collect_session_end() -> None:
"""Run explicit garbage collection with extra checks for session end."""
gc.collect()
# Can be handy to print this to check for leaks between games.
if bool(False):
print('PY OBJ COUNT', len(gc.get_objects()))
if gc.garbage:
print('PYTHON GC FOUND', len(gc.garbage), 'UNCOLLECTIBLE OBJECTS:')
for i, obj in enumerate(gc.garbage):
print(str(i) + ':', obj)
# NOTE: no longer running these checks. Perhaps we can allow
# running them with an explicit flag passed, but we should never
# run them by default because gc.get_objects() can mess up the app.
# See notes at top of efro.debug.
# if bool(False):
# print_live_object_warnings('after session shutdown')
def garbage_collect() -> None:
"""Run an explicit pass of garbage collection.
category: General Utility Functions
May also print warnings/etc. if collection takes too long or if
uncollectible objects are found (so use this instead of simply
gc.collect().
"""
gc.collect()
def print_corrupt_file_error() -> None:
"""Print an error if a corrupt file is found."""
# FIXME - filter this out for builds without bauiv1.
if not _babase.app.headless_mode:
_babase.apptimer(
2.0,
lambda: _babase.screenmessage(
_babase.app.lang.get_resource(
'internal.corruptFileText'
).replace('${EMAIL}', 'support@froemling.net'),
color=(1, 0, 0),
),
)
_babase.apptimer(2.0, _babase.getsimplesound('error').play)
_tb_held_files: list[TextIO] = []
@ioprepped
@dataclass
class DumpedAppStateMetadata:
"""High level info about a dumped app state."""
reason: str
app_time: float
log_level: LogLevel
def dump_app_state(
delay: float = 0.0,
reason: str = 'Unspecified',
log_level: LogLevel = LogLevel.WARNING,
) -> None:
"""Dump various app state for debugging purposes.
This includes stack traces for all Python threads (and potentially
other info in the future).
This is intended for use debugging deadlock situations. It will dump
to preset file location(s) in the app config dir, and will attempt to
log and clear the results after dumping. If that should fail (due to
a hung app, etc.), then the results will be logged and cleared on the
next app run.
Do not use this call during regular smooth operation of the app; it
is should only be used for debugging or in response to confirmed
problems as it can leak file descriptors, cause hitches, etc.
"""
# pylint: disable=consider-using-with
import faulthandler
# Dump our metadata immediately. If a delay is passed, it generally
# means we expect things to hang momentarily, so we should not delay
# writing our metadata or it will likely not happen. Though we
# should remember that metadata doesn't line up perfectly in time with
# the dump in that case.
try:
mdpath = os.path.join(
os.path.dirname(_babase.app.config_file_path), '_appstate_dump_md'
)
with open(mdpath, 'w', encoding='utf-8') as outfile:
outfile.write(
dataclass_to_json(
DumpedAppStateMetadata(
reason=reason,
app_time=_babase.apptime(),
log_level=log_level,
)
)
)
except Exception:
# Abandon whole dump if we can't write metadata.
logging.exception('Error writing app state dump metadata.')
return
tbpath = os.path.join(
os.path.dirname(_babase.app.config_file_path), '_appstate_dump_tb'
)
tbfile = open(tbpath, 'w', encoding='utf-8')
# faulthandler needs the raw file descriptor to still be valid when
# it fires, so stuff this into a global var to make sure it doesn't get
# cleaned up.
_tb_held_files.append(tbfile)
if delay > 0.0:
faulthandler.dump_traceback_later(delay, file=tbfile)
else:
faulthandler.dump_traceback(file=tbfile)
# Attempt to log shortly after dumping.
# Allow sufficient time since we don't know how long the dump takes.
# We want this to work from any thread, so need to kick this part
# over to the logic thread so timer works.
_babase.pushcall(
tpartial(_babase.apptimer, delay + 1.0, log_dumped_app_state),
from_other_thread=True,
suppress_other_thread_warning=True,
)
def log_dumped_app_state() -> None:
"""If an app-state dump exists, log it and clear it. No-op otherwise."""
try:
out = ''
mdpath = os.path.join(
os.path.dirname(_babase.app.config_file_path), '_appstate_dump_md'
)
if os.path.exists(mdpath):
# We may be hanging on to open file descriptors for use by
# faulthandler (see above). If we are, we need to clear them
# now or else we'll get 'file in use' errors below when we
# try to unlink it on windows.
for heldfile in _tb_held_files:
heldfile.close()
_tb_held_files.clear()
with open(mdpath, 'r', encoding='utf-8') as infile:
appstatedata = infile.read()
# Kill the file first in case we can't parse the data; don't
# want to get stuck doing this repeatedly.
os.unlink(mdpath)
metadata = dataclass_from_json(DumpedAppStateMetadata, appstatedata)
out += (
f'App state dump:\nReason: {metadata.reason}\n'
f'Time: {metadata.app_time:.2f}'
)
tbpath = os.path.join(
os.path.dirname(_babase.app.config_file_path),
'_appstate_dump_tb',
)
if os.path.exists(tbpath):
with open(tbpath, 'r', encoding='utf-8') as infile:
out += '\nPython tracebacks:\n' + infile.read()
os.unlink(tbpath)
logging.log(metadata.log_level.python_logging_level, out)
except Exception:
logging.exception('Error logging dumped app state.')
class AppHealthMonitor(AppSubsystem):
"""Logs things like app-not-responding issues."""
def __init__(self) -> None:
assert _babase.in_logic_thread()
super().__init__()
self._running = True
self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
self._thread.start()
self._response = False
self._first_check = True
def _app_monitor_thread_main(self) -> None:
try:
self._monitor_app()
except Exception:
logging.exception('Error in AppHealthMonitor thread.')
def _set_response(self) -> None:
assert _babase.in_logic_thread()
self._response = True
def _check_running(self) -> bool:
# Workaround for the fact that mypy assumes _running
# doesn't change during the course of a function.
return self._running
def _monitor_app(self) -> None:
import time
while bool(True):
# Always sleep a bit between checks.
time.sleep(1.234)
# Do nothing while backgrounded.
while not self._running:
time.sleep(2.3456)
# Wait for the logic thread to run something we send it.
starttime = time.monotonic()
self._response = False
_babase.pushcall(self._set_response, raw=True)
while not self._response:
# Abort this check if we went into the background.
if not self._check_running():
break
# Wait a bit longer the first time through since the app
# could still be starting up; we generally don't want to
# report that.
threshold = 10 if self._first_check else 5
# If we've been waiting too long (and the app is running)
# dump the app state and bail. Make an exception for the
# first check though since the app could just be taking
# a while to get going; we don't want to report that.
duration = time.monotonic() - starttime
if duration > threshold:
dump_app_state(
reason=f'Logic thread unresponsive'
f' for {threshold} seconds.'
)
# We just do one alert for now.
return
time.sleep(1.042)
self._first_check = False
def on_app_pause(self) -> None:
assert _babase.in_logic_thread()
self._running = False
def on_app_resume(self) -> None:
assert _babase.in_logic_thread()
self._running = True

View file

@ -0,0 +1,247 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to managing cloud based assets."""
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
from dataclasses import dataclass, field
from pathlib import Path
import threading
import urllib.request
import logging
import weakref
import time
import os
import sys
from efro.dataclassio import (
ioprepped,
IOAttrs,
dataclass_from_json,
dataclass_to_json,
)
import _babase
if TYPE_CHECKING:
from bacommon.assets import AssetPackageFlavor
@ioprepped
@dataclass
class FileValue:
"""State for an individual file."""
@ioprepped
@dataclass
class State:
"""Holds all persistent state for the asset-manager."""
files: Annotated[dict[str, FileValue], IOAttrs('files')] = field(
default_factory=dict
)
class AssetManager:
"""Wrangles all assets."""
_state: State
def __init__(self, rootdir: Path) -> None:
print('AssetManager()')
assert isinstance(rootdir, Path)
self.thread_ident = threading.get_ident()
self._rootdir = rootdir
self._started = False
if not self._rootdir.is_dir():
raise RuntimeError(f'Provided rootdir does not exist: "{rootdir}"')
self.load_state()
def __del__(self) -> None:
print('~AssetManager()')
if self._started:
logging.warning('AssetManager dying in a started state.')
def launch_gather(
self,
packages: list[str],
flavor: AssetPackageFlavor,
account_token: str,
) -> AssetGather:
"""Spawn an asset-gather operation from this manager."""
print(
'would gather',
packages,
'and flavor',
flavor,
'with token',
account_token,
)
return AssetGather(self)
def update(self) -> None:
"""Can be called periodically to perform upkeep."""
def start(self) -> None:
"""Tell the manager to start working.
This will initiate network activity and other processing.
"""
if self._started:
logging.warning('AssetManager.start() called on running manager.')
self._started = True
def stop(self) -> None:
"""Tell the manager to stop working.
All network activity should be ceased before this function returns.
"""
if not self._started:
logging.warning('AssetManager.stop() called on stopped manager.')
self._started = False
self.save_state()
@property
def rootdir(self) -> Path:
"""The root directory for this manager."""
return self._rootdir
@property
def state_path(self) -> Path:
"""The path of the state file."""
return Path(self._rootdir, 'state')
def load_state(self) -> None:
"""Loads state from disk. Resets to default state if unable to."""
print('ASSET-MANAGER LOADING STATE')
try:
state_path = self.state_path
if state_path.exists():
with open(self.state_path, encoding='utf-8') as infile:
self._state = dataclass_from_json(State, infile.read())
return
except Exception:
logging.exception('Error loading existing AssetManager state')
self._state = State()
def save_state(self) -> None:
"""Save state to disk (if possible)."""
print('ASSET-MANAGER SAVING STATE')
try:
with open(self.state_path, 'w', encoding='utf-8') as outfile:
outfile.write(dataclass_to_json(self._state))
except Exception:
logging.exception('Error writing AssetManager state')
class AssetGather:
"""Wrangles a gathering of assets."""
def __init__(self, manager: AssetManager) -> None:
assert threading.get_ident() == manager.thread_ident
self._manager = weakref.ref(manager)
# self._valid = True
print('AssetGather()')
# url = 'https://files.ballistica.net/bombsquad/promo/BSGamePlay.mov'
# url = 'http://www.python.org/ftp/python/2.7.3/Python-2.7.3.tgz'
# fetch_url(url,
# filename=Path(manager.rootdir, 'testdl'),
# asset_gather=self)
# print('fetch success')
thread = threading.Thread(target=self._run)
thread.run()
def _run(self) -> None:
"""Run the gather in a background thread."""
print('hello from gather bg')
# First, do some sort of.
# @property
# def valid(self) -> bool:
# """Whether this gather is still valid.
# A gather becomes in valid if its originating AssetManager dies.
# """
# return True
def __del__(self) -> None:
print('~AssetGather()')
def fetch_url(url: str, filename: Path, asset_gather: AssetGather) -> None:
"""Fetch a given url to a given filename for a given AssetGather."""
# pylint: disable=consider-using-with
import socket
# We don't want to keep the provided AssetGather alive, but we want
# to abort if it dies.
assert isinstance(asset_gather, AssetGather)
# weak_gather = weakref.ref(asset_gather)
# Pass a very short timeout to urllib so we have opportunities
# to cancel even with network blockage.
req = urllib.request.urlopen(
urllib.request.Request(
url, None, {'User-Agent': _babase.user_agent_string()}
),
context=_babase.app.net.sslcontext,
timeout=1,
)
file_size = int(req.headers['Content-Length'])
print(f'\nDownloading: {filename} Bytes: {file_size:,}')
def doit() -> None:
time.sleep(1)
print('dir', type(req.fp), dir(req.fp))
print('WOULD DO IT', flush=True)
# req.close()
# req.fp.close()
threading.Thread(target=doit).run()
with open(filename, 'wb') as outfile:
file_size_dl = 0
block_sz = 1024 * 1024 * 1000
time_outs = 0
while True:
try:
data = req.read(block_sz)
except ValueError:
import traceback
traceback.print_exc()
print('VALUEERROR', flush=True)
break
except socket.timeout:
print('TIMEOUT', flush=True)
# File has not had activity in max seconds.
if time_outs > 3:
print('\n\n\nsorry -- try back later')
os.unlink(filename)
raise
print(
'\nHmmm... little issue... '
'I\'ll wait a couple of seconds'
)
time.sleep(3)
time_outs += 1
continue
# We reached the end of the download!
if not data:
sys.stdout.write('\rDone!\n\n')
sys.stdout.flush()
break
file_size_dl += len(data)
outfile.write(data)
percent = file_size_dl * 1.0 / file_size
status = f'{file_size_dl:20,} Bytes [{percent:.2%}] received'
sys.stdout.write('\r' + status)
sys.stdout.flush()
print('done with', req.fp)

89
dist/ba_data/python/babase/_asyncio.py vendored Normal file
View file

@ -0,0 +1,89 @@
# Released under the MIT License. See LICENSE for details.
#
"""Asyncio related functionality.
Exploring the idea of allowing Python coroutines to run gracefully
besides our internal event loop. They could prove useful for networking
operations or possibly game logic.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import asyncio
import logging
import time
import os
if TYPE_CHECKING:
import babase
# Our timer and event loop for the ballistica logic thread.
_asyncio_timer: babase.AppTimer | None = None
_asyncio_event_loop: asyncio.AbstractEventLoop | None = None
DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1'
def setup_asyncio() -> asyncio.AbstractEventLoop:
"""Setup asyncio functionality for the logic thread."""
# pylint: disable=global-statement
import _babase
import babase
assert _babase.in_logic_thread()
# Create our event-loop. We don't expect there to be one
# running on this thread before we do.
try:
asyncio.get_running_loop()
print('Found running asyncio loop; unexpected.')
except RuntimeError:
pass
global _asyncio_event_loop
_asyncio_event_loop = asyncio.new_event_loop()
_asyncio_event_loop.set_default_executor(babase.app.threadpool)
# Ideally we should integrate asyncio into our C++ Thread class's
# low level event loop so that asyncio timers/sockets/etc. could
# be true first-class citizens. For now, though, we can explicitly
# pump an asyncio loop periodically which gets us a decent
# approximation of that, which should be good enough for
# all but extremely time sensitive uses.
# See https://stackoverflow.com/questions/29782377/
# is-it-possible-to-run-only-a-single-step-of-the-asyncio-event-loop
def run_cycle() -> None:
assert _asyncio_event_loop is not None
_asyncio_event_loop.call_soon(_asyncio_event_loop.stop)
starttime = time.monotonic() if DEBUG_TIMING else 0
_asyncio_event_loop.run_forever()
endtime = time.monotonic() if DEBUG_TIMING else 0
# Let's aim to have nothing take longer than 1/120 of a second.
if DEBUG_TIMING:
warn_time = 1.0 / 120
duration = endtime - starttime
if duration > warn_time:
logging.warning(
'Asyncio loop step took %.4fs; ideal max is %.4f',
duration,
warn_time,
)
global _asyncio_timer
_asyncio_timer = _babase.AppTimer(1.0 / 30.0, run_cycle, repeat=True)
if bool(False):
async def aio_test() -> None:
print('TEST AIO TASK STARTING')
assert _asyncio_event_loop is not None
assert asyncio.get_running_loop() is _asyncio_event_loop
await asyncio.sleep(2.0)
print('TEST AIO TASK ENDING')
_testtask = _asyncio_event_loop.create_task(aio_test())
return _asyncio_event_loop

192
dist/ba_data/python/babase/_cloud.py vendored Normal file
View file

@ -0,0 +1,192 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the cloud."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, overload
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Callable, Any
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):
"""Manages communication with cloud components."""
def is_connected(self) -> bool:
"""Return whether a connection to the cloud is present.
This is a good indicator (though not for certain) that sending
messages will succeed.
"""
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

@ -0,0 +1,37 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _babase
from babase._appmode import AppMode
from babase._appintent import AppIntentExec, AppIntentDefault
if TYPE_CHECKING:
from babase import AppIntent
class EmptyAppMode(AppMode):
"""An empty app mode that can be used as a fallback/etc."""
@classmethod
def supports_intent(cls, intent: AppIntent) -> bool:
# We support default and exec intents currently.
return isinstance(intent, AppIntentExec | AppIntentDefault)
def handle_intent(self, intent: AppIntent) -> None:
if isinstance(intent, AppIntentExec):
_babase.empty_app_mode_handle_intent_exec(intent.code)
return
assert isinstance(intent, AppIntentDefault)
_babase.empty_app_mode_handle_intent_default()
def on_activate(self) -> None:
# Let the native layer do its thing.
_babase.empty_app_mode_activate()
def on_deactivate(self) -> None:
# Let the native layer do its thing.
_babase.empty_app_mode_deactivate()

239
dist/ba_data/python/babase/_env.py vendored Normal file
View file

@ -0,0 +1,239 @@
# Released under the MIT License. See LICENSE for details.
#
"""Environment related functionality."""
from __future__ import annotations
import sys
import signal
import logging
from typing import TYPE_CHECKING
from efro.log import LogLevel
if TYPE_CHECKING:
from typing import Any
from efro.log import LogEntry, LogHandler
_g_babase_imported = False # pylint: disable=invalid-name
_g_babase_app_started = False # pylint: disable=invalid-name
def on_native_module_import() -> None:
"""Called when _babase is being imported.
This code should do as little as possible; we want to defer all
environment modifications until we actually commit to running an
app.
"""
import _babase
import baenv
global _g_babase_imported # pylint: disable=global-statement
assert not _g_babase_imported
_g_babase_imported = True
# If we have a log_handler set up, wire it up to feed _babase its
# output.
envconfig = baenv.get_config()
if envconfig.log_handler is not None:
_feed_logs_to_babase(envconfig.log_handler)
env = _babase.pre_env()
# Give a soft warning if we're being used with a different binary
# version than we were built for.
running_build: int = env['build_number']
if running_build != baenv.TARGET_BALLISTICA_BUILD:
logging.warning(
'These scripts are meant to be used with'
' Ballistica build %d, but you are running build %d.'
" This might cause problems. Module path: '%s'.",
baenv.TARGET_BALLISTICA_BUILD,
running_build,
__file__,
)
debug_build = env['debug_build']
# We expect dev_mode on in debug builds and off otherwise;
# make noise if that's not the case.
if debug_build != sys.flags.dev_mode:
logging.warning(
'Ballistica was built with debug-mode %s'
' but Python is running with dev-mode %s;'
' this mismatch may cause problems.'
' See https://docs.python.org/3/library/devmode.html',
debug_build,
sys.flags.dev_mode,
)
def on_main_thread_start_app() -> None:
"""Called in the main thread when we're starting an app.
We use this opportunity to set up the Python runtime environment
as we like it for running our app stuff. This includes things like
signal-handling, garbage-collection, and logging.
"""
import gc
import baenv
import _babase
global _g_babase_app_started # pylint: disable=global-statement
_g_babase_app_started = True
assert _g_babase_imported
assert baenv.config_exists()
# If we were unable to set paths earlier, complain now.
if baenv.did_paths_set_fail():
logging.warning(
'Ballistica Python paths have not been set. This may cause'
' problems. To ensure paths are set, run baenv.configure()'
' BEFORE importing any Ballistica modules.'
)
# Set up interrupt-signal handling.
# Note: I've found we need to set up our C signal handling AFTER
# we've told Python to disable its own; otherwise (on Mac at least)
# it wipes out our existing C handler.
signal.signal(signal.SIGINT, signal.SIG_DFL) # Do default handling.
_babase.setup_sigint()
# Turn off fancy-pants cyclic garbage-collection. We run it only at
# explicit times to avoid random hitches and keep things more
# deterministic. Non-reference-looped objects will still get cleaned
# up immediately, so we should try to structure things to avoid
# reference loops (just like Swift, ObjC, etc).
# FIXME - move this to Python bootstrapping code. or perhaps disable
# it completely since we've got more bg stuff happening now?...
# (but put safeguards in place to time/minimize gc pauses).
gc.disable()
# pylint: disable=c-extension-no-member
if not TYPE_CHECKING:
import __main__
# Clear out the standard quit/exit messages since they don't
# work in our embedded situation (should revisit this once we're
# usable from a standard interpreter). Note that these don't
# exist in the first place for our monolithic builds which don't
# use site.py.
for attr in ('quit', 'exit'):
if hasattr(__main__.__builtins__, attr):
delattr(__main__.__builtins__, attr)
# Also replace standard interactive help with our simplified
# non-blocking one which is more friendly to cloud/in-app console
# situations.
__main__.__builtins__.help = _CustomHelper()
# On Windows I'm seeing the following error creating asyncio loops
# in background threads with the default proactor setup:
# ValueError: set_wakeup_fd only works in main thread of the main
# interpreter.
# So let's explicitly request selector loops. Interestingly this
# error only started showing up once I moved Python init to the main
# thread; previously the various asyncio bg thread loops were
# working fine (maybe something caused them to default to selector
# in that case?..
if sys.platform == 'win32':
import asyncio
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
def on_app_launching() -> None:
"""Called when the app reaches the launching state."""
import _babase
import baenv
assert _babase.in_logic_thread()
# Let the user know if the app Python dir is a 'user' one.
envconfig = baenv.get_config()
if envconfig.is_user_app_python_dir:
_babase.screenmessage(
f"Using user system scripts: '{envconfig.app_python_dir}'",
color=(0.6, 0.6, 1.0),
)
def _feed_logs_to_babase(log_handler: LogHandler) -> None:
"""Route log/print output to internal ballistica console/etc."""
import _babase
def _on_log(entry: LogEntry) -> None:
# Forward this along to the engine to display in the in-app
# console, in the Android log, etc.
_babase.display_log(
name=entry.name,
level=entry.level.name,
message=entry.message,
)
# We also want to feed some logs to the old v1-cloud-log system.
# Let's go with anything warning or higher as well as the
# stdout/stderr log messages that babase.app.log_handler creates
# for us. We should retire or upgrade this system at some point.
if entry.level.value >= LogLevel.WARNING.value or entry.name in (
'stdout',
'stderr',
):
_babase.v1_cloud_log(entry.message)
# Add our callback and also feed it all entries already in the
# cache. This will feed the engine any logs that happened between
# baenv.configure() and now.
# FIXME: while this works for now, the downside is that these
# callbacks fire in a bg thread so certain things like android
# logging will be delayed compared to code that uses native logging
# calls directly. Perhaps we should add some sort of 'immediate'
# callback option to better handle such cases (similar to the
# immediate echofile stderr print that already occurs).
log_handler.add_callback(_on_log, feed_existing_logs=True)
class _CustomHelper:
"""Replacement 'help' that behaves better for our setup."""
def __repr__(self) -> str:
return 'Type help(object) for help about object.'
def __call__(self, *args: Any, **kwds: Any) -> Any:
# We get an ugly error importing pydoc on our embedded platforms
# due to _sysconfigdata_xxx.py not being present (but then
# things mostly work). Let's get the ugly error out of the way
# explicitly.
# FIXME: we shouldn't be seeing this error anymore. Should
# revisit this.
import sysconfig
try:
# This errors once but seems to run cleanly after, so let's
# get the error out of the way.
sysconfig.get_path('stdlib')
except ModuleNotFoundError:
pass
import pydoc
# Disable pager and interactive help since neither works well
# with our funky multi-threaded setup or in-game/cloud consoles.
# Let's just do simple text dumps.
pydoc.pager = pydoc.plainpager
if not args and not kwds:
print(
'Interactive help is not available in this environment.\n'
'Type help(object) for help about object.'
)
return None
return pydoc.help(*args, **kwds)

188
dist/ba_data/python/babase/_error.py vendored Normal file
View file

@ -0,0 +1,188 @@
# Released under the MIT License. See LICENSE for details.
#
"""Error related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING:
from typing import Any
class ContextError(Exception):
"""Exception raised when a call is made in an invalid context.
Category: **Exception Classes**
Examples of this include calling UI functions within an Activity context
or calling scene manipulation functions outside of a game context.
"""
class NotFoundError(Exception):
"""Exception raised when a referenced object does not exist.
Category: **Exception Classes**
"""
class PlayerNotFoundError(NotFoundError):
"""Exception raised when an expected player does not exist.
Category: **Exception Classes**
"""
class SessionPlayerNotFoundError(NotFoundError):
"""Exception raised when an expected session-player does not exist.
Category: **Exception Classes**
"""
class TeamNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Team does not exist.
Category: **Exception Classes**
"""
class MapNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Map does not exist.
Category: **Exception Classes**
"""
class DelegateNotFoundError(NotFoundError):
"""Exception raised when an expected delegate object does not exist.
Category: **Exception Classes**
"""
class SessionTeamNotFoundError(NotFoundError):
"""Exception raised when an expected session-team does not exist.
Category: **Exception Classes**
"""
class NodeNotFoundError(NotFoundError):
"""Exception raised when an expected Node does not exist.
Category: **Exception Classes**
"""
class ActorNotFoundError(NotFoundError):
"""Exception raised when an expected actor does not exist.
Category: **Exception Classes**
"""
class ActivityNotFoundError(NotFoundError):
"""Exception raised when an expected bascenev1.Activity does not exist.
Category: **Exception Classes**
"""
class SessionNotFoundError(NotFoundError):
"""Exception raised when an expected session does not exist.
Category: **Exception Classes**
"""
class InputDeviceNotFoundError(NotFoundError):
"""Exception raised when an expected input-device does not exist.
Category: **Exception Classes**
"""
class WidgetNotFoundError(NotFoundError):
"""Exception raised when an expected widget does not exist.
Category: **Exception Classes**
"""
# TODO: Should integrate some sort of context printing into our
# log handling so we can just use logging.exception() and kill these
# two functions.
def print_exception(*args: Any, **keywds: Any) -> None:
"""Print info about an exception along with pertinent context state.
Category: **General Utility Functions**
Prints all arguments provided along with various info about the
current context and the outstanding exception.
Pass the keyword 'once' as True if you want the call to only happen
one time from an exact calling location.
"""
import traceback
if keywds:
allowed_keywds = ['once']
if any(keywd not in allowed_keywds for keywd in keywds):
raise TypeError('invalid keyword(s)')
try:
# If we're only printing once and already have, bail.
if keywds.get('once', False):
if not _babase.do_once():
return
err_str = ' '.join([str(a) for a in args])
print('ERROR:', err_str)
_babase.print_context()
print('PRINTED-FROM:')
# Basically the output of traceback.print_stack()
stackstr = ''.join(traceback.format_stack())
print(stackstr, end='')
print('EXCEPTION:')
# Basically the output of traceback.print_exc()
excstr = traceback.format_exc()
print('\n'.join(' ' + l for l in excstr.splitlines()))
except Exception:
# I suppose using print_exception here would be a bad idea.
print('ERROR: exception in babase.print_exception():')
traceback.print_exc()
def print_error(err_str: str, once: bool = False) -> None:
"""Print info about an error along with pertinent context state.
Category: **General Utility Functions**
Prints all positional arguments provided along with various info about the
current context.
Pass the keyword 'once' as True if you want the call to only happen
one time from an exact calling location.
"""
import traceback
try:
# If we're only printing once and already have, bail.
if once:
if not _babase.do_once():
return
print('ERROR:', err_str)
_babase.print_context()
# Basically the output of traceback.print_stack()
stackstr = ''.join(traceback.format_stack())
print(stackstr, end='')
except Exception:
print('ERROR: exception in babase.print_error():')
traceback.print_exc()

404
dist/ba_data/python/babase/_general.py vendored Normal file
View file

@ -0,0 +1,404 @@
# Released under the MIT License. See LICENSE for details.
#
"""Utility snippets applying to generic Python code."""
from __future__ import annotations
import types
import weakref
import random
import inspect
from typing import TYPE_CHECKING, TypeVar, Protocol, NewType
from efro.terminal import Clr
import _babase
from babase._error import print_error, print_exception
if TYPE_CHECKING:
from typing import Any
from efro.call import Call as Call # 'as Call' so we re-export.
# Declare distinct types for different time measurements we use so the
# type-checker can help prevent us from mixing and matching accidentally.
# Our monotonic time measurement that starts at 0 when the app launches
# and pauses while the app is suspended.
AppTime = NewType('AppTime', float)
# Like app-time but incremented at frame draw time and in a smooth
# consistent manner; useful to keep animations smooth and jitter-free.
DisplayTime = NewType('DisplayTime', float)
class Existable(Protocol):
"""A Protocol for objects supporting an exists() method.
Category: **Protocols**
"""
def exists(self) -> bool:
"""Whether this object exists."""
ExistableT = TypeVar('ExistableT', bound=Existable)
T = TypeVar('T')
def existing(obj: ExistableT | None) -> ExistableT | None:
"""Convert invalid references to None for any babase.Existable object.
Category: **Gameplay Functions**
To best support type checking, it is important that invalid references
not be passed around and instead get converted to values of None.
That way the type checker can properly flag attempts to pass possibly-dead
objects (FooType | None) into functions expecting only live ones
(FooType), etc. This call can be used on any 'existable' object
(one with an exists() method) and will convert it to a None value
if it does not exist.
For more info, see notes on 'existables' here:
https://ballistica.net/wiki/Coding-Style-Guide
"""
assert obj is None or hasattr(obj, 'exists'), f'No "exists" on {obj}'
return obj if obj is not None and obj.exists() else None
def getclass(name: str, subclassof: type[T]) -> type[T]:
"""Given a full class name such as foo.bar.MyClass, return the class.
Category: **General Utility Functions**
The class will be checked to make sure it is a subclass of the provided
'subclassof' class, and a TypeError will be raised if not.
"""
import importlib
splits = name.split('.')
modulename = '.'.join(splits[:-1])
classname = splits[-1]
module = importlib.import_module(modulename)
cls: type = getattr(module, classname)
if not issubclass(cls, subclassof):
raise TypeError(f'{name} is not a subclass of {subclassof}.')
return cls
def json_prep(data: Any) -> Any:
"""Return a json-friendly version of the provided data.
This converts any tuples to lists and any bytes to strings
(interpreted as utf-8, ignoring errors). Logs errors (just once)
if any data is modified/discarded/unsupported.
"""
if isinstance(data, dict):
return dict(
(json_prep(key), json_prep(value))
for key, value in list(data.items())
)
if isinstance(data, list):
return [json_prep(element) for element in data]
if isinstance(data, tuple):
print_error('json_prep encountered tuple', once=True)
return [json_prep(element) for element in data]
if isinstance(data, bytes):
try:
return data.decode(errors='ignore')
except Exception:
from babase import _error
print_error('json_prep encountered utf-8 decode error', once=True)
return data.decode(errors='ignore')
if not isinstance(data, (str, float, bool, type(None), int)):
print_error(
'got unsupported type in json_prep:' + str(type(data)), once=True
)
return data
def utf8_all(data: Any) -> Any:
"""Convert any unicode data in provided sequence(s) to utf8 bytes."""
if isinstance(data, dict):
return dict(
(utf8_all(key), utf8_all(value))
for key, value in list(data.items())
)
if isinstance(data, list):
return [utf8_all(element) for element in data]
if isinstance(data, tuple):
return tuple(utf8_all(element) for element in data)
if isinstance(data, str):
return data.encode('utf-8', errors='ignore')
return data
def get_type_name(cls: type) -> str:
"""Return a full type name including module for a class."""
return cls.__module__ + '.' + cls.__name__
class _WeakCall:
"""Wrap a callable and arguments into a single callable object.
Category: **General Utility Classes**
When passed a bound method as the callable, the instance portion
of it is weak-referenced, meaning the underlying instance is
free to die if all other references to it go away. Should this
occur, calling the WeakCall is simply a no-op.
Think of this as a handy way to tell an object to do something
at some point in the future if it happens to still exist.
##### Examples
**EXAMPLE A:** this code will create a FooClass instance and call its
bar() method 5 seconds later; it will be kept alive even though
we overwrite its variable with None because the bound method
we pass as a timer callback (foo.bar) strong-references it
>>> foo = FooClass()
... babase.apptimer(5.0, foo.bar)
... foo = None
**EXAMPLE B:** This code will *not* keep our object alive; it will die
when we overwrite it with None and the timer will be a no-op when it
fires
>>> foo = FooClass()
... babase.apptimer(5.0, ba.WeakCall(foo.bar))
... foo = None
**EXAMPLE C:** Wrap a method call with some positional and keyword args:
>>> myweakcall = babase.WeakCall(self.dostuff, argval1,
... namedarg=argval2)
... # Now we have a single callable to run that whole mess.
... # The same as calling myobj.dostuff(argval1, namedarg=argval2)
... # (provided my_obj still exists; this will do nothing
... # otherwise).
... myweakcall()
Note: additional args and keywords you provide to the WeakCall()
constructor are stored as regular strong-references; you'll need
to wrap them in weakrefs manually if desired.
"""
_did_invalid_call_warning = False
def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a WeakCall.
Pass a callable as the first arg, followed by any number of
arguments or keywords.
"""
if hasattr(args[0], '__func__'):
self._call = WeakMethod(args[0])
else:
app = _babase.app
if not self._did_invalid_call_warning:
print(
(
'Warning: callable passed to babase.WeakCall() is not'
' weak-referencable ('
+ str(args[0])
+ '); use babase.Call() instead to avoid this '
'warning. Stack-trace:'
)
)
import traceback
traceback.print_stack()
self._did_invalid_call_warning = True
self._call = args[0]
self._args = args[1:]
self._keywds = keywds
def __call__(self, *args_extra: Any) -> Any:
return self._call(*self._args + args_extra, **self._keywds)
def __str__(self) -> str:
return (
'<ba.WeakCall object; _call='
+ str(self._call)
+ ' _args='
+ str(self._args)
+ ' _keywds='
+ str(self._keywds)
+ '>'
)
class _Call:
"""Wraps a callable and arguments into a single callable object.
Category: **General Utility Classes**
The callable is strong-referenced so it won't die until this
object does.
Note that a bound method (ex: ``myobj.dosomething``) contains a reference
to ``self`` (``myobj`` in that case), so you will be keeping that object
alive too. Use babase.WeakCall if you want to pass a method to callback
without keeping its object alive.
"""
def __init__(self, *args: Any, **keywds: Any):
"""Instantiate a Call.
Pass a callable as the first arg, followed by any number of
arguments or keywords.
##### Example
Wrap a method call with 1 positional and 1 keyword arg:
>>> mycall = babase.Call(myobj.dostuff, argval, namedarg=argval2)
... # Now we have a single callable to run that whole mess.
... # ..the same as calling myobj.dostuff(argval, namedarg=argval2)
... mycall()
"""
self._call = args[0]
self._args = args[1:]
self._keywds = keywds
def __call__(self, *args_extra: Any) -> Any:
return self._call(*self._args + args_extra, **self._keywds)
def __str__(self) -> str:
return (
'<ba.Call object; _call='
+ str(self._call)
+ ' _args='
+ str(self._args)
+ ' _keywds='
+ str(self._keywds)
+ '>'
)
if TYPE_CHECKING:
# Some interaction between our ballistica pylint plugin
# and this code is crashing starting on pylint 2.15.0.
# This seems to fix things for now.
# pylint: disable=all
WeakCall = Call
Call = Call
else:
WeakCall = _WeakCall
WeakCall.__name__ = 'WeakCall'
Call = _Call
Call.__name__ = 'Call'
class WeakMethod:
"""A weak-referenced bound method.
Wraps a bound method using weak references so that the original is
free to die. If called with a dead target, is simply a no-op.
"""
def __init__(self, call: types.MethodType):
assert isinstance(call, types.MethodType)
self._func = call.__func__
self._obj = weakref.ref(call.__self__)
def __call__(self, *args: Any, **keywds: Any) -> Any:
obj = self._obj()
if obj is None:
return None
return self._func(*((obj,) + args), **keywds)
def __str__(self) -> str:
return '<ba.WeakMethod object; call=' + str(self._func) + '>'
def verify_object_death(obj: object) -> None:
"""Warn if an object does not get freed within a short period.
Category: **General Utility Functions**
This can be handy to detect and prevent memory/resource leaks.
"""
try:
ref = weakref.ref(obj)
except Exception:
print_exception('Unable to create weak-ref in verify_object_death')
return
# Use a slight range for our checks so they don't all land at once
# if we queue a lot of them.
delay = random.uniform(2.0, 5.5)
# Make this timer in an empty context; don't want it dying with the
# scene/etc.
with _babase.ContextRef.empty():
_babase.apptimer(delay, Call(_verify_object_death, ref))
def _verify_object_death(wref: weakref.ref) -> None:
obj = wref()
if obj is None:
return
try:
name = type(obj).__name__
except Exception:
print(f'Note: unable to get type name for {obj}')
name = 'object'
print(
f'{Clr.RED}Error: {name} not dying when expected to:'
f' {Clr.BLD}{obj}{Clr.RST}\n'
'See efro.debug for ways to debug this.'
)
def storagename(suffix: str | None = None) -> str:
"""Generate a unique name for storing class data in shared places.
Category: **General Utility Functions**
This consists of a leading underscore, the module path at the
call site with dots replaced by underscores, the containing class's
qualified name, and the provided suffix. When storing data in public
places such as 'customdata' dicts, this minimizes the chance of
collisions with other similarly named classes.
Note that this will function even if called in the class definition.
##### Examples
Generate a unique name for storage purposes:
>>> class MyThingie:
... # This will give something like
... # '_mymodule_submodule_mythingie_data'.
... _STORENAME = babase.storagename('data')
...
... # Use that name to store some data in the Activity we were
... # passed.
... def __init__(self, activity):
... activity.customdata[self._STORENAME] = {}
"""
frame = inspect.currentframe()
if frame is None:
raise RuntimeError('Cannot get current stack frame.')
fback = frame.f_back
# Note: We need to explicitly clear frame here to avoid a ref-loop
# that keeps all function-dicts in the stack alive until the next
# full GC cycle (the stack frame refers to this function's dict,
# which refers to the stack frame).
del frame
if fback is None:
raise RuntimeError('Cannot get parent stack frame.')
modulepath = fback.f_globals.get('__name__')
if modulepath is None:
raise RuntimeError('Cannot get parent stack module path.')
assert isinstance(modulepath, str)
qualname = fback.f_locals.get('__qualname__')
if qualname is not None:
assert isinstance(qualname, str)
fullpath = f'_{modulepath}_{qualname.lower()}'
else:
fullpath = f'_{modulepath}'
if suffix is not None:
fullpath = f'{fullpath}_{suffix}'
return fullpath.replace('.', '_')

395
dist/ba_data/python/babase/_hooks.py vendored Normal file
View file

@ -0,0 +1,395 @@
# Released under the MIT License. See LICENSE for details.
#
"""Snippets of code for use by the internal layer.
History: originally the engine would dynamically compile/eval various Python
code from within C++ code, but the major downside there was that none of it
was type-checked so if names or arguments changed it would go unnoticed
until it broke at runtime. By instead defining such snippets here and then
capturing references to them all at launch it is possible to allow linting
and type-checking magic to happen and most issues will be caught immediately.
"""
# (most of these are self-explanatory)
# pylint: disable=missing-function-docstring
from __future__ import annotations
import time
import logging
from typing import TYPE_CHECKING
import _babase
if TYPE_CHECKING:
pass
def on_app_bootstrapping_complete() -> None:
"""Called by C++ layer when bootstrapping finishes."""
_babase.app.on_app_bootstrapping_complete()
def reset_to_main_menu() -> None:
# Some high-level event wants us to return to the main menu.
# an example of this is re-opening the game after we 'soft' quit it
# on Android.
if _babase.app.classic is not None:
_babase.app.classic.return_to_main_menu_session_gracefully()
else:
logging.warning('reset_to_main_menu: no-op due to classic not present.')
def set_config_fullscreen_on() -> None:
"""The OS has changed our fullscreen state and we should take note."""
_babase.app.config['Fullscreen'] = True
_babase.app.config.commit()
def set_config_fullscreen_off() -> None:
"""The OS has changed our fullscreen state and we should take note."""
_babase.app.config['Fullscreen'] = False
_babase.app.config.commit()
def not_signed_in_screen_message() -> None:
from babase._language import Lstr
_babase.screenmessage(Lstr(resource='notSignedInErrorText'))
def open_url_with_webbrowser_module(url: str) -> None:
"""Show a URL in the browser or print on-screen error if we can't."""
import webbrowser
from babase._language import Lstr
try:
webbrowser.open(url)
except Exception:
logging.exception("Error displaying url '%s'.", url)
_babase.getsimplesound('error').play()
_babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
def connecting_to_party_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='internal.connectingToPartyText'), color=(1, 1, 1)
)
def rejecting_invite_already_in_party_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='internal.rejectingInviteAlreadyInPartyText'),
color=(1, 0.5, 0),
)
def connection_failed_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='internal.connectionFailedText'), color=(1, 0.5, 0)
)
def temporarily_unavailable_message() -> None:
from babase._language import Lstr
if not _babase.app.headless_mode:
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(resource='getTicketsWindow.unavailableTemporarilyText'),
color=(1, 0, 0),
)
def in_progress_message() -> None:
from babase._language import Lstr
if not _babase.app.headless_mode:
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(resource='getTicketsWindow.inProgressText'),
color=(1, 0, 0),
)
def error_message() -> None:
from babase._language import Lstr
if not _babase.app.headless_mode:
_babase.getsimplesound('error').play()
_babase.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
def purchase_not_valid_error() -> None:
from babase._language import Lstr
if not _babase.app.headless_mode:
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(
resource='store.purchaseNotValidError',
subs=[('${EMAIL}', 'support@froemling.net')],
),
color=(1, 0, 0),
)
def purchase_already_in_progress_error() -> None:
from babase._language import Lstr
if not _babase.app.headless_mode:
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(resource='store.purchaseAlreadyInProgressText'),
color=(1, 0, 0),
)
def uuid_str() -> str:
import uuid
return str(uuid.uuid4())
def orientation_reset_cb_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='internal.vrOrientationResetCardboardText'),
color=(0, 1, 0),
)
def orientation_reset_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='internal.vrOrientationResetText'), color=(0, 1, 0)
)
def on_app_pause() -> None:
_babase.app.pause()
def on_app_resume() -> None:
_babase.app.resume()
def show_post_purchase_message() -> None:
assert _babase.app.classic is not None
_babase.app.classic.accounts.show_post_purchase_message()
def language_test_toggle() -> None:
_babase.app.lang.setlanguage(
'Gibberish' if _babase.app.lang.language == 'English' else 'English'
)
def award_in_control_achievement() -> None:
if _babase.app.classic is not None:
_babase.app.classic.ach.award_local_achievement('In Control')
else:
logging.warning('award_in_control_achievement is no-op without classic')
def award_dual_wielding_achievement() -> None:
if _babase.app.classic is not None:
_babase.app.classic.ach.award_local_achievement('Dual Wielding')
else:
logging.warning(
'award_dual_wielding_achievement is no-op without classic'
)
def play_gong_sound() -> None:
if not _babase.app.headless_mode:
_babase.getsimplesound('gong').play()
def launch_coop_game(name: str) -> None:
assert _babase.app.classic is not None
_babase.app.classic.launch_coop_game(name)
def purchases_restored_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='getTicketsWindow.purchasesRestoredText'), color=(0, 1, 0)
)
def unavailable_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='getTicketsWindow.unavailableText'), color=(1, 0, 0)
)
def set_last_ad_network(sval: str) -> None:
if _babase.app.classic is not None:
_babase.app.classic.ads.last_ad_network = sval
_babase.app.classic.ads.last_ad_network_set_time = time.time()
def google_play_purchases_not_available_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='googlePlayPurchasesNotAvailableText'), color=(1, 0, 0)
)
def google_play_services_not_available_message() -> None:
from babase._language import Lstr
_babase.screenmessage(
Lstr(resource='googlePlayServicesNotAvailableText'), color=(1, 0, 0)
)
def empty_call() -> None:
pass
def print_trace() -> None:
import traceback
print('Python Traceback (most recent call last):')
traceback.print_stack()
def toggle_fullscreen() -> None:
cfg = _babase.app.config
cfg['Fullscreen'] = not cfg.resolve('Fullscreen')
cfg.apply_and_commit()
def read_config() -> None:
_babase.app.read_config()
def ui_remote_press() -> None:
"""Handle a press by a remote device that is only usable for nav."""
from babase._language import Lstr
if _babase.app.headless_mode:
return
# Can be called without a context; need a context for getsound.
_babase.screenmessage(
Lstr(resource='internal.controllerForMenusOnlyText'),
color=(1, 0, 0),
)
_babase.getsimplesound('error').play()
def remove_in_game_ads_message() -> None:
if _babase.app.classic is not None:
_babase.app.classic.ads.do_remove_in_game_ads_message()
def do_quit() -> None:
_babase.quit()
def shutdown() -> None:
_babase.app.on_app_shutdown()
def hash_strings(inputs: list[str]) -> str:
"""Hash provided strings into a short output string."""
import hashlib
sha = hashlib.sha1()
for inp in inputs:
sha.update(inp.encode())
return sha.hexdigest()
def have_account_v2_credentials() -> bool:
"""Do we have primary account-v2 credentials set?"""
assert _babase.app.plus is not None
have: bool = _babase.app.plus.accounts.have_primary_credentials()
return have
def implicit_sign_in(
login_type_str: str, login_id: str, display_name: str
) -> None:
"""An implicit login happened."""
from bacommon.login import LoginType
assert _babase.app.plus is not None
_babase.app.plus.accounts.on_implicit_sign_in(
login_type=LoginType(login_type_str),
login_id=login_id,
display_name=display_name,
)
def implicit_sign_out(login_type_str: str) -> None:
"""An implicit logout happened."""
from bacommon.login import LoginType
assert _babase.app.plus is not None
_babase.app.plus.accounts.on_implicit_sign_out(
login_type=LoginType(login_type_str)
)
def login_adapter_get_sign_in_token_response(
login_type_str: str, attempt_id_str: str, result_str: str
) -> None:
"""Login adapter do-sign-in completed."""
from bacommon.login import LoginType
from babase._login import LoginAdapterNative
login_type = LoginType(login_type_str)
attempt_id = int(attempt_id_str)
result = None if result_str == '' else result_str
assert _babase.app.plus is not None
adapter = _babase.app.plus.accounts.login_adapters[login_type]
assert isinstance(adapter, LoginAdapterNative)
adapter.on_sign_in_complete(attempt_id=attempt_id, result=result)
def show_client_too_old_error() -> None:
"""Called at launch if the server tells us we're too old to talk to it."""
from babase._language import Lstr
# If you are using an old build of the app and would like to stop
# seeing this error at launch, do:
# ba.app.config['SuppressClientTooOldErrorForBuild'] = ba.app.build_number
# ba.app.config.commit()
# Note that you will have to do that again later if you update to
# a newer build.
if (
_babase.app.config.get('SuppressClientTooOldErrorForBuild')
== _babase.app.build_number
):
return
if not _babase.app.headless_mode:
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(
translate=(
'serverResponses',
'Server functionality is no longer supported'
' in this version of the game;\n'
'Please update to a newer version.',
)
),
color=(1, 0, 0),
)

33
dist/ba_data/python/babase/_keyboard.py vendored Normal file
View file

@ -0,0 +1,33 @@
# 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."""

649
dist/ba_data/python/babase/_language.py vendored Normal file
View file

@ -0,0 +1,649 @@
# Released under the MIT License. See LICENSE for details.
#
"""Language related functionality."""
from __future__ import annotations
import os
import json
import logging
from typing import TYPE_CHECKING, overload
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Any, Sequence
import babase
class LanguageSubsystem(AppSubsystem):
"""Language functionality for the app.
Category: **App Classes**
Access the single instance of this class at 'babase.app.lang'.
"""
def __init__(self) -> None:
super().__init__()
self.default_language: str = self._get_default_language()
self._language: str | None = None
self._language_target: AttrDict | None = None
self._language_merged: AttrDict | None = None
@property
def locale(self) -> str:
"""Raw country/language code detected by the game (such as 'en_US').
Generally for language-specific code you should look at
babase.App.language, which is the language the game is using
(which may differ from locale if the user sets a language, etc.)
"""
env = _babase.env()
assert isinstance(env['locale'], str)
return env['locale']
@property
def language(self) -> str:
"""The current active language for the app.
This can be selected explicitly by the user or may be set
automatically based on locale or other factors.
"""
if self._language is None:
raise RuntimeError('App language is not yet set.')
return self._language
@property
def available_languages(self) -> list[str]:
"""A list of all available languages.
Note that languages that may be present in game assets but which
are not displayable on the running version of the game are not
included here.
"""
langs = set()
try:
names = os.listdir(
os.path.join(
_babase.app.data_directory, 'ba_data', 'data', 'languages'
)
)
names = [n.replace('.json', '').capitalize() for n in names]
# FIXME: our simple capitalization fails on multi-word names;
# should handle this in a better way...
for i, name in enumerate(names):
if name == 'Chinesetraditional':
names[i] = 'ChineseTraditional'
except Exception:
from babase import _error
_error.print_exception()
names = []
for name in names:
if self._can_display_language(name):
langs.add(name)
return sorted(
name for name in names if self._can_display_language(name)
)
def setlanguage(
self,
language: str | None,
print_change: bool = True,
store_to_config: bool = True,
) -> None:
"""Set the active app language.
Pass None to use OS default language.
"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
assert _babase.in_logic_thread()
cfg = _babase.app.config
cur_language = cfg.get('Lang', None)
# Store this in the config if its changing.
if language != cur_language and store_to_config:
if language is None:
if 'Lang' in cfg:
del cfg['Lang'] # Clear it out for default.
else:
cfg['Lang'] = language
cfg.commit()
switched = True
else:
switched = False
with open(
os.path.join(
_babase.app.data_directory,
'ba_data',
'data',
'languages',
'english.json',
),
encoding='utf-8',
) as infile:
lenglishvalues = json.loads(infile.read())
# None implies default.
if language is None:
language = self.default_language
try:
if language == 'English':
lmodvalues = None
else:
lmodfile = os.path.join(
_babase.app.data_directory,
'ba_data',
'data',
'languages',
language.lower() + '.json',
)
with open(lmodfile, encoding='utf-8') as infile:
lmodvalues = json.loads(infile.read())
except Exception:
logging.exception("Error importing language '%s'.", language)
_babase.screenmessage(
f"Error setting language to '{language}'; see log for details.",
color=(1, 0, 0),
)
switched = False
lmodvalues = None
self._language = language
# Create an attrdict of *just* our target language.
self._language_target = AttrDict()
langtarget = self._language_target
assert langtarget is not None
_add_to_attr_dict(
langtarget, lmodvalues if lmodvalues is not None else lenglishvalues
)
# Create an attrdict of our target language overlaid on our base
# (english).
languages = [lenglishvalues]
if lmodvalues is not None:
languages.append(lmodvalues)
lfull = AttrDict()
for lmod in languages:
_add_to_attr_dict(lfull, lmod)
self._language_merged = lfull
# Pass some keys/values in for low level code to use; start with
# everything in their 'internal' section.
internal_vals = [
v for v in list(lfull['internal'].items()) if isinstance(v[1], str)
]
# Cherry-pick various other values to include.
# (should probably get rid of the 'internal' section
# and do everything this way)
for value in [
'replayNameDefaultText',
'replayWriteErrorText',
'replayVersionErrorText',
'replayReadErrorText',
]:
internal_vals.append((value, lfull[value]))
internal_vals.append(
('axisText', lfull['configGamepadWindow']['axisText'])
)
internal_vals.append(('buttonText', lfull['buttonText']))
lmerged = self._language_merged
assert lmerged is not None
random_names = [
n.strip() for n in lmerged['randomPlayerNamesText'].split(',')
]
random_names = [n for n in random_names if n != '']
_babase.set_internal_language_keys(internal_vals, random_names)
if switched and print_change:
_babase.screenmessage(
Lstr(
resource='languageSetText',
subs=[
('${LANGUAGE}', Lstr(translate=('languages', language)))
],
),
color=(0, 1, 0),
)
def do_apply_app_config(self) -> None:
assert _babase.in_logic_thread()
assert isinstance(_babase.app.config, dict)
lang = _babase.app.config.get('Lang', self.default_language)
if lang != self._language:
self.setlanguage(lang, print_change=False, store_to_config=False)
def get_resource(
self,
resource: str,
fallback_resource: str | None = None,
fallback_value: Any = None,
) -> Any:
"""Return a translation resource by name.
DEPRECATED; use babase.Lstr functionality for these purposes.
"""
try:
# If we have no language set, try and set it to english.
# Also make a fuss because we should try to avoid this.
if self._language_merged is None:
try:
if _babase.do_once():
logging.warning(
'get_resource() called before language'
' set; falling back to english.'
)
self.setlanguage(
'English', print_change=False, store_to_config=False
)
except Exception:
logging.exception(
'Error setting fallback english language.'
)
raise
# If they provided a fallback_resource value, try the
# target-language-only dict first and then fall back to
# trying the fallback_resource value in the merged dict.
if fallback_resource is not None:
try:
values = self._language_target
splits = resource.split('.')
dicts = splits[:-1]
key = splits[-1]
for dct in dicts:
assert values is not None
values = values[dct]
assert values is not None
val = values[key]
return val
except Exception:
# FIXME: Shouldn't we try the fallback resource in
# the merged dict AFTER we try the main resource in
# the merged dict?
try:
values = self._language_merged
splits = fallback_resource.split('.')
dicts = splits[:-1]
key = splits[-1]
for dct in dicts:
assert values is not None
values = values[dct]
assert values is not None
val = values[key]
return val
except Exception:
# If we got nothing for fallback_resource,
# default to the normal code which checks or
# primary value in the merge dict; there's a
# chance we can get an english value for it
# (which we weren't looking for the first time
# through).
pass
values = self._language_merged
splits = resource.split('.')
dicts = splits[:-1]
key = splits[-1]
for dct in dicts:
assert values is not None
values = values[dct]
assert values is not None
val = values[key]
return val
except Exception:
# Ok, looks like we couldn't find our main or fallback
# resource anywhere. Now if we've been given a fallback
# value, return it; otherwise fail.
from babase import _error
if fallback_value is not None:
return fallback_value
raise _error.NotFoundError(
f"Resource not found: '{resource}'"
) from None
def translate(
self,
category: str,
strval: str,
raise_exceptions: bool = False,
print_errors: bool = False,
) -> str:
"""Translate a value (or return the value if no translation available)
DEPRECATED; use babase.Lstr functionality for these purposes.
"""
try:
translated = self.get_resource('translations')[category][strval]
except Exception as exc:
if raise_exceptions:
raise
if print_errors:
print(
(
'Translate error: category=\''
+ category
+ '\' name=\''
+ strval
+ '\' exc='
+ str(exc)
+ ''
)
)
translated = None
translated_out: str
if translated is None:
translated_out = strval
else:
translated_out = translated
assert isinstance(translated_out, str)
return translated_out
def is_custom_unicode_char(self, char: str) -> bool:
"""Return whether a char is in the custom unicode range we use."""
assert isinstance(char, str)
if len(char) != 1:
raise ValueError('Invalid Input; must be length 1')
return 0xE000 <= ord(char) <= 0xF8FF
def _can_display_language(self, language: str) -> bool:
"""Tell whether we can display a particular language.
On some platforms we don't have unicode rendering yet which
limits the languages we can draw.
"""
# We don't yet support full unicode display on windows or linux :-(.
if (
language
in {
'Chinese',
'ChineseTraditional',
'Persian',
'Korean',
'Arabic',
'Hindi',
'Vietnamese',
'Thai',
'Tamil',
}
and not _babase.can_display_full_unicode()
):
return False
return True
def _get_default_language(self) -> str:
languages = {
'ar': 'Arabic',
'be': 'Belarussian',
'zh': 'Chinese',
'hr': 'Croatian',
'cs': 'Czech',
'da': 'Danish',
'nl': 'Dutch',
'eo': 'Esperanto',
'fil': 'Filipino',
'fr': 'French',
'de': 'German',
'el': 'Greek',
'hi': 'Hindi',
'hu': 'Hungarian',
'id': 'Indonesian',
'it': 'Italian',
'ko': 'Korean',
'ms': 'Malay',
'fa': 'Persian',
'pl': 'Polish',
'pt': 'Portuguese',
'ro': 'Romanian',
'ru': 'Russian',
'sr': 'Serbian',
'es': 'Spanish',
'sk': 'Slovak',
'sv': 'Swedish',
'ta': 'Tamil',
'th': 'Thai',
'tr': 'Turkish',
'uk': 'Ukrainian',
'vec': 'Venetian',
'vi': 'Vietnamese',
}
# Special case for Chinese: map specific variations to
# traditional. (otherwise will map to 'Chinese' which is
# simplified)
if self.locale in ('zh_HANT', 'zh_TW'):
language = 'ChineseTraditional'
else:
language = languages.get(self.locale[:2], 'English')
if not self._can_display_language(language):
language = 'English'
return language
class Lstr:
"""Used to define strings in a language-independent way.
Category: **General Utility Classes**
These should be used whenever possible in place of hard-coded
strings so that in-game or UI elements show up correctly on all
clients in their currently-active language.
To see available resource keys, look at any of the bs_language_*.py
files in the game or the translations pages at
legacy.ballistica.net/translate.
##### Examples
EXAMPLE 1: specify a string from a resource path
>>> mynode.text = babase.Lstr(resource='audioSettingsWindow.titleText')
EXAMPLE 2: specify a translated string via a category and english
value; if a translated value is available, it will be used; otherwise
the english value will be. To see available translation categories,
look under the 'translations' resource section.
>>> mynode.text = babase.Lstr(translate=('gameDescriptions',
... 'Defeat all enemies'))
EXAMPLE 3: specify a raw value and some substitutions. Substitutions
can be used with resource and translate modes as well.
>>> mynode.text = babase.Lstr(value='${A} / ${B}',
... subs=[('${A}', str(score)), ('${B}', str(total))])
EXAMPLE 4: babase.Lstr's can be nested. This example would display the
resource at res_a but replace ${NAME} with the value of the
resource at res_b
>>> mytextnode.text = babase.Lstr(
... resource='res_a',
... subs=[('${NAME}', babase.Lstr(resource='res_b'))])
"""
# pylint: disable=dangerous-default-value
# noinspection PyDefaultArgument
@overload
def __init__(
self,
*,
resource: str,
fallback_resource: str = '',
fallback_value: str = '',
subs: Sequence[tuple[str, str | Lstr]] = [],
) -> None:
"""Create an Lstr from a string resource."""
# noinspection PyShadowingNames,PyDefaultArgument
@overload
def __init__(
self,
*,
translate: tuple[str, str],
subs: Sequence[tuple[str, str | Lstr]] = [],
) -> None:
"""Create an Lstr by translating a string in a category."""
# noinspection PyDefaultArgument
@overload
def __init__(
self, *, value: str, subs: Sequence[tuple[str, str | Lstr]] = []
) -> None:
"""Create an Lstr from a raw string value."""
# pylint: enable=redefined-outer-name, dangerous-default-value
def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a Lstr.
Pass a value for either 'resource', 'translate',
or 'value'. (see Lstr help for examples).
'subs' can be a sequence of 2-member sequences consisting of values
and replacements.
'fallback_resource' can be a resource key that will be used if the
main one is not present for
the current language in place of falling back to the english value
('resource' mode only).
'fallback_value' can be a literal string that will be used if neither
the resource nor the fallback resource is found ('resource' mode only).
"""
# pylint: disable=too-many-branches
if args:
raise TypeError('Lstr accepts only keyword arguments')
# Basically just store the exact args they passed.
# However if they passed any Lstr values for subs,
# replace them with that Lstr's dict.
self.args = keywds
our_type = type(self)
if isinstance(self.args.get('value'), our_type):
raise TypeError("'value' must be a regular string; not an Lstr")
if 'subs' in self.args:
subs_new = []
for key, value in keywds['subs']:
if isinstance(value, our_type):
subs_new.append((key, value.args))
else:
subs_new.append((key, value))
self.args['subs'] = subs_new
# As of protocol 31 we support compact key names
# ('t' instead of 'translate', etc). Convert as needed.
if 'translate' in keywds:
keywds['t'] = keywds['translate']
del keywds['translate']
if 'resource' in keywds:
keywds['r'] = keywds['resource']
del keywds['resource']
if 'value' in keywds:
keywds['v'] = keywds['value']
del keywds['value']
if 'fallback' in keywds:
from babase import _error
_error.print_error(
'deprecated "fallback" arg passed to Lstr(); use '
'either "fallback_resource" or "fallback_value"',
once=True,
)
keywds['f'] = keywds['fallback']
del keywds['fallback']
if 'fallback_resource' in keywds:
keywds['f'] = keywds['fallback_resource']
del keywds['fallback_resource']
if 'subs' in keywds:
keywds['s'] = keywds['subs']
del keywds['subs']
if 'fallback_value' in keywds:
keywds['fv'] = keywds['fallback_value']
del keywds['fallback_value']
def evaluate(self) -> str:
"""Evaluate the Lstr and returns a flat string in the current language.
You should avoid doing this as much as possible and instead pass
and store Lstr values.
"""
return _babase.evaluate_lstr(self._get_json())
def is_flat_value(self) -> bool:
"""Return whether the Lstr is a 'flat' value.
This is defined as a simple string value incorporating no
translations, resources, or substitutions. In this case it may
be reasonable to replace it with a raw string value, perform
string manipulation on it, etc.
"""
return bool('v' in self.args and not self.args.get('s', []))
def _get_json(self) -> str:
try:
return json.dumps(self.args, separators=(',', ':'))
except Exception:
from babase import _error
_error.print_exception('_get_json failed for', self.args)
return 'JSON_ERR'
def __str__(self) -> str:
return '<ba.Lstr: ' + self._get_json() + '>'
def __repr__(self) -> str:
return '<ba.Lstr: ' + self._get_json() + '>'
@staticmethod
def from_json(json_string: str) -> babase.Lstr:
"""Given a json string, returns a babase.Lstr. Does no validation."""
lstr = Lstr(value='')
lstr.args = json.loads(json_string)
return lstr
def _add_to_attr_dict(dst: AttrDict, src: dict) -> None:
for key, value in list(src.items()):
if isinstance(value, dict):
try:
dst_dict = dst[key]
except Exception:
dst_dict = dst[key] = AttrDict()
if not isinstance(dst_dict, AttrDict):
raise RuntimeError(
"language key '"
+ key
+ "' is defined both as a dict and value"
)
_add_to_attr_dict(dst_dict, value)
else:
if not isinstance(value, (float, int, bool, str, str, type(None))):
raise TypeError(
"invalid value type for res '"
+ key
+ "': "
+ str(type(value))
)
dst[key] = value
class AttrDict(dict):
"""A dict that can be accessed with dot notation.
(so foo.bar is equivalent to foo['bar'])
"""
def __getattr__(self, attr: str) -> Any:
val = self[attr]
assert not isinstance(val, bytes)
return val
def __setattr__(self, attr: str, value: Any) -> None:
raise AttributeError()

376
dist/ba_data/python/babase/_login.py vendored Normal file
View file

@ -0,0 +1,376 @@
# Released under the MIT License. See LICENSE for details.
#
"""Login related functionality."""
from __future__ import annotations
import time
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, final
from bacommon.login import LoginType
import _babase
if TYPE_CHECKING:
from typing import Callable
DEBUG_LOG = False
class LoginAdapter:
"""Allows using implicit login types in an explicit way.
Some login types such as Google Play Game Services or Game Center are
basically always present and often do not provide a way to log out
from within a running app, so this adapter exists to use them in a
flexible manner by 'attaching' and 'detaching' from an always-present
login, allowing for its use alongside other login types. It also
provides common functionality for server-side account verification and
other handy bits.
"""
@dataclass
class SignInResult:
"""Describes the final result of a sign-in attempt."""
credentials: str
@dataclass
class ImplicitLoginState:
"""Describes the current state of an implicit login."""
login_id: str
display_name: str
def __init__(self, login_type: LoginType):
assert _babase.in_logic_thread()
self.login_type = login_type
self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = (
None
)
self._on_app_loading_called = False
self._implicit_login_state_dirty = False
self._back_end_active = False
# Which login of our type (if any) is associated with the
# current active primary account.
self._active_login_id: str | None = None
self._last_sign_in_time: float | None = None
self._last_sign_in_desc: str | None = None
def on_app_loading(self) -> None:
"""Should be called for each adapter in on_app_loading."""
assert not self._on_app_loading_called
self._on_app_loading_called = True
# Any implicit state we received up until now needs to be pushed
# to the app account subsystem.
self._update_implicit_login_state()
def set_implicit_login_state(
self, state: ImplicitLoginState | None
) -> None:
"""Keep the adapter informed of implicit login states.
This should be called by the adapter back-end when an account
of their associated type gets logged in or out.
"""
assert _babase.in_logic_thread()
# Ignore redundant sets.
if state == self._implicit_login_state:
return
if DEBUG_LOG:
if state is None:
logging.debug(
'LoginAdapter: %s implicit state changed;'
' now signed out.',
self.login_type.name,
)
else:
logging.debug(
'LoginAdapter: %s implicit state changed;'
' now signed in as %s.',
self.login_type.name,
state.display_name,
)
self._implicit_login_state = state
self._implicit_login_state_dirty = True
# (possibly) push it to the app for handling.
self._update_implicit_login_state()
# This might affect whether we consider that back-end as 'active'.
self._update_back_end_active()
def set_active_logins(self, logins: dict[LoginType, str]) -> None:
"""Keep the adapter informed of actively used logins.
This should be called by the app's account subsystem to
keep adapters up to date on the full set of logins attached
to the currently-in-use account.
Note that the logins dict passed in should be immutable as
only a reference to it is stored, not a copy.
"""
assert _babase.in_logic_thread()
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got active logins %s.',
self.login_type.name,
{k: v[:4] + '...' + v[-4:] for k, v in logins.items()},
)
self._active_login_id = logins.get(self.login_type)
self._update_back_end_active()
def on_back_end_active_change(self, active: bool) -> None:
"""Called when active state for the back-end is (possibly) changing.
Meant to be overridden by subclasses.
Being active means that the implicit login provided by the back-end
is actually being used by the app. It should therefore register
unlocked achievements, leaderboard scores, allow viewing native
UIs, etc. When not active it should ignore everything and behave
as if logged out, even if it technically is still logged in.
"""
assert _babase.in_logic_thread()
del active # Unused.
@final
def sign_in(
self,
result_cb: Callable[[LoginAdapter, SignInResult | Exception], None],
description: str,
) -> None:
"""Attempt an explicit sign in via this adapter.
This can be called even if the back-end is not implicitly signed in;
the adapter will attempt to sign in if possible. An exception will
be returned if the sign-in attempt fails.
"""
assert _babase.in_logic_thread()
from babase._general import Call
# Have been seeing multiple sign-in attempts come through
# nearly simultaneously which can be problematic server-side.
# Let's error if a sign-in attempt is made within a few seconds
# of the last one to address this.
now = time.monotonic()
appnow = _babase.apptime()
if self._last_sign_in_time is not None:
since_last = now - self._last_sign_in_time
if since_last < 1.0:
logging.warning(
'LoginAdapter: %s adapter sign_in() called too soon'
' (%.2fs) after last; this-desc="%s", last-desc="%s",'
' ba-app-time=%.2f.',
self.login_type.name,
since_last,
description,
self._last_sign_in_desc,
appnow,
)
_babase.pushcall(
Call(
result_cb,
self,
RuntimeError('sign_in called too soon after last.'),
)
)
return
self._last_sign_in_desc = description
self._last_sign_in_time = now
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sign_in() called;'
' fetching sign-in-token...',
self.login_type.name,
)
def _got_sign_in_token_result(result: str | None) -> None:
import bacommon.cloud
# Failed to get a sign-in-token.
if result is None:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sign-in-token fetch failed;'
' aborting sign-in.',
self.login_type.name,
)
_babase.pushcall(
Call(
result_cb,
self,
RuntimeError('fetch-sign-in-token failed.'),
)
)
return
# 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
# success.
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sign-in-token fetch succeeded;'
' passing to cloud for verification...',
self.login_type.name,
)
def _got_sign_in_response(
response: bacommon.cloud.SignInResponse | Exception,
) -> None:
if isinstance(response, Exception):
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got error'
' sign-in response: %s',
self.login_type.name,
response,
)
_babase.pushcall(Call(result_cb, self, response))
else:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got successful'
' sign-in response',
self.login_type.name,
)
if response.credentials is None:
result2: LoginAdapter.SignInResult | Exception = (
RuntimeError(
'No credentials returned after'
' submitting sign-in-token.'
)
)
else:
result2 = self.SignInResult(
credentials=response.credentials
)
_babase.pushcall(Call(result_cb, self, result2))
assert _babase.app.plus is not None
_babase.app.plus.cloud.send_message_cb(
bacommon.cloud.SignInMessage(
self.login_type,
result,
description=description,
apptime=appnow,
),
on_response=_got_sign_in_response,
)
# Kick off the process by fetching a sign-in token.
self.get_sign_in_token(completion_cb=_got_sign_in_token_result)
def is_back_end_active(self) -> bool:
"""Is this adapter's back-end currently active?"""
return self._back_end_active
def get_sign_in_token(
self, completion_cb: Callable[[str | None], None]
) -> None:
"""Get a sign-in token from the adapter back end.
This token is then passed to the master-server to complete the
login process.
The adapter can use this opportunity to bring up account creation
UI, call its internal sign_in function, etc. as needed.
The provided completion_cb should then be called with either a token
or None if sign in failed or was cancelled.
"""
from babase._general import Call
# Default implementation simply fails immediately.
_babase.pushcall(Call(completion_cb, None))
def _update_implicit_login_state(self) -> None:
# If we've received an implicit login state, schedule it to be
# sent along to the app. We wait until on-app-launch has been
# called so that account-client-v2 has had a chance to load
# any existing state so it can properly respond to this.
if self._implicit_login_state_dirty and self._on_app_loading_called:
from babase._general import Call
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter sending'
' implicit-state-changed to app.',
self.login_type.name,
)
assert _babase.app.plus is not None
_babase.pushcall(
Call(
_babase.app.plus.accounts.on_implicit_login_state_changed,
self.login_type,
self._implicit_login_state,
)
)
self._implicit_login_state_dirty = False
def _update_back_end_active(self) -> None:
was_active = self._back_end_active
if self._implicit_login_state is None:
is_active = False
else:
is_active = (
self._implicit_login_state.login_id == self._active_login_id
)
if was_active != is_active:
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter back-end-active is now %s.',
self.login_type.name,
is_active,
)
self.on_back_end_active_change(is_active)
self._back_end_active = is_active
class LoginAdapterNative(LoginAdapter):
"""A login adapter that does its work in the native layer."""
def __init__(self) -> None:
super().__init__(LoginType.GPGS)
# Store int ids for in-flight attempts since they may go through
# various platform layers and back.
self._sign_in_attempt_num = 123
self._sign_in_attempts: dict[int, Callable[[str | None], None]] = {}
def get_sign_in_token(
self, completion_cb: Callable[[str | None], None]
) -> None:
attempt_id = self._sign_in_attempt_num
self._sign_in_attempts[attempt_id] = completion_cb
self._sign_in_attempt_num += 1
_babase.login_adapter_get_sign_in_token(
self.login_type.value, attempt_id
)
def on_back_end_active_change(self, active: bool) -> None:
_babase.login_adapter_back_end_active_change(
self.login_type.value, active
)
def on_sign_in_complete(self, attempt_id: int, result: str | None) -> None:
"""Called by the native layer on a completed attempt."""
assert _babase.in_logic_thread()
if attempt_id not in self._sign_in_attempts:
logging.exception('sign-in attempt_id %d not found', attempt_id)
return
callback = self._sign_in_attempts.pop(attempt_id)
callback(result)
class LoginAdapterGPGS(LoginAdapterNative):
"""Google Play Game Services adapter."""

58
dist/ba_data/python/babase/_math.py vendored Normal file
View file

@ -0,0 +1,58 @@
# Released under the MIT License. See LICENSE for details.
#
"""Math related functionality."""
from __future__ import annotations
from collections import abc
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from typing import Sequence
def vec3validate(value: Sequence[float]) -> Sequence[float]:
"""Ensure a value is valid for use as a Vec3.
category: General Utility Functions
Raises a TypeError exception if not.
Valid values include any type of sequence consisting of 3 numeric values.
Returns the same value as passed in (but with a definite type
so this can be used to disambiguate 'Any' types).
Generally this should be used in 'if __debug__' or assert clauses
to keep runtime overhead minimal.
"""
from numbers import Number
if not isinstance(value, abc.Sequence):
raise TypeError(f"Expected a sequence; got {type(value)}")
if len(value) != 3:
raise TypeError(f"Expected a length-3 sequence (got {len(value)})")
if not all(isinstance(i, Number) for i in value):
raise TypeError(f"Non-numeric value passed for vec3: {value}")
return value
def is_point_in_box(pnt: Sequence[float], box: Sequence[float]) -> bool:
"""Return whether a given point is within a given box.
category: General Utility Functions
For use with standard def boxes (position|rotate|scale).
"""
return (
(abs(pnt[0] - box[0]) <= box[6] * 0.5)
and (abs(pnt[1] - box[1]) <= box[7] * 0.5)
and (abs(pnt[2] - box[2]) <= box[8] * 0.5)
)
def normalized_color(color: Sequence[float]) -> tuple[float, ...]:
"""Scale a color so its largest value is 1; useful for coloring lights.
category: General Utility Functions
"""
color_biased = tuple(max(c, 0.01) for c in color) # account for black
mult = 1.0 / max(color_biased)
return tuple(c * mult for c in color_biased)

515
dist/ba_data/python/babase/_meta.py vendored Normal file
View file

@ -0,0 +1,515 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to dynamic discoverability of classes."""
from __future__ import annotations
import os
import time
import logging
from threading import Thread
from pathlib import Path
from typing import TYPE_CHECKING, TypeVar
from dataclasses import dataclass, field
from efro.call import tpartial
import _babase
if TYPE_CHECKING:
from typing import Callable
# The meta api version of this build of the game.
# Only packages and modules requiring this exact api version
# will be considered when scanning directories.
# See: https://ballistica.net/wiki/Meta-Tag-System
CURRENT_API_VERSION = 8
# Meta export lines can use these names to represent these classes.
# This is purely a convenience; it is possible to use full class paths
# instead of these or to make the meta system aware of arbitrary classes.
EXPORT_CLASS_NAME_SHORTCUTS: dict[str, str] = {
'plugin': 'babase.Plugin',
'keyboard': 'babase.Keyboard',
}
T = TypeVar('T')
@dataclass
class ScanResults:
"""Final results from a meta-scan."""
exports: dict[str, list[str]] = field(default_factory=dict)
incorrect_api_modules: list[str] = field(default_factory=list)
announce_errors_occurred: bool = False
def exports_of_class(self, cls: type) -> list[str]:
"""Return exports of a given class."""
return self.exports.get(f'{cls.__module__}.{cls.__qualname__}', [])
class MetadataSubsystem:
"""Subsystem for working with script metadata in the app.
Category: **App Classes**
Access the single shared instance of this class at 'babase.app.meta'.
"""
def __init__(self) -> None:
self._scan: DirectoryScan | None = None
# Can be populated before starting the scan.
self.extra_scan_dirs: list[str] = []
# Results populated once scan is complete.
self.scanresults: ScanResults | None = None
self._scan_complete_cb: Callable[[], None] | None = None
def start_scan(self, scan_complete_cb: Callable[[], None]) -> None:
"""Begin the overall scan.
This will start scanning built in directories (which for vanilla
installs should be the vast majority of the work). This should only
be called once.
"""
assert self._scan_complete_cb is None
assert self._scan is None
self._scan_complete_cb = scan_complete_cb
self._scan = DirectoryScan(
[
path
for path in [
_babase.app.python_directory_app,
_babase.app.python_directory_user,
]
if path is not None
]
)
Thread(target=self._run_scan_in_bg, daemon=True).start()
def start_extra_scan(self) -> None:
"""Proceed to the extra_scan_dirs portion of the scan.
This is for parts of the scan that must be delayed until
workspace sync completion or other such events. This must be
called exactly once.
"""
assert self._scan is not None
self._scan.set_extras(self.extra_scan_dirs)
def load_exported_classes(
self,
cls: type[T],
completion_cb: Callable[[list[type[T]]], None],
completion_cb_in_bg_thread: bool = False,
) -> None:
"""High level function to load meta-exported classes.
Will wait for scanning to complete if necessary, and will load all
registered classes of a particular type in a background thread before
calling the passed callback in the logic thread. Errors may be logged
to messaged to the user in some way but the callback will be called
regardless.
To run the completion callback directly in the bg thread where the
loading work happens, pass completion_cb_in_bg_thread=True.
"""
Thread(
target=tpartial(
self._load_exported_classes,
cls,
completion_cb,
completion_cb_in_bg_thread,
),
daemon=True,
).start()
def _load_exported_classes(
self,
cls: type[T],
completion_cb: Callable[[list[type[T]]], None],
completion_cb_in_bg_thread: bool,
) -> None:
from babase._general import getclass
classes: list[type[T]] = []
try:
classnames = self._wait_for_scan_results().exports_of_class(cls)
for classname in classnames:
try:
classes.append(getclass(classname, cls))
except Exception:
logging.exception('error importing %s', classname)
except Exception:
logging.exception('Error loading exported classes.')
completion_call = tpartial(completion_cb, classes)
if completion_cb_in_bg_thread:
completion_call()
else:
_babase.pushcall(completion_call, from_other_thread=True)
def _wait_for_scan_results(self) -> ScanResults:
"""Return scan results, blocking if the scan is not yet complete."""
if self.scanresults is None:
if _babase.in_logic_thread():
logging.warning(
'babase.meta._wait_for_scan_results()'
' called in logic thread before scan completed;'
' this can cause hitches.'
)
# Now wait a bit for the scan to complete.
# Eventually error though if it doesn't.
starttime = time.time()
while self.scanresults is None:
time.sleep(0.05)
if time.time() - starttime > 10.0:
raise TimeoutError(
'timeout waiting for meta scan to complete.'
)
return self.scanresults
def _run_scan_in_bg(self) -> None:
"""Runs a scan (for use in background thread)."""
try:
assert self._scan is not None
self._scan.run()
results = self._scan.results
self._scan = None
except Exception:
logging.exception('metascan: Error running scan in bg.')
results = ScanResults(announce_errors_occurred=True)
# Place results and tell the logic thread they're ready.
self.scanresults = results
_babase.pushcall(self._handle_scan_results, from_other_thread=True)
def _handle_scan_results(self) -> None:
"""Called in the logic thread with results of a completed scan."""
from babase._language import Lstr
assert _babase.in_logic_thread()
results = self.scanresults
assert results is not None
do_play_error_sound = False
# If we found modules needing to be updated to the newer api version,
# mention that specifically.
if results.incorrect_api_modules:
if len(results.incorrect_api_modules) > 1:
msg = Lstr(
resource='scanScriptsMultipleModulesNeedUpdatesText',
subs=[
('${PATH}', results.incorrect_api_modules[0]),
(
'${NUM}',
str(len(results.incorrect_api_modules) - 1),
),
('${API}', str(CURRENT_API_VERSION)),
],
)
else:
msg = Lstr(
resource='scanScriptsSingleModuleNeedsUpdatesText',
subs=[
('${PATH}', results.incorrect_api_modules[0]),
('${API}', str(CURRENT_API_VERSION)),
],
)
_babase.screenmessage(msg, color=(1, 0, 0))
do_play_error_sound = True
# Let the user know if there's warning/errors in their log
# they may want to look at.
if results.announce_errors_occurred:
_babase.screenmessage(
Lstr(resource='scanScriptsErrorText'), color=(1, 0, 0)
)
do_play_error_sound = True
if do_play_error_sound:
_babase.getsimplesound('error').play()
# Let the game know we're done.
assert self._scan_complete_cb is not None
self._scan_complete_cb()
class DirectoryScan:
"""Scans directories for metadata."""
def __init__(self, paths: list[str]):
"""Given one or more paths, parses available meta information.
It is assumed that these paths are also in PYTHONPATH.
It is also assumed that any subdirectories are Python packages.
"""
# Skip non-existent paths completely.
self.base_paths = [Path(p) for p in paths if os.path.isdir(p)]
self.extra_paths: list[Path] = []
self.extra_paths_set = False
self.results = ScanResults()
def set_extras(self, paths: list[str]) -> None:
"""Set extra portion."""
# Skip non-existent paths completely.
self.extra_paths += [Path(p) for p in paths if os.path.isdir(p)]
self.extra_paths_set = True
def run(self) -> None:
"""Do the thing."""
for pathlist in [self.base_paths, self.extra_paths]:
# Spin and wait until extra paths are provided before doing them.
if pathlist is self.extra_paths:
while not self.extra_paths_set:
time.sleep(0.001)
modules: list[tuple[Path, Path]] = []
for path in pathlist:
self._get_path_module_entries(path, '', modules)
for moduledir, subpath in modules:
try:
self._scan_module(moduledir, subpath)
except Exception:
logging.exception("metascan: Error scanning '%s'.", subpath)
# Sort our results
for exportlist in self.results.exports.values():
exportlist.sort()
def _get_path_module_entries(
self, path: Path, subpath: str | Path, modules: list[tuple[Path, Path]]
) -> None:
"""Scan provided path and add module entries to provided list."""
try:
# Special case: let's save some time and skip the whole 'babase'
# package since we know it doesn't contain any meta tags.
fullpath = Path(path, subpath)
entries = [
(path, Path(subpath, name))
for name in os.listdir(fullpath)
# Actually scratch that for now; trying to avoid special cases.
# if name != 'babase'
]
except PermissionError:
# Expected sometimes.
entries = []
except Exception:
# Unexpected; report this.
logging.exception('metascan: Error in _get_path_module_entries.')
self.results.announce_errors_occurred = True
entries = []
# Now identify python packages/modules out of what we found.
for entry in entries:
if entry[1].name.endswith('.py'):
modules.append(entry)
elif (
Path(entry[0], entry[1]).is_dir()
and Path(entry[0], entry[1], '__init__.py').is_file()
):
modules.append(entry)
def _scan_module(self, moduledir: Path, subpath: Path) -> None:
"""Scan an individual module and add the findings to results."""
if subpath.name.endswith('.py'):
fpath = Path(moduledir, subpath)
ispackage = False
else:
fpath = Path(moduledir, subpath, '__init__.py')
ispackage = True
with fpath.open(encoding='utf-8') as infile:
flines = infile.readlines()
meta_lines = {
lnum: l[1:].split()
for lnum, l in enumerate(flines)
if '# ba_meta ' in l
}
is_top_level = len(subpath.parts) <= 1
required_api = self._get_api_requirement(
subpath, meta_lines, is_top_level
)
# Top level modules with no discernible api version get ignored.
if is_top_level and required_api is None:
return
# If we find a module requiring a different api version, warn
# and ignore.
if required_api is not None and required_api != CURRENT_API_VERSION:
logging.warning(
'metascan: %s requires api %s but we are running'
' %s. Ignoring module.',
subpath,
required_api,
CURRENT_API_VERSION,
)
self.results.incorrect_api_modules.append(
self._module_name_for_subpath(subpath)
)
return
# Ok; can proceed with a full scan of this module.
self._process_module_meta_tags(subpath, flines, meta_lines)
# If its a package, recurse into its subpackages.
if ispackage:
try:
submodules: list[tuple[Path, Path]] = []
self._get_path_module_entries(moduledir, subpath, submodules)
for submodule in submodules:
if submodule[1].name != '__init__.py':
self._scan_module(submodule[0], submodule[1])
except Exception:
logging.exception('metascan: Error scanning %s.', subpath)
def _module_name_for_subpath(self, subpath: Path) -> str:
# (should not be getting these)
assert '__init__.py' not in str(subpath)
return '.'.join(subpath.parts).removesuffix('.py')
def _process_module_meta_tags(
self, subpath: Path, flines: list[str], meta_lines: dict[int, list[str]]
) -> None:
"""Pull data from a module based on its ba_meta tags."""
for lindex, mline in meta_lines.items():
# meta_lines is just anything containing '# ba_meta '; make sure
# the ba_meta is in the right place.
if mline[0] != 'ba_meta':
logging.warning(
'metascan: %s:%d: malformed ba_meta statement.',
subpath,
lindex + 1,
)
self.results.announce_errors_occurred = True
elif (
len(mline) == 4 and mline[1] == 'require' and mline[2] == 'api'
):
# Ignore 'require api X' lines in this pass.
pass
elif len(mline) != 3 or mline[1] != 'export':
# Currently we only support 'ba_meta export FOO';
# complain for anything else we see.
logging.warning(
'metascan: %s:%d: unrecognized ba_meta statement.',
subpath,
lindex + 1,
)
self.results.announce_errors_occurred = True
else:
# Looks like we've got a valid export line!
modulename = self._module_name_for_subpath(subpath)
exporttypestr = mline[2]
export_class_name = self._get_export_class_name(
subpath, flines, lindex
)
if export_class_name is not None:
classname = modulename + '.' + export_class_name
# Since we'll soon have multiple versions of 'game'
# classes we need to migrate people to using base
# class names for them.
if exporttypestr == 'game':
logging.warning(
"metascan: %s:%d: '# ba_meta export"
" game' tag should be replaced by '# ba_meta"
" export bascenev1.GameActivity'.",
subpath,
lindex + 1,
)
self.results.announce_errors_occurred = True
else:
# If export type is one of our shortcuts, sub in the
# actual class path. Otherwise assume its a classpath
# itself.
exporttype = EXPORT_CLASS_NAME_SHORTCUTS.get(
exporttypestr
)
if exporttype is None:
exporttype = exporttypestr
self.results.exports.setdefault(exporttype, []).append(
classname
)
def _get_export_class_name(
self, subpath: Path, lines: list[str], lindex: int
) -> str | None:
"""Given line num of an export tag, returns its operand class name."""
lindexorig = lindex
classname = None
while True:
lindex += 1
if lindex >= len(lines):
break
lbits = lines[lindex].split()
if not lbits:
continue # Skip empty lines.
if lbits[0] != 'class':
break
if len(lbits) > 1:
cbits = lbits[1].split('(')
if len(cbits) > 1 and cbits[0].isidentifier():
classname = cbits[0]
break # Success!
if classname is None:
logging.warning(
'metascan: %s:%d: class definition not found below'
" 'ba_meta export' statement.",
subpath,
lindexorig + 1,
)
self.results.announce_errors_occurred = True
return classname
def _get_api_requirement(
self,
subpath: Path,
meta_lines: dict[int, list[str]],
toplevel: bool,
) -> int | None:
"""Return an API requirement integer or None if none present.
Malformed api requirement strings will be logged as warnings.
"""
lines = [
l
for l in meta_lines.values()
if len(l) == 4
and l[0] == 'ba_meta'
and l[1] == 'require'
and l[2] == 'api'
and l[3].isdigit()
]
# We're successful if we find exactly one properly formatted
# line.
if len(lines) == 1:
return int(lines[0][3])
# Ok; not successful. lets issue warnings for a few error cases.
if len(lines) > 1:
logging.warning(
"metascan: %s: multiple '# ba_meta require api <NUM>'"
' lines found; ignoring module.',
subpath,
)
self.results.announce_errors_occurred = True
elif not lines and toplevel and meta_lines:
# If we're a top-level module containing meta lines but no
# valid "require api" line found, complain.
logging.warning(
"metascan: %s: no valid '# ba_meta require api <NUM>"
' line found; ignoring module.',
subpath,
)
self.results.announce_errors_occurred = True
return None

View file

@ -0,0 +1,2 @@
# Released under the MIT License. See LICENSE for details.
#

View file

@ -0,0 +1,205 @@
# Released under the MIT License. See LICENSE for details.
"""Enum vals generated by batools.pythonenumsmodule; do not edit by hand."""
from enum import Enum
class InputType(Enum):
"""Types of input a controller can send to the game.
Category: Enums
"""
UP_DOWN = 2
LEFT_RIGHT = 3
JUMP_PRESS = 4
JUMP_RELEASE = 5
PUNCH_PRESS = 6
PUNCH_RELEASE = 7
BOMB_PRESS = 8
BOMB_RELEASE = 9
PICK_UP_PRESS = 10
PICK_UP_RELEASE = 11
RUN = 12
FLY_PRESS = 13
FLY_RELEASE = 14
START_PRESS = 15
START_RELEASE = 16
HOLD_POSITION_PRESS = 17
HOLD_POSITION_RELEASE = 18
LEFT_PRESS = 19
LEFT_RELEASE = 20
RIGHT_PRESS = 21
RIGHT_RELEASE = 22
UP_PRESS = 23
UP_RELEASE = 24
DOWN_PRESS = 25
DOWN_RELEASE = 26
class UIScale(Enum):
"""The overall scale the UI is being rendered for. Note that this is
independent of pixel resolution. For example, a phone and a desktop PC
might render the game at similar pixel resolutions but the size they
display content at will vary significantly.
Category: Enums
'large' is used for devices such as desktop PCs where fine details can
be clearly seen. UI elements are generally smaller on the screen
and more content can be seen at once.
'medium' is used for devices such as tablets, TVs, or VR headsets.
This mode strikes a balance between clean readability and amount of
content visible.
'small' is used primarily for phones or other small devices where
content needs to be presented as large and clear in order to remain
readable from an average distance.
"""
LARGE = 0
MEDIUM = 1
SMALL = 2
class TimeType(Enum):
"""Specifies the type of time for various operations to target/use.
Category: Enums
'sim' time is the local simulation time for an activity or session.
It can proceed at different rates depending on game speed, stops
for pauses, etc.
'base' is the baseline time for an activity or session. It proceeds
consistently regardless of game speed or pausing, but may stop during
occurrences such as network outages.
'real' time is mostly based on clock time, with a few exceptions. It may
not advance while the app is backgrounded for instance. (the engine
attempts to prevent single large time jumps from occurring)
"""
SIM = 0
BASE = 1
REAL = 2
class TimeFormat(Enum):
"""Specifies the format time values are provided in.
Category: Enums
"""
SECONDS = 0
MILLISECONDS = 1
class Permission(Enum):
"""Permissions that can be requested from the OS.
Category: Enums
"""
STORAGE = 0
class SpecialChar(Enum):
"""Special characters the game can print.
Category: Enums
"""
DOWN_ARROW = 0
UP_ARROW = 1
LEFT_ARROW = 2
RIGHT_ARROW = 3
TOP_BUTTON = 4
LEFT_BUTTON = 5
RIGHT_BUTTON = 6
BOTTOM_BUTTON = 7
DELETE = 8
SHIFT = 9
BACK = 10
LOGO_FLAT = 11
REWIND_BUTTON = 12
PLAY_PAUSE_BUTTON = 13
FAST_FORWARD_BUTTON = 14
DPAD_CENTER_BUTTON = 15
OUYA_BUTTON_O = 16
OUYA_BUTTON_U = 17
OUYA_BUTTON_Y = 18
OUYA_BUTTON_A = 19
OUYA_LOGO = 20
LOGO = 21
TICKET = 22
GOOGLE_PLAY_GAMES_LOGO = 23
GAME_CENTER_LOGO = 24
DICE_BUTTON1 = 25
DICE_BUTTON2 = 26
DICE_BUTTON3 = 27
DICE_BUTTON4 = 28
GAME_CIRCLE_LOGO = 29
PARTY_ICON = 30
TEST_ACCOUNT = 31
TICKET_BACKING = 32
TROPHY1 = 33
TROPHY2 = 34
TROPHY3 = 35
TROPHY0A = 36
TROPHY0B = 37
TROPHY4 = 38
LOCAL_ACCOUNT = 39
EXPLODINARY_LOGO = 40
FLAG_UNITED_STATES = 41
FLAG_MEXICO = 42
FLAG_GERMANY = 43
FLAG_BRAZIL = 44
FLAG_RUSSIA = 45
FLAG_CHINA = 46
FLAG_UNITED_KINGDOM = 47
FLAG_CANADA = 48
FLAG_INDIA = 49
FLAG_JAPAN = 50
FLAG_FRANCE = 51
FLAG_INDONESIA = 52
FLAG_ITALY = 53
FLAG_SOUTH_KOREA = 54
FLAG_NETHERLANDS = 55
FEDORA = 56
HAL = 57
CROWN = 58
YIN_YANG = 59
EYE_BALL = 60
SKULL = 61
HEART = 62
DRAGON = 63
HELMET = 64
MUSHROOM = 65
NINJA_STAR = 66
VIKING_HELMET = 67
MOON = 68
SPIDER = 69
FIREBALL = 70
FLAG_UNITED_ARAB_EMIRATES = 71
FLAG_QATAR = 72
FLAG_EGYPT = 73
FLAG_KUWAIT = 74
FLAG_ALGERIA = 75
FLAG_SAUDI_ARABIA = 76
FLAG_MALAYSIA = 77
FLAG_CZECH_REPUBLIC = 78
FLAG_AUSTRALIA = 79
FLAG_SINGAPORE = 80
OCULUS_LOGO = 81
STEAM_LOGO = 82
NVIDIA_LOGO = 83
FLAG_IRAN = 84
FLAG_POLAND = 85
FLAG_ARGENTINA = 86
FLAG_PHILIPPINES = 87
FLAG_CHILE = 88
MIKIROG = 89
V2_LOGO = 90

75
dist/ba_data/python/babase/_net.py vendored Normal file
View file

@ -0,0 +1,75 @@
# Released under the MIT License. See LICENSE for details.
#
"""Networking related functionality."""
from __future__ import annotations
import ssl
import threading
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import socket
# Timeout for standard functions talking to the master-server/etc.
DEFAULT_REQUEST_TIMEOUT_SECONDS = 60
class NetworkSubsystem:
"""Network related app subsystem."""
def __init__(self) -> None:
# Anyone accessing/modifying zone_pings should hold this lock,
# as it is updated by a background thread.
self.zone_pings_lock = threading.Lock()
# Zone IDs mapped to average pings. This will remain empty
# until enough pings have been run to be reasonably certain
# that a nearby server has been pinged.
self.zone_pings: dict[str, float] = {}
self._sslcontext: ssl.SSLContext | None = None
# For debugging.
self.v1_test_log: str = ''
self.v1_ctest_results: dict[int, str] = {}
self.server_time_offset_hours: float | None = None
@property
def sslcontext(self) -> ssl.SSLContext:
"""Create/return our shared SSLContext.
This can be reused for all standard urllib requests/etc.
"""
# Note: I've run into older Android devices taking upwards of 1 second
# to put together a default SSLContext, so recycling one can definitely
# be a worthwhile optimization. This was suggested to me in this
# thread by one of Python's SSL maintainers:
# https://github.com/python/cpython/issues/94637
if self._sslcontext is None:
self._sslcontext = ssl.create_default_context()
return self._sslcontext
def get_ip_address_type(addr: str) -> socket.AddressFamily:
"""Return socket.AF_INET6 or socket.AF_INET4 for the provided address."""
import socket
socket_type = None
# First try it as an ipv4 address.
try:
socket.inet_pton(socket.AF_INET, addr)
socket_type = socket.AF_INET
except OSError:
pass
# Hmm apparently not ipv4; try ipv6.
if socket_type is None:
try:
socket.inet_pton(socket.AF_INET6, addr)
socket_type = socket.AF_INET6
except OSError:
pass
if socket_type is None:
raise ValueError(f'addr seems to be neither v4 or v6: {addr}')
return socket_type

333
dist/ba_data/python/babase/_plugin.py vendored Normal file
View file

@ -0,0 +1,333 @@
# Released under the MIT License. See LICENSE for details.
#
"""Plugin related functionality."""
from __future__ import annotations
import logging
import importlib.util
from typing import TYPE_CHECKING
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Any
import babase
class PluginSubsystem(AppSubsystem):
"""Subsystem for plugin handling in the app.
Category: **App Classes**
Access the single shared instance of this class at `ba.app.plugins`.
"""
AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY = 'Auto Enable New Plugins'
AUTO_ENABLE_NEW_PLUGINS_DEFAULT = True
def __init__(self) -> None:
super().__init__()
# Info about plugins that we are aware of. This may include
# plugins discovered through meta-scanning as well as plugins
# registered in the app-config. This may include plugins that
# cannot be loaded for various reasons or that have been
# intentionally disabled.
self.plugin_specs: dict[str, babase.PluginSpec] = {}
# The set of live active plugin objects.
self.active_plugins: list[babase.Plugin] = []
def on_meta_scan_complete(self) -> None:
"""Called when meta-scanning is complete."""
from babase._language import Lstr
config_changed = False
found_new = False
plugstates: dict[str, dict] = _babase.app.config.setdefault(
'Plugins', {}
)
assert isinstance(plugstates, dict)
results = _babase.app.meta.scanresults
assert results is not None
auto_enable_new_plugins = (
_babase.app.config.get(
self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
)
is True
)
assert not self.plugin_specs
assert not self.active_plugins
# Create a plugin-spec for each plugin class we found in the
# meta-scan.
for class_path in results.exports_of_class(Plugin):
assert class_path not in self.plugin_specs
plugspec = self.plugin_specs[class_path] = PluginSpec(
class_path=class_path, loadable=True
)
# Auto-enable new ones if desired.
if auto_enable_new_plugins:
if class_path not in plugstates:
plugspec.enabled = True
config_changed = True
found_new = True
# If we're *not* auto-enabling, just let the user know if we
# found new ones.
if found_new and not auto_enable_new_plugins:
_babase.screenmessage(
Lstr(resource='pluginsDetectedText'), color=(0, 1, 0)
)
_babase.getsimplesound('ding').play()
# Ok, now go through all plugins registered in the app-config
# that weren't covered by the meta stuff above, either creating
# plugin-specs for them or clearing them out. This covers
# plugins with api versions not matching ours, plugins without
# ba_meta tags, and plugins that have since disappeared.
assert isinstance(plugstates, dict)
wrong_api_prefixes = [f'{m}.' for m in results.incorrect_api_modules]
disappeared_plugs: set[str] = set()
for class_path in sorted(plugstates.keys()):
# Already have a spec for it; nothing to be done.
if class_path in self.plugin_specs:
continue
# If this plugin corresponds to any modules that we've
# identified as having incorrect api versions, we'll take
# note of its existence but we won't try to load it.
if any(
class_path.startswith(prefix) for prefix in wrong_api_prefixes
):
plugspec = self.plugin_specs[class_path] = PluginSpec(
class_path=class_path, loadable=False
)
continue
# Ok, it seems to be a class we have no metadata for. Look
# to see if it appears to be an actual class we could
# theoretically load. If so, we'll try. If not, we consider
# the plugin to have disappeared and inform the user as
# such.
try:
spec = importlib.util.find_spec(
'.'.join(class_path.split('.')[:-1])
)
except Exception:
spec = None
if spec is None:
disappeared_plugs.add(class_path)
continue
# If plugins disappeared, let the user know gently and remove them
# from the config so we'll again let the user know if they later
# reappear. This makes it much smoother to switch between users
# or workspaces.
if disappeared_plugs:
_babase.getsimplesound('shieldDown').play()
_babase.screenmessage(
Lstr(
resource='pluginsRemovedText',
subs=[('${NUM}', str(len(disappeared_plugs)))],
),
color=(1, 1, 0),
)
plugnames = ', '.join(disappeared_plugs)
logging.info(
'%d plugin(s) no longer found: %s.',
len(disappeared_plugs),
plugnames,
)
for goneplug in disappeared_plugs:
del _babase.app.config['Plugins'][goneplug]
_babase.app.config.commit()
if config_changed:
_babase.app.config.commit()
def on_app_running(self) -> None:
# Load up our plugins and go ahead and call their on_app_running
# calls.
self.load_plugins()
for plugin in self.active_plugins:
try:
plugin.on_app_running()
except Exception:
from babase import _error
_error.print_exception('Error in plugin on_app_running()')
def on_app_pause(self) -> None:
for plugin in self.active_plugins:
try:
plugin.on_app_pause()
except Exception:
from babase import _error
_error.print_exception('Error in plugin on_app_pause()')
def on_app_resume(self) -> None:
for plugin in self.active_plugins:
try:
plugin.on_app_resume()
except Exception:
from babase import _error
_error.print_exception('Error in plugin on_app_resume()')
def on_app_shutdown(self) -> None:
for plugin in self.active_plugins:
try:
plugin.on_app_shutdown()
except Exception:
from babase import _error
_error.print_exception('Error in plugin on_app_shutdown()')
def load_plugins(self) -> None:
"""(internal)"""
# Load plugins from any specs that are enabled & able to.
for _class_path, plug_spec in sorted(self.plugin_specs.items()):
plugin = plug_spec.attempt_load_if_enabled()
if plugin is not None:
self.active_plugins.append(plugin)
class PluginSpec:
"""Represents a plugin the engine knows about.
Category: **App Classes**
The 'enabled' attr represents whether this plugin is set to load.
Getting or setting that attr affects the corresponding app-config
key. Remember to commit the app-config after making any changes.
The 'attempted_load' attr will be True if the engine has attempted
to load the plugin. If 'attempted_load' is True for a plugin-spec
but the 'plugin' attr is None, it means there was an error loading
the plugin. If a plugin's api-version does not match the running
app, if a new plugin is detected with auto-enable-plugins disabled,
or if the user has explicitly disabled a plugin, the engine will not
even attempt to load it.
"""
def __init__(self, class_path: str, loadable: bool):
self.class_path = class_path
self.loadable = loadable
self.attempted_load = False
self.plugin: Plugin | None = None
@property
def enabled(self) -> bool:
"""Whether the user wants this plugin to load."""
plugstates: dict[str, dict] = _babase.app.config.get('Plugins', {})
assert isinstance(plugstates, dict)
val = plugstates.get(self.class_path, {}).get('enabled', False) is True
return val
@enabled.setter
def enabled(self, val: bool) -> None:
plugstates: dict[str, dict] = _babase.app.config.setdefault(
'Plugins', {}
)
assert isinstance(plugstates, dict)
plugstate = plugstates.setdefault(self.class_path, {})
plugstate['enabled'] = val
def attempt_load_if_enabled(self) -> Plugin | None:
"""Possibly load the plugin and report errors."""
from babase._general import getclass
from babase._language import Lstr
assert not self.attempted_load
assert self.plugin is None
if not self.enabled:
return None
self.attempted_load = True
if not self.loadable:
return None
try:
cls = getclass(self.class_path, Plugin)
except Exception as exc:
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(
resource='pluginClassLoadErrorText',
subs=[
('${PLUGIN}', self.class_path),
('${ERROR}', str(exc)),
],
),
color=(1, 0, 0),
)
logging.exception(
"Error loading plugin class '%s'.", self.class_path
)
return None
try:
self.plugin = cls()
return self.plugin
except Exception as exc:
from babase import _error
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(
resource='pluginInitErrorText',
subs=[
('${PLUGIN}', self.class_path),
('${ERROR}', str(exc)),
],
),
color=(1, 0, 0),
)
logging.exception(
"Error initing plugin class: '%s'.", self.class_path
)
return None
class Plugin:
"""A plugin to alter app behavior in some way.
Category: **App Classes**
Plugins are discoverable by the meta-tag system
and the user can select which ones they want to activate.
Active plugins are then called at specific times as the
app is running in order to modify its behavior in some way.
"""
def on_app_running(self) -> None:
"""Called when the app reaches the running state."""
def on_app_pause(self) -> None:
"""Called after pausing game activity."""
def on_app_resume(self) -> None:
"""Called after the game continues."""
def on_app_shutdown(self) -> None:
"""Called before closing the application."""
def has_settings_ui(self) -> bool:
"""Called to ask if we have settings UI we can show."""
return False
def show_settings_ui(self, source_widget: Any | None) -> None:
"""Called to show our settings UI."""

90
dist/ba_data/python/babase/_text.py vendored Normal file
View file

@ -0,0 +1,90 @@
# Released under the MIT License. See LICENSE for details.
#
"""Text related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import babase
def timestring(
timeval: float | int,
centi: bool = True,
) -> babase.Lstr:
"""Generate a babase.Lstr for displaying a time value.
Category: **General Utility Functions**
Given a time value, returns a babase.Lstr with:
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
WARNING: the underlying Lstr value is somewhat large so don't use this
to rapidly update Node text values for an onscreen timer or you may
consume significant network bandwidth. For that purpose you should
use a 'timedisplay' Node and attribute connections.
"""
from babase._language import Lstr
# We take float seconds but operate on int milliseconds internally.
timeval = int(1000 * timeval)
bits = []
subs = []
hval = (timeval // 1000) // (60 * 60)
if hval != 0:
bits.append('${H}')
subs.append(
(
'${H}',
Lstr(
resource='timeSuffixHoursText',
subs=[('${COUNT}', str(hval))],
),
)
)
mval = ((timeval // 1000) // 60) % 60
if mval != 0:
bits.append('${M}')
subs.append(
(
'${M}',
Lstr(
resource='timeSuffixMinutesText',
subs=[('${COUNT}', str(mval))],
),
)
)
# We add seconds if its non-zero *or* we haven't added anything else.
if centi:
# pylint: disable=consider-using-f-string
sval = timeval / 1000.0 % 60.0
if sval >= 0.005 or not bits:
bits.append('${S}')
subs.append(
(
'${S}',
Lstr(
resource='timeSuffixSecondsText',
subs=[('${COUNT}', ('%.2f' % sval))],
),
)
)
else:
sval = timeval // 1000 % 60
if sval != 0 or not bits:
bits.append('${S}')
subs.append(
(
'${S}',
Lstr(
resource='timeSuffixSecondsText',
subs=[('${COUNT}', str(sval))],
),
)
)
return Lstr(value=' '.join(bits), subs=subs)

217
dist/ba_data/python/babase/_workspace.py vendored Normal file
View file

@ -0,0 +1,217 @@
# Released under the MIT License. See LICENSE for details.
#
"""Workspace related functionality."""
from __future__ import annotations
import os
import sys
import logging
from pathlib import Path
from threading import Thread
from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.error import CleanError
import _babase
import bacommon.cloud
from bacommon.transfer import DirectoryManifest
if TYPE_CHECKING:
from typing import Callable
import babase
class WorkspaceSubsystem:
"""Subsystem for workspace handling in the app.
Category: **App Classes**
Access the single shared instance of this class at `ba.app.workspaces`.
"""
def __init__(self) -> None:
pass
def set_active_workspace(
self,
account: babase.AccountV2Handle,
workspaceid: str,
workspacename: str,
on_completed: Callable[[], None],
) -> None:
"""(internal)"""
# Do our work in a background thread so we don't destroy
# interactivity.
Thread(
target=lambda: self._set_active_workspace_bg(
account=account,
workspaceid=workspaceid,
workspacename=workspacename,
on_completed=on_completed,
),
daemon=True,
).start()
def _errmsg(self, msg: babase.Lstr) -> None:
_babase.screenmessage(msg, color=(1, 0, 0))
_babase.getsimplesound('error').play()
def _successmsg(self, msg: babase.Lstr) -> None:
_babase.screenmessage(msg, color=(0, 1, 0))
_babase.getsimplesound('gunCocking').play()
def _set_active_workspace_bg(
self,
account: babase.AccountV2Handle,
workspaceid: str,
workspacename: str,
on_completed: Callable[[], None],
) -> None:
from babase._language import Lstr
class _SkipSyncError(RuntimeError):
pass
plus = _babase.app.plus
assert plus is not None
set_path = True
wspath = Path(
_babase.get_volatile_data_directory(), 'workspaces', workspaceid
)
try:
# If it seems we're offline, don't even attempt a sync,
# but allow using the previous synced state.
# (is this a good idea?)
if not plus.cloud.is_connected():
raise _SkipSyncError()
manifest = DirectoryManifest.create_from_disk(wspath)
# FIXME: Should implement a way to pass account credentials in
# from the logic thread.
state = bacommon.cloud.WorkspaceFetchState(manifest=manifest)
while True:
with account:
response = plus.cloud.send_message(
bacommon.cloud.WorkspaceFetchMessage(
workspaceid=workspaceid, state=state
)
)
state = response.state
self._handle_deletes(
workspace_dir=wspath, deletes=response.deletes
)
self._handle_downloads_inline(
workspace_dir=wspath,
downloads_inline=response.downloads_inline,
)
if response.done:
# Server only deals in files; let's clean up any
# leftover empty dirs after the dust has cleared.
self._handle_dir_prune_empty(str(wspath))
break
state.iteration += 1
_babase.pushcall(
tpartial(
self._successmsg,
Lstr(
resource='activatedText',
subs=[('${THING}', workspacename)],
),
),
from_other_thread=True,
)
except _SkipSyncError:
_babase.pushcall(
tpartial(
self._errmsg,
Lstr(
resource='workspaceSyncReuseText',
subs=[('${WORKSPACE}', workspacename)],
),
),
from_other_thread=True,
)
except CleanError as exc:
# Avoid reusing existing if we fail in the middle; could
# be in wonky state.
set_path = False
_babase.pushcall(
tpartial(self._errmsg, Lstr(value=str(exc))),
from_other_thread=True,
)
except Exception:
# Ditto.
set_path = False
logging.exception("Error syncing workspace '%s'.", workspacename)
_babase.pushcall(
tpartial(
self._errmsg,
Lstr(
resource='workspaceSyncErrorText',
subs=[('${WORKSPACE}', workspacename)],
),
),
from_other_thread=True,
)
if set_path and wspath.is_dir():
# Add to Python paths and also to list of stuff to be scanned
# for meta tags.
sys.path.insert(0, str(wspath))
_babase.app.meta.extra_scan_dirs.append(str(wspath))
# Job's done!
_babase.pushcall(on_completed, from_other_thread=True)
def _handle_deletes(self, workspace_dir: Path, deletes: list[str]) -> None:
"""Handle file deletes."""
for fname in deletes:
fname = os.path.join(workspace_dir, fname)
# Server shouldn't be sending us dir paths here.
assert not os.path.isdir(fname)
os.unlink(fname)
def _handle_downloads_inline(
self,
workspace_dir: Path,
downloads_inline: dict[str, bytes],
) -> None:
"""Handle inline file data to be saved to the client."""
for fname, fdata in downloads_inline.items():
fname = os.path.join(workspace_dir, fname)
# If there's a directory where we want our file to go, clear it
# out first. File deletes should have run before this so
# everything under it should be empty and thus killable via rmdir.
if os.path.isdir(fname):
for basename, dirnames, _fn in os.walk(fname, topdown=False):
for dirname in dirnames:
os.rmdir(os.path.join(basename, dirname))
os.rmdir(fname)
dirname = os.path.dirname(fname)
if dirname:
os.makedirs(dirname, exist_ok=True)
with open(fname, 'wb') as outfile:
outfile.write(fdata)
def _handle_dir_prune_empty(self, prunedir: str) -> None:
"""Handle pruning empty directories."""
# Walk the tree bottom-up so we can properly kill recursive empty dirs.
for basename, dirnames, filenames in os.walk(prunedir, topdown=False):
# It seems that child dirs we kill during the walk are still
# listed when the parent dir is visited, so lets make sure
# to only acknowledge still-existing ones.
dirnames = [
d for d in dirnames if os.path.exists(os.path.join(basename, d))
]
if not dirnames and not filenames and basename != prunedir:
os.rmdir(basename)

190
dist/ba_data/python/babase/modutils.py vendored Normal file
View file

@ -0,0 +1,190 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to modding."""
from __future__ import annotations
from typing import TYPE_CHECKING
import os
import _babase
if TYPE_CHECKING:
from typing import Sequence
def get_human_readable_user_scripts_path() -> str:
"""Return a human readable location of user-scripts.
This is NOT a valid filesystem path; may be something like "(SD Card)".
"""
app = _babase.app
path: str | None = app.python_directory_user
if path is None:
return '<Not Available>'
# These days, on Android, we use getExternalFilesDir() as the base of our
# app's user-scripts dir, which gives us paths like:
# /storage/emulated/0/Android/data/net.froemling.bombsquad/files
# Userspace apps tend to show that as:
# Android/data/net.froemling.bombsquad/files
# We'd like to display it that way, but I'm not sure if there's a clean
# way to get the root of the external storage area (/storage/emulated/0)
# so that we could strip it off. There is
# Environment.getExternalStorageDirectory() but that is deprecated.
# So for now let's just be conservative and trim off recognized prefixes
# and show the whole ugly path as a fallback.
# Note that we used to use externalStorageText resource but gonna try
# without it for now. (simply 'foo' instead of <External Storage>/foo).
if app.classic is not None and app.classic.platform == 'android':
for pre in ['/storage/emulated/0/']:
if path.startswith(pre):
path = path.removeprefix(pre)
break
return path
def _request_storage_permission() -> bool:
"""If needed, requests storage permission from the user (& return true)."""
from babase._language import Lstr
# noinspection PyProtectedMember
# (PyCharm inspection bug?)
from babase._mgen.enums import Permission
if not _babase.have_permission(Permission.STORAGE):
_babase.getsimplesound('error').play()
_babase.screenmessage(
Lstr(resource='storagePermissionAccessText'), color=(1, 0, 0)
)
_babase.apptimer(
1.0, lambda: _babase.request_permission(Permission.STORAGE)
)
return True
return False
def show_user_scripts() -> None:
"""Open or nicely print the location of the user-scripts directory."""
app = _babase.app
# First off, if we need permission for this, ask for it.
if _request_storage_permission():
return
# If we're running in a nonstandard environment its possible this is unset.
if app.python_directory_user is None:
_babase.screenmessage('<unset>')
return
# Secondly, if the dir doesn't exist, attempt to make it.
if not os.path.exists(app.python_directory_user):
os.makedirs(app.python_directory_user)
# On android, attempt to write a file in their user-scripts dir telling
# them about modding. This also has the side-effect of allowing us to
# media-scan that dir so it shows up in android-file-transfer, since it
# doesn't seem like there's a way to inform the media scanner of an empty
# directory, which means they would have to reboot their device before
# they can see it.
if app.classic is not None and app.classic.platform == 'android':
try:
usd: str | None = app.python_directory_user
if usd is not None and os.path.isdir(usd):
file_name = usd + '/about_this_folder.txt'
with open(file_name, 'w', encoding='utf-8') as outfile:
outfile.write(
'You can drop files in here to mod the game.'
' See settings/advanced'
' in the game for more info.'
)
except Exception:
from babase import _error
_error.print_exception('error writing about_this_folder stuff')
# On a few platforms we try to open the dir in the UI.
if app.classic is not None and app.classic.platform in ['mac', 'windows']:
_babase.open_dir_externally(app.python_directory_user)
# Otherwise we just print a pretty version of it.
else:
_babase.screenmessage(get_human_readable_user_scripts_path())
def create_user_system_scripts() -> None:
"""Set up a copy of Ballistica app scripts under user scripts dir.
(for editing and experimenting)
"""
import shutil
app = _babase.app
# First off, if we need permission for this, ask for it.
if _request_storage_permission():
return
# Its possible these are unset in non-standard environments.
if app.python_directory_user is None:
raise RuntimeError('user python dir unset')
if app.python_directory_app is None:
raise RuntimeError('app python dir unset')
path = app.python_directory_user + '/sys/' + app.version
pathtmp = path + '_tmp'
if os.path.exists(path):
shutil.rmtree(path)
if os.path.exists(pathtmp):
shutil.rmtree(pathtmp)
def _ignore_filter(src: str, names: Sequence[str]) -> Sequence[str]:
del src, names # Unused
# We simply skip all __pycache__ directories. (the user would have
# to blow them away anyway to make changes;
# See https://github.com/efroemling/ballistica/wiki
# /Knowledge-Nuggets#python-cache-files-gotcha
return ('__pycache__',)
print(f'COPYING "{app.python_directory_app}" -> "{pathtmp}".')
shutil.copytree(app.python_directory_app, pathtmp, ignore=_ignore_filter)
print(f'MOVING "{pathtmp}" -> "{path}".')
shutil.move(pathtmp, path)
print(
f"Created system scripts at :'{path}"
f"'\nRestart {_babase.appname()} to use them."
f' (use babase.quit() to exit the game)'
)
if app.classic is not None and app.classic.platform == 'android':
print(
'Note: the new files may not be visible via '
'android-file-transfer until you restart your device.'
)
def delete_user_system_scripts() -> None:
"""Clean out the scripts created by create_user_system_scripts()."""
import shutil
app = _babase.app
if app.python_directory_user is None:
raise RuntimeError('user python dir unset')
path = app.python_directory_user + '/sys/' + app.version
if os.path.exists(path):
shutil.rmtree(path)
print(
f'User system scripts deleted.\n'
f'Restart {_babase.appname()} to use internal'
f' scripts. (use babase.quit() to exit the game)'
)
else:
print(f"User system scripts not found at '{path}'.")
# If the sys path is empty, kill it.
dpath = app.python_directory_user + '/sys'
if os.path.isdir(dpath) and not os.listdir(dpath):
os.rmdir(dpath)