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

View file

@ -1,277 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""A dummy stub module for the real _bainternal.
The real _bainternal is a compiled extension module and only available
in the live engine. This dummy-module allows Pylint/Mypy/etc. to
function reasonably well outside of that environment.
Make sure this file is never included in dirs seen by the engine!
In the future perhaps this can be a stub (.pyi) file, but we will need
to make sure that it works with all our tools (mypy, pylint, pycharm).
NOTE: This file was autogenerated by batools.dummymodule; do not edit by hand.
"""
# I'm sorry Pylint. I know this file saddens you. Be strong.
# pylint: disable=useless-suppression
# pylint: disable=unnecessary-pass
# pylint: disable=use-dict-literal
# pylint: disable=use-list-literal
# pylint: disable=unused-argument
# pylint: disable=missing-docstring
# pylint: disable=too-many-locals
# pylint: disable=redefined-builtin
# pylint: disable=too-many-lines
# pylint: disable=redefined-outer-name
# pylint: disable=invalid-name
# pylint: disable=no-value-for-parameter
from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar
if TYPE_CHECKING:
from typing import Any, Callable
_T = TypeVar('_T')
def _uninferrable() -> Any:
"""Get an "Any" in mypy and "uninferrable" in Pylint."""
# pylint: disable=undefined-variable
return _not_a_real_variable # type: ignore
def add_transaction(
transaction: dict, callback: Callable | None = None
) -> None:
"""(internal)"""
return None
def game_service_has_leaderboard(game: str, config: str) -> bool:
"""(internal)
Given a game and config string, returns whether there is a leaderboard
for it on the game service.
"""
return bool()
def get_master_server_address(source: int = -1, version: int = 1) -> str:
"""(internal)
Return the address of the master server.
"""
return str()
def get_news_show() -> str:
"""(internal)"""
return str()
def get_price(item: str) -> str | None:
"""(internal)"""
return ''
def get_public_login_id() -> str | None:
"""(internal)"""
return ''
def get_purchased(item: str) -> bool:
"""(internal)"""
return bool()
def get_purchases_state() -> int:
"""(internal)"""
return int()
def get_v1_account_display_string(full: bool = True) -> str:
"""(internal)"""
return str()
def get_v1_account_misc_read_val(name: str, default_value: Any) -> Any:
"""(internal)"""
return _uninferrable()
def get_v1_account_misc_read_val_2(name: str, default_value: Any) -> Any:
"""(internal)"""
return _uninferrable()
def get_v1_account_misc_val(name: str, default_value: Any) -> Any:
"""(internal)"""
return _uninferrable()
def get_v1_account_name() -> str:
"""(internal)"""
return str()
def get_v1_account_state() -> str:
"""(internal)"""
return str()
def get_v1_account_state_num() -> int:
"""(internal)"""
return int()
def get_v1_account_ticket_count() -> int:
"""(internal)
Returns the number of tickets for the current account.
"""
return int()
def get_v1_account_type() -> str:
"""(internal)"""
return str()
def get_v2_fleet() -> str:
"""(internal)"""
return str()
def have_outstanding_transactions() -> bool:
"""(internal)"""
return bool()
def in_game_purchase(item: str, price: int) -> None:
"""(internal)"""
return None
def is_blessed() -> bool:
"""(internal)"""
return bool()
def mark_config_dirty() -> None:
"""(internal)
Category: General Utility Functions
"""
return None
def power_ranking_query(callback: Callable, season: Any = None) -> None:
"""(internal)"""
return None
def purchase(item: str) -> None:
"""(internal)"""
return None
def report_achievement(achievement: str, pass_to_account: bool = True) -> None:
"""(internal)"""
return None
def reset_achievements() -> None:
"""(internal)"""
return None
def restore_purchases() -> None:
"""(internal)"""
return None
def run_transactions() -> None:
"""(internal)"""
return None
def sign_in_v1(account_type: str) -> None:
"""(internal)
Category: General Utility Functions
"""
return None
def sign_out_v1(v2_embedded: bool = False) -> None:
"""(internal)
Category: General Utility Functions
"""
return None
def submit_score(
game: str,
config: str,
name: Any,
score: int | None,
callback: Callable,
order: str = 'increasing',
tournament_id: str | None = None,
score_type: str = 'points',
campaign: str | None = None,
level: str | None = None,
) -> None:
"""(internal)
Submit a score to the server; callback will be called with the results.
As a courtesy, please don't send fake scores to the server. I'd prefer
to devote my time to improving the game instead of trying to make the
score server more mischief-proof.
"""
return None
def tournament_query(
callback: Callable[[dict | None], None], args: dict
) -> None:
"""(internal)"""
return None

View file

@ -1,396 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""The public face of Ballistica.
This top level module is a collection of most commonly used functionality.
For many modding purposes, the bits exposed here are all you'll need.
In some specific cases you may need to pull in individual submodules instead.
"""
# pylint: disable=redefined-builtin
from _ba import (
CollideModel,
Context,
ContextCall,
Data,
InputDevice,
Material,
Model,
Node,
SessionPlayer,
Sound,
Texture,
Timer,
Vec3,
Widget,
buttonwidget,
camerashake,
checkboxwidget,
columnwidget,
containerwidget,
do_once,
emitfx,
getactivity,
getcollidemodel,
getmodel,
getnodes,
getsession,
getsound,
gettexture,
hscrollwidget,
imagewidget,
newactivity,
newnode,
playsound,
printnodes,
ls_objects,
ls_input_devices,
pushcall,
quit,
rowwidget,
safecolor,
screenmessage,
scrollwidget,
set_analytics_screen,
charstr,
textwidget,
time,
timer,
open_url,
widget,
clipboard_is_supported,
clipboard_has_text,
clipboard_get_text,
clipboard_set_text,
getdata,
in_logic_thread,
)
from ba._accountv2 import AccountV2Handle
from ba._activity import Activity
from ba._plugin import PotentialPlugin, Plugin, PluginSubsystem
from ba._actor import Actor
from ba._player import PlayerInfo, Player, EmptyPlayer, StandLocation
from ba._nodeactor import NodeActor
from ba._app import App
from ba._cloud import CloudSubsystem
from ba._coopgame import CoopGameActivity
from ba._coopsession import CoopSession
from ba._dependency import (
Dependency,
DependencyComponent,
DependencySet,
AssetPackage,
)
from ba._generated.enums import (
TimeType,
Permission,
TimeFormat,
SpecialChar,
InputType,
UIScale,
)
from ba._error import (
print_exception,
print_error,
ContextError,
NotFoundError,
PlayerNotFoundError,
SessionPlayerNotFoundError,
NodeNotFoundError,
ActorNotFoundError,
InputDeviceNotFoundError,
WidgetNotFoundError,
ActivityNotFoundError,
TeamNotFoundError,
MapNotFoundError,
SessionTeamNotFoundError,
SessionNotFoundError,
DelegateNotFoundError,
DependencyError,
)
from ba._freeforallsession import FreeForAllSession
from ba._gameactivity import GameActivity
from ba._gameresults import GameResults
from ba._settings import (
Setting,
IntSetting,
FloatSetting,
ChoiceSetting,
BoolSetting,
IntChoiceSetting,
FloatChoiceSetting,
)
from ba._language import Lstr, LanguageSubsystem
from ba._map import Map, getmaps
from ba._session import Session
from ba._ui import UISubsystem
from ba._servermode import ServerController
from ba._score import ScoreType, ScoreConfig
from ba._stats import PlayerScoredMessage, PlayerRecord, Stats
from ba._team import SessionTeam, Team, EmptyTeam
from ba._teamgame import TeamGameActivity
from ba._dualteamsession import DualTeamSession
from ba._achievement import Achievement, AchievementSubsystem
from ba._appconfig import AppConfig
from ba._appdelegate import AppDelegate
from ba._apputils import is_browser_likely_available, garbage_collect
from ba._campaign import Campaign
from ba._gameutils import (
GameTip,
animate,
animate_array,
show_damage_count,
timestring,
cameraflash,
)
from ba._general import (
WeakCall,
Call,
existing,
Existable,
verify_object_death,
storagename,
getclass,
)
from ba._keyboard import Keyboard
from ba._level import Level
from ba._lobby import Lobby, Chooser
from ba._math import normalized_color, is_point_in_box, vec3validate
from ba._meta import MetadataSubsystem
from ba._messages import (
UNHANDLED,
OutOfBoundsMessage,
DeathType,
DieMessage,
PlayerDiedMessage,
StandMessage,
PickUpMessage,
DropMessage,
PickedUpMessage,
DroppedMessage,
ShouldShatterMessage,
ImpactDamageMessage,
FreezeMessage,
ThawMessage,
HitMessage,
CelebrateMessage,
)
from ba._music import (
setmusic,
MusicPlayer,
MusicType,
MusicPlayMode,
MusicSubsystem,
)
from ba._powerup import PowerupMessage, PowerupAcceptMessage
from ba._multiteamsession import MultiTeamSession
from ba.ui import Window, UIController, uicleanupcheck
from ba._collision import Collision, getcollision
app: App
__all__ = [
'AccountV2Handle',
'Achievement',
'AchievementSubsystem',
'Activity',
'ActivityNotFoundError',
'Actor',
'ActorNotFoundError',
'animate',
'animate_array',
'app',
'App',
'AppConfig',
'AppDelegate',
'AssetPackage',
'BoolSetting',
'buttonwidget',
'Call',
'cameraflash',
'camerashake',
'Campaign',
'CelebrateMessage',
'charstr',
'checkboxwidget',
'ChoiceSetting',
'Chooser',
'clipboard_get_text',
'clipboard_has_text',
'clipboard_is_supported',
'clipboard_set_text',
'CollideModel',
'Collision',
'columnwidget',
'containerwidget',
'Context',
'ContextCall',
'ContextError',
'CloudSubsystem',
'CoopGameActivity',
'CoopSession',
'Data',
'DeathType',
'DelegateNotFoundError',
'Dependency',
'DependencyComponent',
'DependencyError',
'DependencySet',
'DieMessage',
'do_once',
'DropMessage',
'DroppedMessage',
'DualTeamSession',
'emitfx',
'EmptyPlayer',
'EmptyTeam',
'Existable',
'existing',
'FloatChoiceSetting',
'FloatSetting',
'FreeForAllSession',
'FreezeMessage',
'GameActivity',
'GameResults',
'GameTip',
'garbage_collect',
'getactivity',
'getclass',
'getcollidemodel',
'getcollision',
'getdata',
'getmaps',
'getmodel',
'getnodes',
'getsession',
'getsound',
'gettexture',
'HitMessage',
'hscrollwidget',
'imagewidget',
'ImpactDamageMessage',
'in_logic_thread',
'InputDevice',
'InputDeviceNotFoundError',
'InputType',
'IntChoiceSetting',
'IntSetting',
'is_browser_likely_available',
'is_point_in_box',
'Keyboard',
'LanguageSubsystem',
'Level',
'Lobby',
'Lstr',
'Map',
'MapNotFoundError',
'Material',
'MetadataSubsystem',
'Model',
'MultiTeamSession',
'MusicPlayer',
'MusicPlayMode',
'MusicSubsystem',
'MusicType',
'newactivity',
'newnode',
'Node',
'NodeActor',
'NodeNotFoundError',
'normalized_color',
'NotFoundError',
'open_url',
'OutOfBoundsMessage',
'Permission',
'PickedUpMessage',
'PickUpMessage',
'Player',
'PlayerDiedMessage',
'PlayerInfo',
'PlayerNotFoundError',
'PlayerRecord',
'PlayerScoredMessage',
'playsound',
'Plugin',
'PluginSubsystem',
'PotentialPlugin',
'PowerupAcceptMessage',
'PowerupMessage',
'print_error',
'print_exception',
'printnodes',
'ls_objects',
'ls_input_devices',
'pushcall',
'quit',
'rowwidget',
'safecolor',
'ScoreConfig',
'ScoreType',
'screenmessage',
'scrollwidget',
'ServerController',
'Session',
'SessionNotFoundError',
'SessionPlayer',
'SessionPlayerNotFoundError',
'SessionTeam',
'SessionTeamNotFoundError',
'set_analytics_screen',
'setmusic',
'Setting',
'ShouldShatterMessage',
'show_damage_count',
'Sound',
'SpecialChar',
'StandLocation',
'StandMessage',
'Stats',
'storagename',
'Team',
'TeamGameActivity',
'TeamNotFoundError',
'Texture',
'textwidget',
'ThawMessage',
'time',
'TimeFormat',
'Timer',
'timer',
'timestring',
'TimeType',
'uicleanupcheck',
'UIController',
'UIScale',
'UISubsystem',
'UNHANDLED',
'Vec3',
'vec3validate',
'verify_object_death',
'WeakCall',
'Widget',
'widget',
'WidgetNotFoundError',
'Window',
]
# Have these things present themselves cleanly as 'ba.Foo'
# instead of 'ba._submodule.Foo'
def _simplify_module_names() -> None:
import os
# Though pdoc gets confused when we override __module__,
# so let's make an exception for it.
if os.environ.get('BA_DOCS_GENERATION', '0') != '1':
from efro.util import set_canonical_module
globs = globals()
set_canonical_module(
module_globals=globs,
names=[n for n in globs.keys() if not n.startswith('_')],
)
_simplify_module_names()
del _simplify_module_names

View file

@ -1,267 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Account related functionality."""
from __future__ import annotations
import copy
import time
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Any, Optional
import ba
class AccountSubsystem:
"""Subsystem for account handling in the app.
Category: App Classes
Access the single shared instance of this class at 'ba.app.plugins'.
"""
def __init__(self) -> None:
self.account_tournament_list: Optional[tuple[int, list[str]]] = None
# FIXME: should abstract/structure these.
self.tournament_info: dict = {}
self.league_rank_cache: dict = {}
self.last_post_purchase_message_time: Optional[float] = None
# If we try to run promo-codes due to launch-args/etc we might
# not be signed in yet; go ahead and queue them up in that case.
self.pending_promo_codes: list[str] = []
def on_app_launch(self) -> None:
"""Called when the app is done bootstrapping."""
# Auto-sign-in to a local account in a moment if we're set to.
def do_auto_sign_in() -> None:
if _ba.app.headless_mode or _ba.app.config.get(
'Auto Account State') == 'Local':
_ba.sign_in('Local')
_ba.pushcall(do_auto_sign_in)
def on_app_resume(self) -> None:
"""Should be called when the app is resumed."""
# Mark our cached tourneys as invalid so anyone using them knows
# they might be out of date.
for entry in list(self.tournament_info.values()):
entry['valid'] = False
def handle_account_gained_tickets(self, count: int) -> None:
"""Called when the current account has been awarded tickets.
(internal)
"""
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='getTicketsWindow.receivedTicketsText',
subs=[('${COUNT}', str(count))]),
color=(0, 1, 0))
_ba.playsound(_ba.getsound('cashRegister'))
def cache_league_rank_data(self, data: Any) -> None:
"""(internal)"""
self.league_rank_cache['info'] = copy.deepcopy(data)
def get_cached_league_rank_data(self) -> Any:
"""(internal)"""
return self.league_rank_cache.get('info', None)
def get_league_rank_points(self,
data: Optional[dict[str, Any]],
subset: str = None) -> int:
"""(internal)"""
if data is None:
return 0
# If the data contains an achievement total, use that. otherwise calc
# locally.
if data['at'] is not None:
total_ach_value = data['at']
else:
total_ach_value = 0
for ach in _ba.app.ach.achievements:
if ach.complete:
total_ach_value += ach.power_ranking_value
trophies_total: int = (data['t0a'] * data['t0am'] +
data['t0b'] * data['t0bm'] +
data['t1'] * data['t1m'] +
data['t2'] * data['t2m'] +
data['t3'] * data['t3m'] +
data['t4'] * data['t4m'])
if subset == 'trophyCount':
val: int = (data['t0a'] + data['t0b'] + data['t1'] + data['t2'] +
data['t3'] + data['t4'])
assert isinstance(val, int)
return val
if subset == 'trophies':
assert isinstance(trophies_total, int)
return trophies_total
if subset is not None:
raise ValueError('invalid subset value: ' + str(subset))
if data['p']:
pro_mult = 1.0 + float(
_ba.get_account_misc_read_val('proPowerRankingBoost',
0.0)) * 0.01
else:
pro_mult = 1.0
# For final value, apply our pro mult and activeness-mult.
return int(
(total_ach_value + trophies_total) *
(data['act'] if data['act'] is not None else 1.0) * pro_mult)
def cache_tournament_info(self, info: Any) -> None:
"""(internal)"""
from ba._generated.enums import TimeType, TimeFormat
for entry in info:
cache_entry = self.tournament_info[entry['tournamentID']] = (
copy.deepcopy(entry))
# Also store the time we received this, so we can adjust
# time-remaining values/etc.
cache_entry['timeReceived'] = _ba.time(TimeType.REAL,
TimeFormat.MILLISECONDS)
cache_entry['valid'] = True
def get_purchased_icons(self) -> list[str]:
"""(internal)"""
# pylint: disable=cyclic-import
from ba import _store
if _ba.get_account_state() != 'signed_in':
return []
icons = []
store_items = _store.get_store_items()
for item_name, item in list(store_items.items()):
if item_name.startswith('icons.') and _ba.get_purchased(item_name):
icons.append(item['icon'])
return icons
def ensure_have_account_player_profile(self) -> None:
"""
Ensure the standard account-named player profile exists;
creating if needed.
(internal)
"""
# This only applies when we're signed in.
if _ba.get_account_state() != 'signed_in':
return
# If the short version of our account name currently cant be
# displayed by the game, cancel.
if not _ba.have_chars(_ba.get_account_display_string(full=False)):
return
config = _ba.app.config
if ('Player Profiles' not in config
or '__account__' not in config['Player Profiles']):
# Create a spaz with a nice default purply color.
_ba.add_transaction({
'type': 'ADD_PLAYER_PROFILE',
'name': '__account__',
'profile': {
'character': 'Spaz',
'color': [0.5, 0.25, 1.0],
'highlight': [0.5, 0.25, 1.0]
}
})
_ba.run_transactions()
def have_pro(self) -> bool:
"""Return whether pro is currently unlocked."""
# Check our tickets-based pro upgrade and our two real-IAP based
# upgrades. Also always unlock this stuff in ballistica-core builds.
return bool(
_ba.get_purchased('upgrades.pro')
or _ba.get_purchased('static.pro')
or _ba.get_purchased('static.pro_sale')
or 'ballistica' + 'core' == _ba.appname())
def have_pro_options(self) -> bool:
"""Return whether pro-options are present.
This is True for owners of Pro or for old installs
before Pro was a requirement for these options.
"""
# We expose pro options if the server tells us to
# (which is generally just when we own pro),
# or also if we've been grandfathered in or are using ballistica-core
# builds.
return self.have_pro() or bool(
_ba.get_account_misc_read_val_2('proOptionsUnlocked', False)
or _ba.app.config.get('lc14292', 0) > 1)
def show_post_purchase_message(self) -> None:
"""(internal)"""
from ba._language import Lstr
from ba._generated.enums import TimeType
cur_time = _ba.time(TimeType.REAL)
if (self.last_post_purchase_message_time is None
or cur_time - self.last_post_purchase_message_time > 3.0):
self.last_post_purchase_message_time = cur_time
with _ba.Context('ui'):
_ba.screenmessage(Lstr(resource='updatingAccountText',
fallback_resource='purchasingText'),
color=(0, 1, 0))
_ba.playsound(_ba.getsound('click01'))
def on_account_state_changed(self) -> None:
"""(internal)"""
from ba._language import Lstr
# Run any pending promo codes we had queued up while not signed in.
if _ba.get_account_state() == 'signed_in' and self.pending_promo_codes:
for code in self.pending_promo_codes:
_ba.screenmessage(Lstr(resource='submittingPromoCodeText'),
color=(0, 1, 0))
_ba.add_transaction({
'type': 'PROMO_CODE',
'expire_time': time.time() + 5,
'code': code
})
_ba.run_transactions()
self.pending_promo_codes = []
def add_pending_promo_code(self, code: str) -> None:
"""(internal)"""
from ba._language import Lstr
from ba._generated.enums import TimeType
# If we're not signed in, queue up the code to run the next time we
# are and issue a warning if we haven't signed in within the next
# few seconds.
if _ba.get_account_state() != 'signed_in':
def check_pending_codes() -> None:
"""(internal)"""
# If we're still not signed in and have pending codes,
# inform the user that they need to sign in to use them.
if self.pending_promo_codes:
_ba.screenmessage(Lstr(resource='signInForPromoCodeText'),
color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
self.pending_promo_codes.append(code)
_ba.timer(6.0, check_pending_codes, timetype=TimeType.REAL)
return
_ba.screenmessage(Lstr(resource='submittingPromoCodeText'),
color=(0, 1, 0))
_ba.add_transaction({
'type': 'PROMO_CODE',
'expire_time': time.time() + 5,
'code': code
})
_ba.run_transactions()

View file

@ -1,769 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the high level state of the app."""
from __future__ import annotations
import random
import logging
from enum import Enum
from typing import TYPE_CHECKING
from concurrent.futures import ThreadPoolExecutor
import _ba
from ba._music import MusicSubsystem
from ba._language import LanguageSubsystem
from ba._ui import UISubsystem
from ba._achievement import AchievementSubsystem
from ba._plugin import PluginSubsystem
from ba._accountv1 import AccountV1Subsystem
from ba._meta import MetadataSubsystem
from ba._ads import AdsSubsystem
from ba._net import NetworkSubsystem
from ba._workspace import WorkspaceSubsystem
from ba._appcomponent import AppComponentSubsystem
from ba import _internal
if TYPE_CHECKING:
import asyncio
from typing import Any, Callable
import efro.log
import ba
from ba._cloud import CloudSubsystem
from bastd.actor import spazappearance
from ba._accountv2 import AccountV2Subsystem
from ba._level import Level
from ba._apputils import AppHealthMonitor
class App:
"""A class for high level app functionality and state.
Category: **App Classes**
Use ba.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
# Implementations for these will be filled in by internal libs.
accounts_v2: AccountV2Subsystem
cloud: CloudSubsystem
log_handler: efro.log.LogHandler
health_monitor: AppHealthMonitor
class State(Enum):
"""High level state the app can be in."""
# The 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 ba.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 user_agent_string(self) -> str:
"""String containing various bits of info about OS/device/etc."""
assert isinstance(self._env['user_agent_string'], str)
return self._env['user_agent_string']
@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 'ba.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 python_directory_user(self) -> str:
"""Path where the app looks for custom user scripts."""
assert isinstance(self._env['python_directory_user'], str)
return self._env['python_directory_user']
@property
def python_directory_app(self) -> str:
"""Path where the app looks for its bundled scripts."""
assert isinstance(self._env['python_directory_app'], str)
return self._env['python_directory_app']
@property
def python_directory_app_site(self) -> str:
"""Path containing pip packages bundled with the app."""
assert isinstance(self._env['python_directory_app_site'], str)
return self._env['python_directory_app_site']
@property
def config(self) -> ba.AppConfig:
"""The ba.AppConfig instance representing the app's config state."""
assert self._config is not None
return self._config
@property
def platform(self) -> str:
"""Name of the current platform.
Examples are: 'mac', 'windows', android'.
"""
assert isinstance(self._env['platform'], str)
return self._env['platform']
@property
def subplatform(self) -> str:
"""String for subplatform.
Can be empty. For the 'android' platform, subplatform may
be 'google', 'amazon', etc.
"""
assert isinstance(self._env['subplatform'], str)
return self._env['subplatform']
@property
def api_version(self) -> int:
"""The game'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 backward-incompatible changes are
introduced to game APIs. When that happens, scripts should be updated
accordingly and set to target the new API version number.
"""
from ba._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']
@property
def ui_bounds(self) -> tuple[float, float, float, float]:
"""Bounds of the 'safe' screen area in ui space.
This tuple contains: (x-min, x-max, y-min, y-max)
"""
return _ba.uibounds()
def __init__(self) -> None:
"""(internal)
Do not instantiate this class; use ba.app to access
the single shared instance.
"""
# pylint: disable=too-many-statements
self.state = self.State.INITIAL
self._bootstrapping_completed = 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
# 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 = _ba.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)
self.allow_ticket_purchases: bool = not self.iircade_mode
# Default executor which can be used for misc background processing.
# It should also be passed to any asyncio loops we create so that
# everything shares the same single set of threads.
self.threadpool = ThreadPoolExecutor(thread_name_prefix='baworker')
# Misc.
self.tips: list[str] = []
self.stress_test_reset_timer: ba.Timer | None = None
self.did_weak_call_warning = False
self.log_have_new = False
self.log_upload_timer_started = False
self._config: ba.AppConfig | None = None
self.printed_live_object_warning = False
# We include this extra hash with shared input-mapping names so
# that we don't share mappings between differently-configured
# systems. For instance, different android devices may give different
# key values for the same controller type so we keep their mappings
# distinct.
self.input_map_hash: str | None = None
# Co-op Campaigns.
self.campaigns: dict[str, ba.Campaign] = {}
self.custom_coop_practice_games: list[str] = []
# Server Mode.
self.server: ba.ServerController | None = None
self.components = AppComponentSubsystem()
self.meta = MetadataSubsystem()
self.accounts_v1 = AccountV1Subsystem()
self.plugins = PluginSubsystem()
self.music = MusicSubsystem()
self.lang = LanguageSubsystem()
self.ach = AchievementSubsystem()
self.ui = UISubsystem()
self.ads = AdsSubsystem()
self.net = NetworkSubsystem()
self.workspaces = WorkspaceSubsystem()
# Lobby.
self.lobby_random_profile_index: int = 1
self.lobby_random_char_index_offset = random.randrange(1000)
self.lobby_account_profile_device_id: int | None = None
# Main Menu.
self.main_menu_did_initial_transition = False
self.main_menu_last_news_fetch_time: float | None = None
# Spaz.
self.spaz_appearances: dict[str, spazappearance.Appearance] = {}
self.last_spaz_turbo_warn_time: float = -99999.0
# Maps.
self.maps: dict[str, type[ba.Map]] = {}
# Gameplay.
self.teams_series_length = 7
self.ffa_series_length = 24
self.coop_session_args: dict = {}
self.value_test_defaults: dict = {}
self.first_main_menu = True # FIXME: Move to mainmenu class.
self.did_menu_intro = False # FIXME: Move to mainmenu class.
self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu.
self.main_menu_resume_callbacks: list = [] # Can probably go away.
self.special_offer: dict | None = None
self.ping_thread_count = 0
self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any.
self.store_layout: dict[str, list[dict[str, Any]]] | None = None
self.store_items: dict[str, dict] | None = None
self.pro_sale_start_time: int | None = None
self.pro_sale_start_val: int | None = None
self.delegate: ba.AppDelegate | None = None
self._asyncio_timer: ba.Timer | None = None
def on_app_launching(self) -> None:
"""Called when the app is first entering the launching state."""
# pylint: disable=cyclic-import
# pylint: disable=too-many-locals
from ba import _asyncio
from ba import _appconfig
from ba import _map
from ba import _campaign
from bastd import appdelegate
from bastd import maps as stdmaps
from bastd.actor import spazappearance
from ba._generated.enums import TimeType
from ba._apputils import (
log_dumped_app_state,
handle_leftover_v1_cloud_log_file,
AppHealthMonitor,
)
assert _ba.in_logic_thread()
self._aioloop = _asyncio.setup_asyncio()
self.health_monitor = AppHealthMonitor()
cfg = self.config
self.delegate = appdelegate.AppDelegate()
self.ui.on_app_launch()
spazappearance.register_appearances()
_campaign.init_campaigns()
# FIXME: This should not be hard-coded.
for maptype in [
stdmaps.HockeyStadium,
stdmaps.FootballStadium,
stdmaps.Bridgit,
stdmaps.BigG,
stdmaps.Roundabout,
stdmaps.MonkeyFace,
stdmaps.ZigZag,
stdmaps.ThePad,
stdmaps.DoomShroom,
stdmaps.LakeFrigid,
stdmaps.TipTop,
stdmaps.CragCastle,
stdmaps.TowerD,
stdmaps.HappyThoughts,
stdmaps.StepRightUp,
stdmaps.Courtyard,
stdmaps.Rampage,
]:
_map.register_map(maptype)
# Non-test, non-debug builds should generally be blessed; warn if not.
# (so I don't accidentally release a build that can't play tourneys)
if (
not self.debug_build
and not self.test_build
and not _internal.is_blessed()
):
_ba.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
# If there's a leftover log file, attempt to upload it to the
# master-server and/or get rid of it.
handle_leftover_v1_cloud_log_file()
# Only do this stuff 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.platform in ('mac', 'linux', 'windows'):
from bastd.ui.configerror import ConfigErrorWindow
_ba.pushcall(ConfigErrorWindow)
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)
self.music.on_app_launch()
launch_count = cfg.get('launchCount', 0)
launch_count += 1
# So we know how many times we've run the game at various
# version milestones.
for key in ('lc14173', 'lc14292'):
cfg.setdefault(key, launch_count)
cfg['launchCount'] = launch_count
cfg.commit()
# Run a test in a few seconds to see if we should pop up an existing
# pending special offer.
def check_special_offer() -> None:
from bastd.ui.specialoffer import show_offer
config = self.config
if (
'pendingSpecialOffer' in config
and _internal.get_public_login_id()
== config['pendingSpecialOffer']['a']
):
self.special_offer = config['pendingSpecialOffer']['o']
show_offer()
if not self.headless_mode:
_ba.timer(3.0, check_special_offer, timetype=TimeType.REAL)
# Get meta-system scanning built-in stuff in the bg.
self.meta.start_scan(scan_complete_cb=self.on_meta_scan_complete)
self.accounts_v2.on_app_launch()
self.accounts_v1.on_app_launch()
# See note below in on_app_pause.
if self.state != self.State.LAUNCHING:
logging.error(
'on_app_launch found state %s; expected LAUNCHING.', self.state
)
# If any traceback dumps happened last run, log and clear them.
log_dumped_app_state()
self._launch_completed = True
self._update_state()
def on_app_loading(self) -> None:
"""Called when initially entering the loading state."""
def on_app_running(self) -> None:
"""Called when initially entering the running state."""
self.plugins.on_app_running()
# from ba._dependency import test_depset
# test_depset()
def on_bootstrapping_completed(self) -> None:
"""Called by the C++ layer once its ready to rock."""
assert _ba.in_logic_thread()
assert not self._bootstrapping_completed
self._bootstrapping_completed = True
self._update_state()
def on_meta_scan_complete(self) -> None:
"""Called by meta-scan when it is done doing its thing."""
assert _ba.in_logic_thread()
self.plugins.on_meta_scan_complete()
assert not self._meta_scan_completed
self._meta_scan_completed = True
self._update_state()
def _update_state(self) -> None:
assert _ba.in_logic_thread()
if self._app_paused:
# Entering paused state:
if self.state is not self.State.PAUSED:
self.state = self.State.PAUSED
self.cloud.on_app_pause()
self.accounts_v1.on_app_pause()
self.plugins.on_app_pause()
self.health_monitor.on_app_pause()
else:
# Leaving paused state:
if self.state is self.State.PAUSED:
self.fg_state += 1
self.cloud.on_app_resume()
self.accounts_v1.on_app_resume()
self.music.on_app_resume()
self.plugins.on_app_resume()
self.health_monitor.on_app_resume()
# Handle initially entering or returning to other states.
if self._initial_sign_in_completed and self._meta_scan_completed:
self.state = self.State.RUNNING
if not self._called_on_app_running:
self._called_on_app_running = True
self.on_app_running()
elif self._launch_completed:
self.state = self.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._bootstrapping_completed
self.state = self.State.LAUNCHING
if not self._called_on_app_launching:
self._called_on_app_launching = True
self.on_app_launching()
def on_app_pause(self) -> None:
"""Called when the app goes to a suspended state."""
assert not self._app_paused # Should avoid redundant calls.
self._app_paused = True
self._update_state()
def on_app_resume(self) -> None:
"""Run when the app resumes from a suspended state."""
assert self._app_paused # Should avoid redundant calls.
self._app_paused = False
self._update_state()
def on_app_shutdown(self) -> None:
"""(internal)"""
self.state = self.State.SHUTTING_DOWN
self.music.on_app_shutdown()
self.plugins.on_app_shutdown()
def read_config(self) -> None:
"""(internal)"""
from ba._appconfig import read_config
self._config, self.config_file_healthy = read_config()
def pause(self) -> None:
"""Pause the game due to a user request or menu popping up.
If there's a foreground host-activity that says it's pausable, tell it
to pause ..we now no longer pause if there are connected clients.
"""
activity: ba.Activity | None = _ba.get_foreground_host_activity()
if (
activity is not None
and activity.allow_pausing
and not _ba.have_connected_clients()
):
from ba._language import Lstr
from ba._nodeactor import NodeActor
# FIXME: Shouldn't be touching scene stuff here;
# should just pass the request on to the host-session.
with _ba.Context(activity):
globs = activity.globalsnode
if not globs.paused:
_ba.playsound(_ba.getsound('refWhistle'))
globs.paused = True
# FIXME: This should not be an attr on Actor.
activity.paused_text = NodeActor(
_ba.newnode(
'text',
attrs={
'text': Lstr(resource='pausedByHostText'),
'client_only': True,
'flatness': 1.0,
'h_align': 'center',
},
)
)
def resume(self) -> None:
"""Resume the game due to a user request or menu closing.
If there's a foreground host-activity that's currently paused, tell it
to resume.
"""
# FIXME: Shouldn't be touching scene stuff here;
# should just pass the request on to the host-session.
activity = _ba.get_foreground_host_activity()
if activity is not None:
with _ba.Context(activity):
globs = activity.globalsnode
if globs.paused:
_ba.playsound(_ba.getsound('refWhistle'))
globs.paused = False
# FIXME: This should not be an actor attr.
activity.paused_text = None
def add_coop_practice_level(self, level: Level) -> None:
"""Adds an individual level to the 'practice' section in Co-op."""
# Assign this level to our catch-all campaign.
self.campaigns['Challenges'].addlevel(level)
# Make note to add it to our challenges UI.
self.custom_coop_practice_games.append(f'Challenges:{level.name}')
def return_to_main_menu_session_gracefully(
self, reset_ui: bool = True
) -> None:
"""Attempt to cleanly get back to the main menu."""
# pylint: disable=cyclic-import
from ba import _benchmark
from ba._general import Call
from bastd.mainmenu import MainMenuSession
if reset_ui:
_ba.app.ui.clear_main_menu_window()
if isinstance(_ba.get_foreground_host_session(), MainMenuSession):
# It may be possible we're on the main menu but the screen is faded
# so fade back in.
_ba.fade_screen(True)
return
_benchmark.stop_stress_test() # Stop stress-test if in progress.
# If we're in a host-session, tell them to end.
# This lets them tear themselves down gracefully.
host_session: ba.Session | None = _ba.get_foreground_host_session()
if host_session is not None:
# Kick off a little transaction so we'll hopefully have all the
# latest account state when we get back to the menu.
_internal.add_transaction(
{'type': 'END_SESSION', 'sType': str(type(host_session))}
)
_internal.run_transactions()
host_session.end()
# Otherwise just force the issue.
else:
_ba.pushcall(Call(_ba.new_host_session, MainMenuSession))
def add_main_menu_close_callback(self, call: Callable[[], Any]) -> None:
"""(internal)"""
# If there's no main menu up, just call immediately.
if not self.ui.has_main_menu_window():
with _ba.Context('ui'):
call()
else:
self.main_menu_resume_callbacks.append(call)
def launch_coop_game(
self, game: str, force: bool = False, args: dict | None = None
) -> bool:
"""High level way to launch a local co-op session."""
# pylint: disable=cyclic-import
from ba._campaign import getcampaign
from bastd.ui.coop.level import CoopLevelLockedWindow
if args is None:
args = {}
if game == '':
raise ValueError('empty game name')
campaignname, levelname = game.split(':')
campaign = getcampaign(campaignname)
# If this campaign is sequential, make sure we've completed the
# one before this.
if campaign.sequential and not force:
for level in campaign.levels:
if level.name == levelname:
break
if not level.complete:
CoopLevelLockedWindow(
campaign.getlevel(levelname).displayname,
campaign.getlevel(level.name).displayname,
)
return False
# Ok, we're good to go.
self.coop_session_args = {
'campaign': campaignname,
'level': levelname,
}
for arg_name, arg_val in list(args.items()):
self.coop_session_args[arg_name] = arg_val
def _fade_end() -> None:
from ba import _coopsession
try:
_ba.new_host_session(_coopsession.CoopSession)
except Exception:
from ba import _error
_error.print_exception()
from bastd.mainmenu import MainMenuSession
_ba.new_host_session(MainMenuSession)
_ba.fade_screen(False, endcall=_fade_end)
return True
def handle_deep_link(self, url: str) -> None:
"""Handle a deep link URL."""
from ba._language import Lstr
appname = _ba.appname()
if url.startswith(f'{appname}://code/'):
code = url.replace(f'{appname}://code/', '')
self.accounts_v1.add_pending_promo_code(code)
else:
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
def on_initial_sign_in_completed(self) -> None:
"""Callback to be run after initial sign-in (or lack thereof).
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.
"""
# Tell meta it can start scanning extra stuff that just showed up
# (account workspaces).
self.meta.start_extra_scan()
self._initial_sign_in_completed = True
self._update_state()

View file

@ -1,3 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the high level state of the app."""

View file

@ -1,192 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Bootstrapping."""
from __future__ import annotations
import os
import sys
from typing import TYPE_CHECKING
from efro.log import setup_logging, LogLevel
import _ba
if TYPE_CHECKING:
from typing import Any
from efro.log import LogEntry
_g_did_bootstrap = False # pylint: disable=invalid-name
def bootstrap() -> None:
"""Run bootstrapping logic.
This is the very first ballistica code that runs (aside from imports).
It sets up low level environment bits and creates the app instance.
"""
global _g_did_bootstrap # pylint: disable=global-statement, invalid-name
if _g_did_bootstrap:
raise RuntimeError('Bootstrap has already been called.')
_g_did_bootstrap = True
# The first thing we do is set up our logging system and feed
# Python's stdout/stderr into it. Then we can at least debug problems
# on systems where native stdout/stderr is not easily accessible
# such as Android.
log_handler = setup_logging(
log_path=None,
level=LogLevel.DEBUG,
suppress_non_root_debug=True,
log_stdout_stderr=True,
cache_size_limit=1024 * 1024,
)
log_handler.add_callback(_on_log)
env = _ba.env()
# Give a soft warning if we're being used with a different binary
# version than we expect.
expected_build = 21005
running_build: int = env['build_number']
if running_build != expected_build:
print(
f'WARNING: These script files are meant to be used with'
f' Ballistica build {expected_build}.\n'
f' You are running build {running_build}.'
f' This might cause the app to error or misbehave.',
file=sys.stderr,
)
# In bootstrap_monolithic.py we told Python not to handle SIGINT itself
# (because that must be done in the main thread). Now we finish the
# job by adding our own handler to replace it.
# 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.
_ba.setup_sigint()
# Sanity check: we should always be run in UTF-8 mode.
if sys.flags.utf8_mode != 1:
print(
'ERROR: Python\'s UTF-8 mode is not set.'
' This will likely result in errors.',
file=sys.stderr,
)
debug_build = env['debug_build']
# We expect dev_mode on in debug builds and off otherwise.
if debug_build != sys.flags.dev_mode:
print(
f'WARNING: Mismatch in debug_build {debug_build}'
f' and sys.flags.dev_mode {sys.flags.dev_mode}',
file=sys.stderr,
)
# In embedded situations (when we're providing our own Python) let's
# also provide our own root certs so ssl works. We can consider overriding
# this in particular embedded cases if we can verify that system certs
# are working.
# (We also allow forcing this via an env var if the user desires)
if (
_ba.contains_python_dist()
or os.environ.get('BA_USE_BUNDLED_ROOT_CERTS') == '1'
):
import certifi
# Let both OpenSSL and requests (if present) know to use this.
os.environ['SSL_CERT_FILE'] = os.environ[
'REQUESTS_CA_BUNDLE'
] = certifi.where()
# 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())
# 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).
del __main__.__builtins__.quit
del __main__.__builtins__.exit
# Also replace standard interactive help with our simplified
# one which is more friendly to cloud/in-game console situations.
__main__.__builtins__.help = _CustomHelper()
# Now spin up our App instance and store it on both _ba and ba.
from ba._app import App
import ba
_ba.app = ba.app = App()
_ba.app.log_handler = log_handler
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.
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)
def _on_log(entry: LogEntry) -> None:
# Just forward this along to the engine to display in the in-game console,
# in the Android log, etc.
_ba.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 ba.app.log_handler creates for us.
if entry.level.value >= LogLevel.WARNING.value or entry.name in (
'stdout',
'stderr',
):
_ba.v1_cloud_log(entry.message)

View file

@ -1,199 +0,0 @@
# 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
ALIBABA_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

View file

@ -1,523 +0,0 @@
# 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
from typing import TYPE_CHECKING
import _ba
from ba import _internal
if TYPE_CHECKING:
from typing import Sequence, Any
import ba
def finish_bootstrapping() -> None:
"""Do final bootstrapping related bits."""
assert _ba.in_logic_thread()
# Ok, low level bootstrapping is done; time to get Python stuff started.
_ba.app.on_bootstrapping_completed()
def reset_to_main_menu() -> None:
"""Reset the game to the main menu gracefully."""
_ba.app.return_to_main_menu_session_gracefully()
def set_config_fullscreen_on() -> None:
"""The app has set fullscreen on its own and we should note it."""
_ba.app.config['Fullscreen'] = True
_ba.app.config.commit()
def set_config_fullscreen_off() -> None:
"""The app has set fullscreen on its own and we should note it."""
_ba.app.config['Fullscreen'] = False
_ba.app.config.commit()
def not_signed_in_screen_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='notSignedInErrorText'))
def connecting_to_party_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.connectingToPartyText'), color=(1, 1, 1)
)
def rejecting_invite_already_in_party_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.rejectingInviteAlreadyInPartyText'),
color=(1, 0.5, 0),
)
def connection_failed_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.connectionFailedText'), color=(1, 0.5, 0)
)
def temporarily_unavailable_message() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(resource='getTicketsWindow.unavailableTemporarilyText'),
color=(1, 0, 0),
)
def in_progress_message() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(resource='getTicketsWindow.inProgressText'), color=(1, 0, 0)
)
def error_message() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(Lstr(resource='errorText'), color=(1, 0, 0))
def purchase_not_valid_error() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(
resource='store.purchaseNotValidError',
subs=[('${EMAIL}', 'support@froemling.net')],
),
color=(1, 0, 0),
)
def purchase_already_in_progress_error() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(resource='store.purchaseAlreadyInProgressText'), color=(1, 0, 0)
)
def gear_vr_controller_warning() -> None:
from ba._language import Lstr
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(resource='usesExternalControllerText'), color=(1, 0, 0)
)
def uuid_str() -> str:
import uuid
return str(uuid.uuid4())
def orientation_reset_cb_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.vrOrientationResetCardboardText'),
color=(0, 1, 0),
)
def orientation_reset_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='internal.vrOrientationResetText'), color=(0, 1, 0)
)
def on_app_pause() -> None:
_ba.app.on_app_pause()
def on_app_resume() -> None:
_ba.app.on_app_resume()
def launch_main_menu_session() -> None:
from bastd.mainmenu import MainMenuSession
_ba.new_host_session(MainMenuSession)
def language_test_toggle() -> None:
_ba.app.lang.setlanguage(
'Gibberish' if _ba.app.lang.language == 'English' else 'English'
)
def award_in_control_achievement() -> None:
_ba.app.ach.award_local_achievement('In Control')
def award_dual_wielding_achievement() -> None:
_ba.app.ach.award_local_achievement('Dual Wielding')
def play_gong_sound() -> None:
_ba.playsound(_ba.getsound('gong'))
def launch_coop_game(name: str) -> None:
_ba.app.launch_coop_game(name)
def purchases_restored_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='getTicketsWindow.purchasesRestoredText'), color=(0, 1, 0)
)
def dismiss_wii_remotes_window() -> None:
call = _ba.app.ui.dismiss_wii_remotes_window_call
if call is not None:
call()
def unavailable_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='getTicketsWindow.unavailableText'), color=(1, 0, 0)
)
def submit_analytics_counts(sval: str) -> None:
_internal.add_transaction({'type': 'ANALYTICS_COUNTS', 'values': sval})
_internal.run_transactions()
def set_last_ad_network(sval: str) -> None:
import time
_ba.app.ads.last_ad_network = sval
_ba.app.ads.last_ad_network_set_time = time.time()
def no_game_circle_message() -> None:
from ba._language import Lstr
_ba.screenmessage(Lstr(resource='noGameCircleText'), color=(1, 0, 0))
def google_play_purchases_not_available_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='googlePlayPurchasesNotAvailableText'), color=(1, 0, 0)
)
def google_play_services_not_available_message() -> None:
from ba._language import Lstr
_ba.screenmessage(
Lstr(resource='googlePlayServicesNotAvailableText'), color=(1, 0, 0)
)
def empty_call() -> None:
pass
def level_icon_press() -> None:
print('LEVEL ICON PRESSED')
def trophy_icon_press() -> None:
print('TROPHY ICON PRESSED')
def coin_icon_press() -> None:
print('COIN ICON PRESSED')
def ticket_icon_press() -> None:
from bastd.ui.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
origin_widget=_ba.get_special_widget('tickets_info_button')
)
def back_button_press() -> None:
_ba.back_press()
def friends_button_press() -> None:
print('FRIEND BUTTON PRESSED!')
def print_trace() -> None:
import traceback
print('Python Traceback (most recent call last):')
traceback.print_stack()
def toggle_fullscreen() -> None:
cfg = _ba.app.config
cfg['Fullscreen'] = not cfg.resolve('Fullscreen')
cfg.apply_and_commit()
def party_icon_activate(origin: Sequence[float]) -> None:
import weakref
from bastd.ui.party import PartyWindow
app = _ba.app
_ba.playsound(_ba.getsound('swish'))
# If it exists, dismiss it; otherwise make a new one.
if app.ui.party_window is not None and app.ui.party_window() is not None:
app.ui.party_window().close()
else:
app.ui.party_window = weakref.ref(PartyWindow(origin=origin))
def read_config() -> None:
_ba.app.read_config()
def ui_remote_press() -> None:
"""Handle a press by a remote device that is only usable for nav."""
from ba._language import Lstr
# Can be called without a context; need a context for getsound.
with _ba.Context('ui'):
_ba.screenmessage(
Lstr(resource='internal.controllerForMenusOnlyText'),
color=(1, 0, 0),
)
_ba.playsound(_ba.getsound('error'))
def quit_window() -> None:
from bastd.ui.confirm import QuitWindow
QuitWindow()
def remove_in_game_ads_message() -> None:
_ba.app.ads.do_remove_in_game_ads_message()
def telnet_access_request() -> None:
from bastd.ui.telnet import TelnetAccessRequestWindow
TelnetAccessRequestWindow()
def do_quit() -> None:
_ba.quit()
def shutdown() -> None:
_ba.app.on_app_shutdown()
def gc_disable() -> None:
import gc
gc.disable()
def device_menu_press(device: ba.InputDevice) -> None:
from bastd.ui.mainmenu import MainMenuWindow
in_main_menu = _ba.app.ui.has_main_menu_window()
if not in_main_menu:
_ba.set_ui_input_device(device)
_ba.playsound(_ba.getsound('swish'))
_ba.app.ui.set_main_menu_window(MainMenuWindow().get_root_widget())
def show_url_window(address: str) -> None:
from bastd.ui.url import ShowURLWindow
ShowURLWindow(address)
def party_invite_revoke(invite_id: str) -> None:
# If there's a confirm window up for joining this particular
# invite, kill it.
for winref in _ba.app.invite_confirm_windows:
win = winref()
if win is not None and win.ew_party_invite_id == invite_id:
_ba.containerwidget(
edit=win.get_root_widget(), transition='out_right'
)
import custom_hooks as chooks
def filter_chat_message(msg: str, client_id: int) -> str | None:
"""Intercept/filter chat messages.
Called for all chat messages while hosting.
Messages originating from the host will have clientID -1.
Should filter and return the string to be displayed, or return None
to ignore the message.
"""
return chooks.filter_chat_message(msg,client_id)
def on_client_request(ip):
chooks.on_join_request(ip)
def kick_vote_started(by:str,to:str) -> None:
"""
get account ids of who started kick vote for whom ,
do what ever u want logging to files , whatever.
"""
chooks.kick_vote_started(by,to)
def on_kicked(account_id:str) -> None:
chooks.on_kicked(account_id)
def on_kick_vote_end() -> None:
chooks.on_kick_vote_end()
def on_player_join(pb_id:str)-> None:
# not integrated yet
pass
def on_player_leave(pb_id:str)-> None:
# not integrated yet
pass
def local_chat_message(msg: str) -> None:
if (
_ba.app.ui.party_window is not None
and _ba.app.ui.party_window() is not None
):
_ba.app.ui.party_window().on_chat_message(msg)
def get_player_icon(sessionplayer: ba.SessionPlayer) -> dict[str, Any]:
info = sessionplayer.get_icon_info()
return {
'texture': _ba.gettexture(info['texture']),
'tint_texture': _ba.gettexture(info['tint_texture']),
'tint_color': info['tint_color'],
'tint2_color': info['tint2_color'],
}
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?"""
return _ba.app.accounts_v2.have_primary_credentials()
def implicit_sign_in(
login_type_str: str, login_id: str, display_name: str
) -> None:
"""An implicit login happened."""
from bacommon.login import LoginType
_ba.app.accounts_v2.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
_ba.app.accounts_v2.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 ba._login import LoginAdapterNative
login_type = LoginType(login_type_str)
attempt_id = int(attempt_id_str)
result = None if result_str == '' else result_str
with _ba.Context('ui'):
adapter = _ba.app.accounts_v2.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 ba._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 (
_ba.app.config.get('SuppressClientTooOldErrorForBuild')
== _ba.app.build_number
):
return
_ba.playsound(_ba.getsound('error'))
_ba.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),
)

View file

@ -1,381 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""A soft wrapper around _bainternal.
This allows code to use _bainternal functionality and get warnings
or fallbacks in some cases instead of hard errors. Code that absolutely
relies on the presence of _bainternal can just use that module directly.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
try:
# noinspection PyUnresolvedReferences
import _bainternal
HAVE_INTERNAL = True
except ImportError:
HAVE_INTERNAL = False
if TYPE_CHECKING:
from typing import Callable, Any
# Code that will function without _bainternal but which should be updated
# to account for its absence should call this to draw attention to itself.
def _no_bainternal_warning() -> None:
import logging
logging.warning('INTERNAL CALL RUN WITHOUT INTERNAL PRESENT.')
# Code that won't work without _bainternal should raise these errors.
def _no_bainternal_error() -> RuntimeError:
raise RuntimeError('_bainternal is not present')
def get_v2_fleet() -> str:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v2_fleet()
raise _no_bainternal_error()
def get_master_server_address(source: int = -1, version: int = 1) -> str:
"""(internal)
Return the address of the master server.
"""
if HAVE_INTERNAL:
return _bainternal.get_master_server_address(
source=source, version=version
)
raise _no_bainternal_error()
def is_blessed() -> bool:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.is_blessed()
# Harmless to always just say no here.
return False
def get_news_show() -> str:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_news_show()
raise _no_bainternal_error()
def game_service_has_leaderboard(game: str, config: str) -> bool:
"""(internal)
Given a game and config string, returns whether there is a leaderboard
for it on the game service.
"""
if HAVE_INTERNAL:
return _bainternal.game_service_has_leaderboard(
game=game, config=config
)
# Harmless to always just say no here.
return False
def report_achievement(achievement: str, pass_to_account: bool = True) -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.report_achievement(
achievement=achievement, pass_to_account=pass_to_account
)
return
# Need to see if this actually still works as expected.. warning for now.
_no_bainternal_warning()
# noinspection PyUnresolvedReferences
def submit_score(
game: str,
config: str,
name: Any,
score: int | None,
callback: Callable,
order: str = 'increasing',
tournament_id: str | None = None,
score_type: str = 'points',
campaign: str | None = None,
level: str | None = None,
) -> None:
"""(internal)
Submit a score to the server; callback will be called with the results.
As a courtesy, please don't send fake scores to the server. I'd prefer
to devote my time to improving the game instead of trying to make the
score server more mischief-proof.
"""
if HAVE_INTERNAL:
_bainternal.submit_score(
game=game,
config=config,
name=name,
score=score,
callback=callback,
order=order,
tournament_id=tournament_id,
score_type=score_type,
campaign=campaign,
level=level,
)
return
# This technically breaks since callback will never be called/etc.
raise _no_bainternal_error()
def tournament_query(
callback: Callable[[dict | None], None], args: dict
) -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.tournament_query(callback=callback, args=args)
return
# This technically breaks since callback will never be called/etc.
raise _no_bainternal_error()
def power_ranking_query(callback: Callable, season: Any = None) -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.power_ranking_query(callback=callback, season=season)
return
# This technically breaks since callback will never be called/etc.
raise _no_bainternal_error()
def restore_purchases() -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.restore_purchases()
return
# This shouldn't break anything but should try to avoid calling it.
_no_bainternal_warning()
def purchase(item: str) -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.purchase(item)
return
# This won't break messily but won't function as intended.
_no_bainternal_warning()
def get_purchases_state() -> int:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_purchases_state()
# This won't function correctly without internal.
raise _no_bainternal_error()
def get_purchased(item: str) -> bool:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_purchased(item)
# Without internal we can just assume no purchases.
return False
def get_price(item: str) -> str | None:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_price(item)
# Without internal we can just assume no prices.
return None
def in_game_purchase(item: str, price: int) -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.in_game_purchase(item=item, price=price)
return
# Without internal this doesn't function as expected.
raise _no_bainternal_error()
# noinspection PyUnresolvedReferences
def add_transaction(
transaction: dict, callback: Callable | None = None
) -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.add_transaction(transaction=transaction, callback=callback)
return
# This won't function correctly without internal (callback never called).
raise _no_bainternal_error()
def reset_achievements() -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.reset_achievements()
return
# Technically doesnt break but won't do anything.
_no_bainternal_warning()
def get_public_login_id() -> str | None:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_public_login_id()
# Harmless to return nothing in this case.
return None
def have_outstanding_transactions() -> bool:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.have_outstanding_transactions()
# Harmless to return False here.
return False
def run_transactions() -> None:
"""(internal)"""
if HAVE_INTERNAL:
_bainternal.run_transactions()
# Harmless no-op in this case.
def get_v1_account_misc_read_val(name: str, default_value: Any) -> Any:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_misc_read_val(
name=name, default_value=default_value
)
raise _no_bainternal_error()
def get_v1_account_misc_read_val_2(name: str, default_value: Any) -> Any:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_misc_read_val_2(
name=name, default_value=default_value
)
raise _no_bainternal_error()
def get_v1_account_misc_val(name: str, default_value: Any) -> Any:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_misc_val(
name=name, default_value=default_value
)
raise _no_bainternal_error()
def get_v1_account_ticket_count() -> int:
"""(internal)
Returns the number of tickets for the current account.
"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_ticket_count()
return 0
def get_v1_account_state_num() -> int:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_state_num()
return 0
def get_v1_account_state() -> str:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_state()
# Without internal present just consider ourself always signed out.
return 'signed_out'
def get_v1_account_display_string(full: bool = True) -> str:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_display_string(full=full)
raise _no_bainternal_error()
def get_v1_account_type() -> str:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_type()
raise _no_bainternal_error()
def get_v1_account_name() -> str:
"""(internal)"""
if HAVE_INTERNAL:
return _bainternal.get_v1_account_name()
raise _no_bainternal_error()
def sign_out_v1(v2_embedded: bool = False) -> None:
"""(internal)
Category: General Utility Functions
"""
if HAVE_INTERNAL:
_bainternal.sign_out_v1(v2_embedded=v2_embedded)
return
raise _no_bainternal_error()
def sign_in_v1(account_type: str) -> None:
"""(internal)
Category: General Utility Functions
"""
if HAVE_INTERNAL:
_bainternal.sign_in_v1(account_type=account_type)
return
raise _no_bainternal_error()
def mark_config_dirty() -> None:
"""(internal)
Category: General Utility Functions
"""
if HAVE_INTERNAL:
_bainternal.mark_config_dirty()
return
# Note to self - need to fix config writing to not rely on
# internal lib.
_no_bainternal_warning()

View file

@ -1,186 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Networking related functionality."""
from __future__ import annotations
import copy
import threading
import weakref
from enum import Enum
from typing import TYPE_CHECKING
import _ba
if TYPE_CHECKING:
from typing import Any, Dict, Union, Callable, Optional
import socket
import ba
ServerCallbackType = Callable[[Union[None, Dict[str, Any]]], None]
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
class ServerResponseType(Enum):
"""How to interpret responses from the server."""
JSON = 0
class ServerCallThread(threading.Thread):
"""Thread to communicate with the master server."""
def __init__(self, request: str, request_type: str,
data: Optional[Dict[str, Any]],
callback: Optional[ServerCallbackType],
response_type: ServerResponseType):
super().__init__()
self._request = request
self._request_type = request_type
if not isinstance(response_type, ServerResponseType):
raise TypeError(f'Invalid response type: {response_type}')
self._response_type = response_type
self._data = {} if data is None else copy.deepcopy(data)
self._callback: Optional[ServerCallbackType] = callback
self._context = _ba.Context('current')
# Save and restore the context we were created from.
activity = _ba.getactivity(doraise=False)
self._activity = weakref.ref(
activity) if activity is not None else None
def _run_callback(self, arg: Union[None, Dict[str, Any]]) -> None:
# If we were created in an activity context and that activity has
# since died, do nothing.
# FIXME: Should we just be using a ContextCall instead of doing
# this check manually?
if self._activity is not None:
activity = self._activity()
if activity is None or activity.expired:
return
# Technically we could do the same check for session contexts,
# but not gonna worry about it for now.
assert self._context is not None
assert self._callback is not None
with self._context:
self._callback(arg)
def run(self) -> None:
# pylint: disable=too-many-branches
import urllib.request
import urllib.error
import json
import http.client
from ba import _general
try:
self._data = _general.utf8_all(self._data)
_ba.set_thread_name('BA_ServerCallThread')
parse = urllib.parse
if self._request_type == 'get':
response = urllib.request.urlopen(
urllib.request.Request(
(_ba.get_master_server_address() + '/' +
self._request + '?' + parse.urlencode(self._data)),
None, {'User-Agent': _ba.app.user_agent_string}))
elif self._request_type == 'post':
response = urllib.request.urlopen(
urllib.request.Request(
_ba.get_master_server_address() + '/' + self._request,
parse.urlencode(self._data).encode(),
{'User-Agent': _ba.app.user_agent_string}))
else:
raise TypeError('Invalid request_type: ' + self._request_type)
# If html request failed.
if response.getcode() != 200:
response_data = None
elif self._response_type == ServerResponseType.JSON:
raw_data = response.read()
# Empty string here means something failed server side.
if raw_data == b'':
response_data = None
else:
# Json.loads requires str in python < 3.6.
raw_data_s = raw_data.decode()
response_data = json.loads(raw_data_s)
else:
raise TypeError(f'invalid responsetype: {self._response_type}')
except Exception as exc:
import errno
do_print = False
response_data = None
# Ignore common network errors; note unexpected ones.
if isinstance(
exc,
(urllib.error.URLError, ConnectionError,
http.client.IncompleteRead, http.client.BadStatusLine)):
pass
elif isinstance(exc, OSError):
if exc.errno == 10051: # Windows unreachable network error.
pass
elif exc.errno in [
errno.ETIMEDOUT, errno.EHOSTUNREACH, errno.ENETUNREACH
]:
pass
else:
do_print = True
elif (self._response_type == ServerResponseType.JSON
and isinstance(exc, json.decoder.JSONDecodeError)):
pass
else:
do_print = True
if do_print:
# Any other error here is unexpected,
# so let's make a note of it,
print(f'Error in ServerCallThread'
f' (response-type={self._response_type},'
f' response-data={response_data}):')
import traceback
traceback.print_exc()
if self._callback is not None:
_ba.pushcall(_general.Call(self._run_callback, response_data),
from_other_thread=True)
def master_server_get(
request: str,
data: Dict[str, Any],
callback: Optional[ServerCallbackType] = None,
response_type: ServerResponseType = ServerResponseType.JSON) -> None:
"""Make a call to the master server via a http GET."""
ServerCallThread(request, 'get', data, callback, response_type).start()
def master_server_post(
request: str,
data: Dict[str, Any],
callback: Optional[ServerCallbackType] = None,
response_type: ServerResponseType = ServerResponseType.JSON) -> None:
"""Make a call to the master server via a http POST."""
ServerCallThread(request, 'post', data, callback, response_type).start()

View file

@ -1,241 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Plugin related functionality."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from dataclasses import dataclass
import _ba
if TYPE_CHECKING:
import ba
class PluginSubsystem:
"""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:
self.potential_plugins: list[ba.PotentialPlugin] = []
self.active_plugins: dict[str, ba.Plugin] = {}
def on_meta_scan_complete(self) -> None:
"""Should be called when meta-scanning is complete."""
from ba._language import Lstr
plugs = _ba.app.plugins
config_changed = False
found_new = False
plugstates: dict[str, dict] = _ba.app.config.setdefault('Plugins', {})
assert isinstance(plugstates, dict)
results = _ba.app.meta.scanresults
assert results is not None
# Create a potential-plugin for each class we found in the scan.
for class_path in results.exports_of_class(Plugin):
plugs.potential_plugins.append(
PotentialPlugin(
display_name=Lstr(value=class_path),
class_path=class_path,
available=True,
)
)
if (
_ba.app.config.get(
self.AUTO_ENABLE_NEW_PLUGINS_CONFIG_KEY,
self.AUTO_ENABLE_NEW_PLUGINS_DEFAULT,
)
is True
):
if class_path not in plugstates:
# Go ahead and enable new plugins by default, but we'll
# inform the user that they need to restart to pick them up.
# they can also disable them in settings so they never load.
plugstates[class_path] = {'enabled': True}
config_changed = True
found_new = True
plugs.potential_plugins.sort(key=lambda p: p.class_path)
# Note: these days we complete meta-scan and immediately activate
# plugins, so we don't need the message about 'restart to activate'
# anymore.
if found_new and bool(False):
_ba.screenmessage(
Lstr(resource='pluginsDetectedText'), color=(0, 1, 0)
)
_ba.playsound(_ba.getsound('ding'))
if config_changed:
_ba.app.config.commit()
def on_app_running(self) -> None:
"""Should be called when the app reaches the running state."""
# Load up our plugins and go ahead and call their on_app_running calls.
self.load_plugins()
for plugin in self.active_plugins.values():
try:
plugin.on_app_running()
except Exception:
from ba import _error
_error.print_exception('Error in plugin on_app_running()')
def on_app_pause(self) -> None:
"""Called when the app goes to a suspended state."""
for plugin in self.active_plugins.values():
try:
plugin.on_app_pause()
except Exception:
from ba import _error
_error.print_exception('Error in plugin on_app_pause()')
def on_app_resume(self) -> None:
"""Run when the app resumes from a suspended state."""
for plugin in self.active_plugins.values():
try:
plugin.on_app_resume()
except Exception:
from ba import _error
_error.print_exception('Error in plugin on_app_resume()')
def on_app_shutdown(self) -> None:
"""Called when the app is being closed."""
for plugin in self.active_plugins.values():
try:
plugin.on_app_shutdown()
except Exception:
from ba import _error
_error.print_exception('Error in plugin on_app_shutdown()')
def load_plugins(self) -> None:
"""(internal)"""
from ba._general import getclass
from ba._language import Lstr
# Note: the plugins we load is purely based on what's enabled
# in the app config. Its not our job to look at meta stuff here.
plugstates: dict[str, dict] = _ba.app.config.get('Plugins', {})
assert isinstance(plugstates, dict)
plugkeys: list[str] = sorted(
key for key, val in plugstates.items() if val.get('enabled', False)
)
disappeared_plugs: set[str] = set()
for plugkey in plugkeys:
try:
cls = getclass(plugkey, Plugin)
except ModuleNotFoundError:
disappeared_plugs.add(plugkey)
continue
except Exception as exc:
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(
resource='pluginClassLoadErrorText',
subs=[('${PLUGIN}', plugkey), ('${ERROR}', str(exc))],
),
color=(1, 0, 0),
)
logging.exception("Error loading plugin class '%s'", plugkey)
continue
try:
plugin = cls()
assert plugkey not in self.active_plugins
self.active_plugins[plugkey] = plugin
except Exception as exc:
from ba import _error
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(
resource='pluginInitErrorText',
subs=[('${PLUGIN}', plugkey), ('${ERROR}', str(exc))],
),
color=(1, 0, 0),
)
_error.print_exception(f"Error initing plugin: '{plugkey}'.")
# 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:
_ba.playsound(_ba.getsound('shieldDown'))
_ba.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 _ba.app.config['Plugins'][goneplug]
_ba.app.config.commit()
@dataclass
class PotentialPlugin:
"""Represents a ba.Plugin which can potentially be loaded.
Category: **App Classes**
These generally represent plugins which were detected by the
meta-tag scan. However they may also represent plugins which
were previously set to be loaded but which were unable to be
for some reason. In that case, 'available' will be set to False.
"""
display_name: ba.Lstr
class_path: str
available: bool
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: ba.Widget | None) -> None:
"""Called to show our settings UI."""

View file

@ -1,498 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Store related functionality for classic mode."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba import _internal
if TYPE_CHECKING:
from typing import Any
import ba
def get_store_item(item: str) -> dict[str, Any]:
"""(internal)"""
return get_store_items()[item]
def get_store_item_name_translated(item_name: str) -> ba.Lstr:
"""Return a ba.Lstr for a store item name."""
# pylint: disable=cyclic-import
from ba import _language
from ba import _map
item_info = get_store_item(item_name)
if item_name.startswith('characters.'):
return _language.Lstr(
translate=('characterNames', item_info['character'])
)
if item_name in ['merch']:
return _language.Lstr(resource='merchText')
if item_name in ['upgrades.pro', 'pro']:
return _language.Lstr(
resource='store.bombSquadProNameText',
subs=[('${APP_NAME}', _language.Lstr(resource='titleText'))],
)
if item_name.startswith('maps.'):
map_type: type[ba.Map] = item_info['map_type']
return _map.get_map_display_string(map_type.name)
if item_name.startswith('games.'):
gametype: type[ba.GameActivity] = item_info['gametype']
return gametype.get_display_string()
if item_name.startswith('icons.'):
return _language.Lstr(resource='editProfileWindow.iconText')
raise ValueError('unrecognized item: ' + item_name)
def get_store_item_display_size(item_name: str) -> tuple[float, float]:
"""(internal)"""
if item_name.startswith('characters.'):
return 340 * 0.6, 430 * 0.6
if item_name in ['pro', 'upgrades.pro', 'merch']:
from ba._generated.enums import UIScale
return 650 * 0.9, 500 * (
0.72
if (
_ba.app.config.get('Merch Link')
and _ba.app.ui.uiscale is UIScale.SMALL
)
else 0.85
)
if item_name.startswith('maps.'):
return 510 * 0.6, 450 * 0.6
if item_name.startswith('icons.'):
return 265 * 0.6, 250 * 0.6
return 450 * 0.6, 450 * 0.6
def get_store_items() -> dict[str, dict]:
"""Returns info about purchasable items.
(internal)
"""
# pylint: disable=cyclic-import
from ba._generated.enums import SpecialChar
from bastd import maps
if _ba.app.store_items is None:
from bastd.game import ninjafight
from bastd.game import meteorshower
from bastd.game import targetpractice
from bastd.game import easteregghunt
# IMPORTANT - need to keep this synced with the master server.
# (doing so manually for now)
_ba.app.store_items = {
'characters.kronk': {'character': 'Kronk'},
'characters.zoe': {'character': 'Zoe'},
'characters.jackmorgan': {'character': 'Jack Morgan'},
'characters.mel': {'character': 'Mel'},
'characters.snakeshadow': {'character': 'Snake Shadow'},
'characters.bones': {'character': 'Bones'},
'characters.bernard': {
'character': 'Bernard',
'highlight': (0.6, 0.5, 0.8),
},
'characters.pixie': {'character': 'Pixel'},
'characters.wizard': {'character': 'Grumbledorf'},
'characters.frosty': {'character': 'Frosty'},
'characters.pascal': {'character': 'Pascal'},
'characters.cyborg': {'character': 'B-9000'},
'characters.agent': {'character': 'Agent Johnson'},
'characters.taobaomascot': {'character': 'Taobao Mascot'},
'characters.santa': {'character': 'Santa Claus'},
'characters.bunny': {'character': 'Easter Bunny'},
'merch': {},
'pro': {},
'maps.lake_frigid': {'map_type': maps.LakeFrigid},
'games.ninja_fight': {
'gametype': ninjafight.NinjaFightGame,
'previewTex': 'courtyardPreview',
},
'games.meteor_shower': {
'gametype': meteorshower.MeteorShowerGame,
'previewTex': 'rampagePreview',
},
'games.target_practice': {
'gametype': targetpractice.TargetPracticeGame,
'previewTex': 'doomShroomPreview',
},
'games.easter_egg_hunt': {
'gametype': easteregghunt.EasterEggHuntGame,
'previewTex': 'towerDPreview',
},
'icons.flag_us': {
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_STATES)
},
'icons.flag_mexico': {'icon': _ba.charstr(SpecialChar.FLAG_MEXICO)},
'icons.flag_germany': {
'icon': _ba.charstr(SpecialChar.FLAG_GERMANY)
},
'icons.flag_brazil': {'icon': _ba.charstr(SpecialChar.FLAG_BRAZIL)},
'icons.flag_russia': {'icon': _ba.charstr(SpecialChar.FLAG_RUSSIA)},
'icons.flag_china': {'icon': _ba.charstr(SpecialChar.FLAG_CHINA)},
'icons.flag_uk': {
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_KINGDOM)
},
'icons.flag_canada': {'icon': _ba.charstr(SpecialChar.FLAG_CANADA)},
'icons.flag_india': {'icon': _ba.charstr(SpecialChar.FLAG_INDIA)},
'icons.flag_japan': {'icon': _ba.charstr(SpecialChar.FLAG_JAPAN)},
'icons.flag_france': {'icon': _ba.charstr(SpecialChar.FLAG_FRANCE)},
'icons.flag_indonesia': {
'icon': _ba.charstr(SpecialChar.FLAG_INDONESIA)
},
'icons.flag_italy': {'icon': _ba.charstr(SpecialChar.FLAG_ITALY)},
'icons.flag_south_korea': {
'icon': _ba.charstr(SpecialChar.FLAG_SOUTH_KOREA)
},
'icons.flag_netherlands': {
'icon': _ba.charstr(SpecialChar.FLAG_NETHERLANDS)
},
'icons.flag_uae': {
'icon': _ba.charstr(SpecialChar.FLAG_UNITED_ARAB_EMIRATES)
},
'icons.flag_qatar': {'icon': _ba.charstr(SpecialChar.FLAG_QATAR)},
'icons.flag_egypt': {'icon': _ba.charstr(SpecialChar.FLAG_EGYPT)},
'icons.flag_kuwait': {'icon': _ba.charstr(SpecialChar.FLAG_KUWAIT)},
'icons.flag_algeria': {
'icon': _ba.charstr(SpecialChar.FLAG_ALGERIA)
},
'icons.flag_saudi_arabia': {
'icon': _ba.charstr(SpecialChar.FLAG_SAUDI_ARABIA)
},
'icons.flag_malaysia': {
'icon': _ba.charstr(SpecialChar.FLAG_MALAYSIA)
},
'icons.flag_czech_republic': {
'icon': _ba.charstr(SpecialChar.FLAG_CZECH_REPUBLIC)
},
'icons.flag_australia': {
'icon': _ba.charstr(SpecialChar.FLAG_AUSTRALIA)
},
'icons.flag_singapore': {
'icon': _ba.charstr(SpecialChar.FLAG_SINGAPORE)
},
'icons.flag_iran': {'icon': _ba.charstr(SpecialChar.FLAG_IRAN)},
'icons.flag_poland': {'icon': _ba.charstr(SpecialChar.FLAG_POLAND)},
'icons.flag_argentina': {
'icon': _ba.charstr(SpecialChar.FLAG_ARGENTINA)
},
'icons.flag_philippines': {
'icon': _ba.charstr(SpecialChar.FLAG_PHILIPPINES)
},
'icons.flag_chile': {'icon': _ba.charstr(SpecialChar.FLAG_CHILE)},
'icons.fedora': {'icon': _ba.charstr(SpecialChar.FEDORA)},
'icons.hal': {'icon': _ba.charstr(SpecialChar.HAL)},
'icons.crown': {'icon': _ba.charstr(SpecialChar.CROWN)},
'icons.yinyang': {'icon': _ba.charstr(SpecialChar.YIN_YANG)},
'icons.eyeball': {'icon': _ba.charstr(SpecialChar.EYE_BALL)},
'icons.skull': {'icon': _ba.charstr(SpecialChar.SKULL)},
'icons.heart': {'icon': _ba.charstr(SpecialChar.HEART)},
'icons.dragon': {'icon': _ba.charstr(SpecialChar.DRAGON)},
'icons.helmet': {'icon': _ba.charstr(SpecialChar.HELMET)},
'icons.mushroom': {'icon': _ba.charstr(SpecialChar.MUSHROOM)},
'icons.ninja_star': {'icon': _ba.charstr(SpecialChar.NINJA_STAR)},
'icons.viking_helmet': {
'icon': _ba.charstr(SpecialChar.VIKING_HELMET)
},
'icons.moon': {'icon': _ba.charstr(SpecialChar.MOON)},
'icons.spider': {'icon': _ba.charstr(SpecialChar.SPIDER)},
'icons.fireball': {'icon': _ba.charstr(SpecialChar.FIREBALL)},
'icons.mikirog': {'icon': _ba.charstr(SpecialChar.MIKIROG)},
}
return _ba.app.store_items
def get_store_layout() -> dict[str, list[dict[str, Any]]]:
"""Return what's available in the store at a given time.
Categorized by tab and by section."""
if _ba.app.store_layout is None:
_ba.app.store_layout = {
'characters': [{'items': []}],
'extras': [{'items': ['pro']}],
'maps': [{'items': ['maps.lake_frigid']}],
'minigames': [],
'icons': [
{
'items': [
'icons.mushroom',
'icons.heart',
'icons.eyeball',
'icons.yinyang',
'icons.hal',
'icons.flag_us',
'icons.flag_mexico',
'icons.flag_germany',
'icons.flag_brazil',
'icons.flag_russia',
'icons.flag_china',
'icons.flag_uk',
'icons.flag_canada',
'icons.flag_india',
'icons.flag_japan',
'icons.flag_france',
'icons.flag_indonesia',
'icons.flag_italy',
'icons.flag_south_korea',
'icons.flag_netherlands',
'icons.flag_uae',
'icons.flag_qatar',
'icons.flag_egypt',
'icons.flag_kuwait',
'icons.flag_algeria',
'icons.flag_saudi_arabia',
'icons.flag_malaysia',
'icons.flag_czech_republic',
'icons.flag_australia',
'icons.flag_singapore',
'icons.flag_iran',
'icons.flag_poland',
'icons.flag_argentina',
'icons.flag_philippines',
'icons.flag_chile',
'icons.moon',
'icons.fedora',
'icons.spider',
'icons.ninja_star',
'icons.skull',
'icons.dragon',
'icons.viking_helmet',
'icons.fireball',
'icons.helmet',
'icons.crown',
]
}
],
}
store_layout = _ba.app.store_layout
store_layout['characters'] = [
{
'items': [
'characters.kronk',
'characters.zoe',
'characters.jackmorgan',
'characters.mel',
'characters.snakeshadow',
'characters.bones',
'characters.bernard',
'characters.agent',
'characters.frosty',
'characters.pascal',
'characters.pixie',
]
}
]
store_layout['minigames'] = [
{
'items': [
'games.ninja_fight',
'games.meteor_shower',
'games.target_practice',
]
}
]
if _internal.get_v1_account_misc_read_val('xmas', False):
store_layout['characters'][0]['items'].append('characters.santa')
store_layout['characters'][0]['items'].append('characters.wizard')
store_layout['characters'][0]['items'].append('characters.cyborg')
if _internal.get_v1_account_misc_read_val('easter', False):
store_layout['characters'].append(
{'title': 'store.holidaySpecialText', 'items': ['characters.bunny']}
)
store_layout['minigames'].append(
{
'title': 'store.holidaySpecialText',
'items': ['games.easter_egg_hunt'],
}
)
# This will cause merch to show only if the master-server has
# given us a link (which means merch is available in our region).
store_layout['extras'] = [{'items': ['pro']}]
if _ba.app.config.get('Merch Link'):
store_layout['extras'][0]['items'].append('merch')
return store_layout
def get_clean_price(price_string: str) -> str:
"""(internal)"""
# I'm not brave enough to try and do any numerical
# manipulation on formatted price strings, but lets do a
# few swap-outs to tidy things up a bit.
psubs = {
'$2.99': '$3.00',
'$4.99': '$5.00',
'$9.99': '$10.00',
'$19.99': '$20.00',
'$49.99': '$50.00',
}
return psubs.get(price_string, price_string)
def get_available_purchase_count(tab: str | None = None) -> int:
"""(internal)"""
try:
if _internal.get_v1_account_state() != 'signed_in':
return 0
count = 0
our_tickets = _internal.get_v1_account_ticket_count()
store_data = get_store_layout()
if tab is not None:
tabs = [(tab, store_data[tab])]
else:
tabs = list(store_data.items())
for tab_name, tabval in tabs:
if tab_name == 'icons':
continue # too many of these; don't show..
count = _calc_count_for_tab(tabval, our_tickets, count)
return count
except Exception:
from ba import _error
_error.print_exception('error calcing available purchases')
return 0
def _calc_count_for_tab(
tabval: list[dict[str, Any]], our_tickets: int, count: int
) -> int:
for section in tabval:
for item in section['items']:
ticket_cost = _internal.get_v1_account_misc_read_val(
'price.' + item, None
)
if ticket_cost is not None:
if our_tickets >= ticket_cost and not _internal.get_purchased(
item
):
count += 1
return count
def get_available_sale_time(tab: str) -> int | None:
"""(internal)"""
# pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks
# pylint: disable=too-many-locals
try:
import datetime
from ba._generated.enums import TimeType, TimeFormat
app = _ba.app
sale_times: list[int | None] = []
# Calc time for our pro sale (old special case).
if tab == 'extras':
config = app.config
if app.accounts_v1.have_pro():
return None
# If we haven't calced/loaded start times yet.
if app.pro_sale_start_time is None:
# If we've got a time-remaining in our config, start there.
if 'PSTR' in config:
app.pro_sale_start_time = int(
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)
)
app.pro_sale_start_val = config['PSTR']
else:
# We start the timer once we get the duration from
# the server.
start_duration = _internal.get_v1_account_misc_read_val(
'proSaleDurationMinutes', None
)
if start_duration is not None:
app.pro_sale_start_time = int(
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)
)
app.pro_sale_start_val = 60000 * start_duration
# If we haven't heard from the server yet, no sale..
else:
return None
assert app.pro_sale_start_val is not None
val: int | None = max(
0,
app.pro_sale_start_val
- (
_ba.time(TimeType.REAL, TimeFormat.MILLISECONDS)
- app.pro_sale_start_time
),
)
# Keep the value in the config up to date. I suppose we should
# write the config occasionally but it should happen often enough
# for other reasons.
config['PSTR'] = val
if val == 0:
val = None
sale_times.append(val)
# Now look for sales in this tab.
sales_raw = _internal.get_v1_account_misc_read_val('sales', {})
store_layout = get_store_layout()
for section in store_layout[tab]:
for item in section['items']:
if item in sales_raw:
if not _internal.get_purchased(item):
to_end = (
datetime.datetime.utcfromtimestamp(
sales_raw[item]['e']
)
- datetime.datetime.utcnow()
).total_seconds()
if to_end > 0:
sale_times.append(int(to_end * 1000))
# Return the smallest time I guess?
sale_times_int = [t for t in sale_times if isinstance(t, int)]
return min(sale_times_int) if sale_times_int else None
except Exception:
from ba import _error
_error.print_exception('error calcing sale time')
return None
def get_unowned_maps() -> list[str]:
"""Return the list of local maps not owned by the current account.
Category: **Asset Functions**
"""
unowned_maps: set[str] = set()
if not _ba.app.headless_mode:
for map_section in get_store_layout()['maps']:
for mapitem in map_section['items']:
if not _internal.get_purchased(mapitem):
m_info = get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps)
def get_unowned_game_types() -> set[type[ba.GameActivity]]:
"""Return present game types not owned by the current account."""
try:
unowned_games: set[type[ba.GameActivity]] = set()
if not _ba.app.headless_mode:
for section in get_store_layout()['minigames']:
for mname in section['items']:
if not _internal.get_purchased(mname):
m_info = get_store_item(mname)
unowned_games.add(m_info['gametype'])
return unowned_games
except Exception:
from ba import _error
_error.print_exception('error calcing un-owned games')
return set()

View file

@ -1,93 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to the cloud."""
from __future__ import annotations
from typing import TYPE_CHECKING, overload
import _ba
if TYPE_CHECKING:
from typing import Union, Callable, Any
from efro.message import Message
import bacommon.cloud
# 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:
"""Used for communicating with the cloud."""
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
@overload
def send_message(
self,
msg: bacommon.cloud.LoginProxyRequestMessage,
on_response: Callable[
[Union[bacommon.cloud.LoginProxyRequestResponse,
Exception]], None],
) -> None:
...
@overload
def send_message(
self,
msg: bacommon.cloud.LoginProxyStateQueryMessage,
on_response: Callable[
[Union[bacommon.cloud.LoginProxyStateQueryResponse,
Exception]], None],
) -> None:
...
@overload
def send_message(
self,
msg: bacommon.cloud.LoginProxyCompleteMessage,
on_response: Callable[[Union[None, Exception]], None],
) -> None:
...
@overload
def send_message(
self,
msg: bacommon.cloud.CredentialsCheckMessage,
on_response: Callable[
[Union[bacommon.cloud.CredentialsCheckResponse, Exception]], None],
) -> None:
...
@overload
def send_message(
self,
msg: bacommon.cloud.AccountSessionReleaseMessage,
on_response: Callable[[Union[None, Exception]], None],
) -> None:
...
def send_message(
self,
msg: Message,
on_response: Callable[[Any], None],
) -> None:
"""Asynchronously send a message to the cloud from the game thread.
The provided on_response call will be run in the logic thread
and passed either the response or the error that occurred.
"""
from ba._general import Call
del msg # Unused.
_ba.pushcall(
Call(on_response,
RuntimeError('Cloud functionality is not available.')))

View file

@ -1,8 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Deprecated functionality.
Classes or functions can be relocated here when they are deprecated.
Any code using them should migrate to alternative methods, as
deprecated items will eventually be fully removed.
"""

View file

@ -1,339 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Exposed functionality not intended for full public use.
Classes and functions contained here, while technically 'public', may change
or disappear without warning, so should be avoided (or used sparingly and
defensively) in mods.
"""
from __future__ import annotations
from _ba import (
show_online_score_ui,
set_ui_input_device,
is_party_icon_visible,
getinputdevice,
add_clean_frame_callback,
unlock_all_input,
increment_analytics_count,
set_debug_speed_exponent,
get_special_widget,
get_qrcode_texture,
get_string_height,
get_string_width,
show_app_invite,
appnameupper,
lock_all_input,
open_file_externally,
fade_screen,
appname,
have_incentivized_ad,
has_video_ads,
workspaces_in_use,
set_party_icon_always_visible,
connect_to_party,
get_game_port,
end_host_scanning,
host_scan_cycle,
charstr,
get_public_party_enabled,
get_public_party_max_size,
set_public_party_name,
set_public_party_max_size,
set_public_party_queue_enabled,
set_authenticate_clients,
set_public_party_enabled,
reset_random_player_names,
new_host_session,
get_foreground_host_session,
get_local_active_input_devices_count,
get_ui_input_device,
is_in_replay,
set_replay_speed_exponent,
get_replay_speed_exponent,
disconnect_from_host,
set_party_window_open,
get_connection_to_host_info,
get_chat_messages,
get_game_roster,
disconnect_client,
chatmessage,
get_random_names,
have_permission,
request_permission,
have_touchscreen_input,
is_xcode_build,
set_low_level_config_value,
get_low_level_config_value,
capture_gamepad_input,
release_gamepad_input,
has_gamma_control,
get_max_graphics_quality,
get_display_resolution,
capture_keyboard_input,
release_keyboard_input,
value_test,
set_touchscreen_editing,
is_running_on_fire_tv,
android_get_external_files_dir,
set_telnet_access_enabled,
new_replay_session,
get_replays_dir,
)
from ba._login import LoginAdapter
from ba._map import (
get_map_class,
register_map,
preload_map_preview_media,
get_map_display_string,
get_filtered_map_name,
)
from ba._appconfig import commit_app_config
from ba._input import (
get_device_value,
get_input_map_hash,
get_input_device_config,
)
from ba._general import getclass, json_prep, get_type_name
from ba._activitytypes import JoinActivity, ScoreScreenActivity
from ba._apputils import (
is_browser_likely_available,
get_remote_app_name,
should_submit_debug_info,
dump_app_state,
log_dumped_app_state,
)
from ba._benchmark import (
run_gpu_benchmark,
run_cpu_benchmark,
run_media_reload_benchmark,
run_stress_test,
)
from ba._campaign import getcampaign
from ba._messages import PlayerProfilesChangedMessage
from ba._multiteamsession import DEFAULT_TEAM_COLORS, DEFAULT_TEAM_NAMES
from ba._music import do_play_music
from ba._net import (
master_server_get,
master_server_post,
get_ip_address_type,
DEFAULT_REQUEST_TIMEOUT_SECONDS,
)
from ba._powerup import get_default_powerup_distribution
from ba._profile import (
get_player_profile_colors,
get_player_profile_icon,
get_player_colors,
)
from ba._tips import get_next_tip
from ba._playlist import (
get_default_free_for_all_playlist,
get_default_teams_playlist,
filter_playlist,
)
from ba._store import (
get_available_sale_time,
get_available_purchase_count,
get_store_item_name_translated,
get_store_item_display_size,
get_store_layout,
get_store_item,
get_clean_price,
get_unowned_maps,
get_unowned_game_types,
)
from ba._tournament import get_tournament_prize_strings
from ba._gameutils import get_trophy_string
from ba._internal import (
get_v2_fleet,
get_master_server_address,
is_blessed,
get_news_show,
game_service_has_leaderboard,
report_achievement,
submit_score,
tournament_query,
power_ranking_query,
restore_purchases,
purchase,
get_purchases_state,
get_purchased,
get_price,
in_game_purchase,
add_transaction,
reset_achievements,
get_public_login_id,
have_outstanding_transactions,
run_transactions,
get_v1_account_misc_read_val,
get_v1_account_misc_read_val_2,
get_v1_account_misc_val,
get_v1_account_ticket_count,
get_v1_account_state_num,
get_v1_account_state,
get_v1_account_display_string,
get_v1_account_type,
get_v1_account_name,
sign_out_v1,
sign_in_v1,
mark_config_dirty,
)
__all__ = [
'LoginAdapter',
'show_online_score_ui',
'set_ui_input_device',
'is_party_icon_visible',
'getinputdevice',
'add_clean_frame_callback',
'unlock_all_input',
'increment_analytics_count',
'set_debug_speed_exponent',
'get_special_widget',
'get_qrcode_texture',
'get_string_height',
'get_string_width',
'show_app_invite',
'appnameupper',
'lock_all_input',
'open_file_externally',
'fade_screen',
'appname',
'have_incentivized_ad',
'has_video_ads',
'workspaces_in_use',
'set_party_icon_always_visible',
'connect_to_party',
'get_game_port',
'end_host_scanning',
'host_scan_cycle',
'charstr',
'get_public_party_enabled',
'get_public_party_max_size',
'set_public_party_name',
'set_public_party_max_size',
'set_public_party_queue_enabled',
'set_authenticate_clients',
'set_public_party_enabled',
'reset_random_player_names',
'new_host_session',
'get_foreground_host_session',
'get_local_active_input_devices_count',
'get_ui_input_device',
'is_in_replay',
'set_replay_speed_exponent',
'get_replay_speed_exponent',
'disconnect_from_host',
'set_party_window_open',
'get_connection_to_host_info',
'get_chat_messages',
'get_game_roster',
'disconnect_client',
'chatmessage',
'get_random_names',
'have_permission',
'request_permission',
'have_touchscreen_input',
'is_xcode_build',
'set_low_level_config_value',
'get_low_level_config_value',
'capture_gamepad_input',
'release_gamepad_input',
'has_gamma_control',
'get_max_graphics_quality',
'get_display_resolution',
'capture_keyboard_input',
'release_keyboard_input',
'value_test',
'set_touchscreen_editing',
'is_running_on_fire_tv',
'android_get_external_files_dir',
'set_telnet_access_enabled',
'new_replay_session',
'get_replays_dir',
'get_unowned_maps',
'get_unowned_game_types',
'get_map_class',
'register_map',
'preload_map_preview_media',
'get_map_display_string',
'get_filtered_map_name',
'commit_app_config',
'get_device_value',
'get_input_map_hash',
'get_input_device_config',
'getclass',
'json_prep',
'get_type_name',
'JoinActivity',
'ScoreScreenActivity',
'is_browser_likely_available',
'get_remote_app_name',
'should_submit_debug_info',
'run_gpu_benchmark',
'run_cpu_benchmark',
'run_media_reload_benchmark',
'run_stress_test',
'getcampaign',
'PlayerProfilesChangedMessage',
'DEFAULT_TEAM_COLORS',
'DEFAULT_TEAM_NAMES',
'do_play_music',
'master_server_get',
'master_server_post',
'get_ip_address_type',
'DEFAULT_REQUEST_TIMEOUT_SECONDS',
'get_default_powerup_distribution',
'get_player_profile_colors',
'get_player_profile_icon',
'get_player_colors',
'get_next_tip',
'get_default_free_for_all_playlist',
'get_default_teams_playlist',
'filter_playlist',
'get_available_sale_time',
'get_available_purchase_count',
'get_store_item_name_translated',
'get_store_item_display_size',
'get_store_layout',
'get_store_item',
'get_clean_price',
'get_tournament_prize_strings',
'get_trophy_string',
'get_v2_fleet',
'get_master_server_address',
'is_blessed',
'get_news_show',
'game_service_has_leaderboard',
'report_achievement',
'submit_score',
'tournament_query',
'power_ranking_query',
'restore_purchases',
'purchase',
'get_purchases_state',
'get_purchased',
'get_price',
'in_game_purchase',
'add_transaction',
'reset_achievements',
'get_public_login_id',
'have_outstanding_transactions',
'run_transactions',
'get_v1_account_misc_read_val',
'get_v1_account_misc_read_val_2',
'get_v1_account_misc_val',
'get_v1_account_ticket_count',
'get_v1_account_state_num',
'get_v1_account_state',
'get_v1_account_display_string',
'get_v1_account_type',
'get_v1_account_name',
'sign_out_v1',
'sign_in_v1',
'mark_config_dirty',
'dump_app_state',
'log_dumped_app_state',
]

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

View file

@ -11,12 +11,12 @@ from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.error import CommunicationError
from bacommon.login import LoginType
import _ba
import _babase
if TYPE_CHECKING:
from typing import Any
from ba._login import LoginAdapter
from babase._login import LoginAdapter
DEBUG_LOG = False
@ -27,11 +27,10 @@ class AccountV2Subsystem:
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.accounts_v2'.
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
@ -46,16 +45,22 @@ class AccountV2Subsystem:
self._implicit_state_changed = False
self._can_do_auto_sign_in = True
if _ba.app.platform == 'android' and _ba.app.subplatform == 'google':
from ba._login import LoginAdapterGPGS
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_launch(self) -> None:
"""Should be called at standard on_app_launch time."""
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_launch()
adapter.on_app_loading()
def set_primary_credentials(self, credentials: str | None) -> None:
"""Set credentials for the primary app account."""
@ -87,7 +92,7 @@ class AccountV2Subsystem:
Will be called with None on log-outs and when new credentials
are set but have not yet been verified.
"""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
# Currently don't do anything special on sign-outs.
if account is None:
@ -102,7 +107,7 @@ class AccountV2Subsystem:
and not self._kicked_off_workspace_load
):
self._kicked_off_workspace_load = True
_ba.app.workspaces.set_active_workspace(
_babase.app.workspaces.set_active_workspace(
account=account,
workspaceid=account.workspaceid,
workspacename=account.workspacename,
@ -112,18 +117,18 @@ class AccountV2Subsystem:
# 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.
_ba.screenmessage(
_babase.screenmessage(
f'\'{account.workspacename}\''
f' will be activated at next app launch.',
color=(1, 1, 0),
)
_ba.playsound(_ba.getsound('error'))
_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
_ba.app.on_initial_sign_in_completed()
_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."""
@ -135,9 +140,9 @@ class AccountV2Subsystem:
self, login_type: LoginType, login_id: str, display_name: str
) -> None:
"""An implicit sign-in happened (called by native layer)."""
from ba._login import LoginAdapter
from babase._login import LoginAdapter
with _ba.Context('ui'):
with _babase.ContextRef.empty():
self.login_adapters[login_type].set_implicit_login_state(
LoginAdapter.ImplicitLoginState(
login_id=login_id, display_name=display_name
@ -146,7 +151,7 @@ class AccountV2Subsystem:
def on_implicit_sign_out(self, login_type: LoginType) -> None:
"""An implicit sign-out happened (called by native layer)."""
with _ba.Context('ui'):
with _babase.ContextRef.empty():
self.login_adapters[login_type].set_implicit_login_state(None)
def on_no_initial_primary_account(self) -> None:
@ -158,7 +163,7 @@ class AccountV2Subsystem:
"""
if not self._initial_sign_in_completed:
self._initial_sign_in_completed = True
_ba.app.on_initial_sign_in_completed()
_babase.app.on_initial_sign_in_completed()
@staticmethod
def _hashstr(val: str) -> str:
@ -179,13 +184,13 @@ class AccountV2Subsystem:
types even if the default implicit one can't be explicitly
logged out or otherwise controlled.
"""
from ba._language import Lstr
from babase._language import Lstr
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
cfg = _ba.app.config
cfg = _babase.app.config
cfgkey = 'ImplicitLoginStates'
cfgdict = _ba.app.config.setdefault(cfgkey, {})
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
@ -213,10 +218,10 @@ class AccountV2Subsystem:
else:
service_str = None
if service_str is not None:
_ba.timer(
_babase.apptimer(
2.0,
tpartial(
_ba.screenmessage,
_babase.screenmessage,
Lstr(
resource='notUsingAccountText',
subs=[
@ -249,13 +254,14 @@ class AccountV2Subsystem:
def on_cloud_connectivity_changed(self, connected: bool) -> None:
"""Should be called with cloud connectivity changes."""
del connected # Unused.
assert _ba.in_logic_thread()
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:
from ba._internal import get_v1_account_state
plus = _babase.app.plus
assert plus is not None
# If implicit state has changed, try to respond.
if self._implicit_state_changed:
@ -267,7 +273,7 @@ class AccountV2Subsystem:
'AccountV2: Signing out as result'
' of implicit state change...',
)
_ba.app.accounts_v2.set_primary_credentials(None)
plus.accounts.set_primary_credentials(None)
self._implicit_state_changed = False
# Once we've made a move here we don't want to
@ -283,7 +289,7 @@ class AccountV2Subsystem:
# switching accounts via the back-end).
# NOTE: should test case where we don't have
# connectivity here.
if _ba.app.cloud.is_connected():
if plus.cloud.is_connected():
if DEBUG_LOG:
logging.debug(
'AccountV2: Signing in as result'
@ -310,9 +316,9 @@ class AccountV2Subsystem:
# 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 = _ba.app.cloud.is_connected()
signed_in_v1 = get_v1_account_state() == 'signed_in'
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
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
@ -334,10 +340,13 @@ class AccountV2Subsystem:
result: LoginAdapter.SignInResult | Exception,
) -> None:
"""A sign-in has completed that the user asked for explicitly."""
from ba._language import Lstr
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):
@ -350,20 +359,19 @@ class AccountV2Subsystem:
)
# For now just show 'error'. Should do better than this.
with _ba.Context('ui'):
_ba.screenmessage(
Lstr(resource='internal.signInErrorText'),
color=(1, 0, 0),
)
_ba.playsound(_ba.getsound('error'))
_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.
_ba.app.accounts_v2.set_primary_credentials(None)
plus.accounts.set_primary_credentials(None)
return
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
plus.accounts.set_primary_credentials(result.credentials)
def _on_implicit_sign_in_completed(
self,
@ -371,7 +379,8 @@ class AccountV2Subsystem:
result: LoginAdapter.SignInResult | Exception,
) -> None:
"""A sign-in has completed that the user didn't ask for explicitly."""
from ba._internal import get_v1_account_state
plus = _babase.app.plus
assert plus is not None
del adapter # Unused.
@ -391,16 +400,16 @@ class AccountV2Subsystem:
# 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 = _ba.app.cloud.is_connected()
signed_in_v1 = get_v1_account_state() == 'signed_in'
signed_in_v2 = _ba.app.accounts_v2.have_primary_credentials()
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:
_ba.app.accounts_v2.set_primary_credentials(result.credentials)
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
_ba.app.on_initial_sign_in_completed()
_babase.app.on_initial_sign_in_completed()
class AccountV2Handle:

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

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING, TypeVar, cast
import _ba
import _babase
if TYPE_CHECKING:
from typing import Callable, Any
@ -19,19 +19,21 @@ class AppComponentSubsystem:
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.
functionality for the app, and allows plugins or other custom code
to easily override said functionality.
Use ba.app.components to get the single shared instance of this class.
Access the single shared instance of this class at
babase.app.components.
The general idea with this setup is that a base-class is defined to
provide some functionality and then anyone wanting that functionality
uses the getclass() method with that base class to return the current
registered implementation. The user should not know or care whether
they are getting the base class itself or some other implementation.
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.
fire in a deferred manner when particular base-classes are
overridden.
"""
def __init__(self) -> None:
@ -45,9 +47,10 @@ class AppComponentSubsystem:
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 _ba.in_logic_thread()
# 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(
@ -58,18 +61,19 @@ class AppComponentSubsystem:
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.
# clean everything. And add ourself to the dirty list
# regardless.
if not self._dirty_base_classes:
_ba.pushcall(self._run_change_callbacks)
_babase.pushcall(self._run_change_callbacks)
self._dirty_base_classes.add(baseclass)
def getclass(self, baseclass: T) -> T:
"""Given a base-class, return the currently set implementation class.
"""Given a base-class, return the current implementation class.
If no custom implementation has been set, the provided base-class
is returned.
If no custom implementation has been set, the provided
base-class is returned.
"""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
del baseclass # Unused.
return cast(T, None)
@ -77,13 +81,13 @@ class AppComponentSubsystem:
def register_change_callback(
self, baseclass: T, callback: Callable[[T], None]
) -> None:
"""Register a callback to fire when a class implementation changes.
"""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 _ba.in_logic_thread()
assert _babase.in_logic_thread()
self._change_callbacks.setdefault(baseclass, []).append(callback)
def _run_change_callbacks(self) -> None:

View file

@ -5,11 +5,13 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
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.
@ -20,7 +22,7 @@ class AppConfig(dict):
defaults, applying contained values to the game, and committing the
config to storage.
Call ba.appconfig() to get the single shared instance of this class.
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).
@ -33,32 +35,33 @@ class AppConfig(dict):
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 ba.App to access actual live state.
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 ba.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.
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 _ba.resolve_appconfig_value(key)
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 ba.AppConfig.resolve() if
the key is not present in the config dict or of an incompatible type.
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 ba.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.
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 _ba.get_appconfig_default_value(key)
return _babase.get_appconfig_default_value(key)
def builtin_keys(self) -> list[str]:
"""Return the list of valid key names recognized by ba.AppConfig.
"""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
@ -70,11 +73,15 @@ class AppConfig(dict):
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 _ba.get_appconfig_builtin_keys()
return _babase.get_appconfig_builtin_keys()
def apply(self) -> None:
"""Apply config values to the running app."""
_ba.apply_config()
"""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.
@ -93,17 +100,16 @@ class AppConfig(dict):
self.commit()
def read_config() -> tuple[AppConfig, bool]:
"""Read the game config."""
def read_app_config() -> tuple[AppConfig, bool]:
"""Read the app config."""
import os
import json
from ba._generated.enums import TimeType
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 = _ba.app.config_file_path
config_file_path = _babase.app.config_file_path
config_contents = ''
try:
if os.path.exists(config_file_path):
@ -118,7 +124,7 @@ def read_config() -> tuple[AppConfig, bool]:
print(
(
'error reading config file at time '
+ str(_ba.time(TimeType.REAL))
+ str(_babase.apptime())
+ ': \''
+ config_file_path
+ '\':\n'
@ -166,12 +172,13 @@ def commit_app_config(force: bool = False) -> None:
(internal)
"""
from ba._internal import mark_config_dirty
plus = _babase.app.plus
assert plus is not None
if not _ba.app.config_file_healthy and not force:
if not _babase.app.config_file_healthy and not force:
print(
'Current config file is broken; '
'skipping write to avoid losing settings.'
)
return
mark_config_dirty()
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."""

View file

@ -10,13 +10,16 @@ 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 _ba
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Any, TextIO
import ba
import babase
def is_browser_likely_available() -> bool:
@ -24,118 +27,136 @@ def is_browser_likely_available() -> bool:
category: General Utility Functions
If this returns False you may want to avoid calling ba.show_url()
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 = _ba.app
platform = app.platform
touchscreen = _ba.getinputdevice('TouchScreen', '#1', doraise=False)
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 touchscreen is None):
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() -> ba.Lstr:
def get_remote_app_name() -> babase.Lstr:
"""(internal)"""
from ba import _language
from babase import _language
return _language.Lstr(resource='remote_app.app_name')
def should_submit_debug_info() -> bool:
"""(internal)"""
return _ba.app.config.get('Submit Debug Info', True)
return _babase.app.config.get('Submit Debug Info', True)
def handle_v1_cloud_log() -> None:
"""Called on debug log prints.
"""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.
When this happens, we can upload our log to the server after a short
bit if desired.
"""
from ba._net import master_server_post
from ba._generated.enums import TimeType
from ba._internal import get_news_show
app = _ba.app
app.log_have_new = True
if not app.log_upload_timer_started:
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(_ba.get_foreground_host_session())
sessionname = str(classic.get_foreground_host_session())
except Exception:
sessionname = 'unavailable'
try:
activityname = str(_ba.get_foreground_host_activity())
activityname = str(classic.get_foreground_host_activity())
except Exception:
activityname = 'unavailable'
info = {
'log': _ba.get_v1_cloud_log(),
'log': _babase.get_v1_cloud_log(),
'version': app.version,
'build': app.build_number,
'userAgentString': app.user_agent_string,
'userAgentString': classic.legacy_user_agent_string,
'session': sessionname,
'activity': activityname,
'fatal': 0,
'userRanCommands': _ba.has_user_run_commands(),
'time': _ba.time(TimeType.REAL),
'userModded': _ba.workspaces_in_use(),
'newsShow': get_news_show(),
'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:
app.log_have_new = False
_ba.mark_log_sent()
classic.log_have_new = False
_babase.mark_log_sent()
master_server_post('bsLog', info, response)
classic.master_server_v1_post('bsLog', info, response)
app.log_upload_timer_started = True
classic.log_upload_timer_started = True
# Delay our log upload slightly in case other
# pertinent info gets printed between now and then.
with _ba.Context('ui'):
_ba.timer(3.0, _put_log, timetype=TimeType.REAL)
with _babase.ContextRef.empty():
_babase.apptimer(3.0, _put_log)
# After a while, allow another log-put.
def _reset() -> None:
app.log_upload_timer_started = False
if app.log_have_new:
assert classic is not None
classic.log_upload_timer_started = False
if classic.log_have_new:
handle_v1_cloud_log()
if not _ba.is_log_full():
with _ba.Context('ui'):
_ba.timer(
600.0,
_reset,
timetype=TimeType.REAL,
suppress_format_warning=True,
)
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
from ba._net import master_server_post
if os.path.exists(_ba.get_v1_cloud_log_file_path()):
if os.path.exists(_babase.get_v1_cloud_log_file_path()):
with open(
_ba.get_v1_cloud_log_file_path(), encoding='utf-8'
_babase.get_v1_cloud_log_file_path(), encoding='utf-8'
) as infile:
info = json.loads(infile.read())
infile.close()
@ -147,19 +168,21 @@ def handle_leftover_v1_cloud_log_file() -> None:
# lets kill it.
if data is not None:
try:
os.remove(_ba.get_v1_cloud_log_file_path())
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
master_server_post('bsLog', info, response)
_babase.app.classic.master_server_v1_post(
'bsLog', info, response
)
else:
# If they don't want logs uploaded just kill it.
os.remove(_ba.get_v1_cloud_log_file_path())
os.remove(_babase.get_v1_cloud_log_file_path())
except Exception:
from ba import _error
from babase import _error
_error.print_exception('Error handling leftover log file.')
@ -180,8 +203,8 @@ def garbage_collect_session_end() -> None:
# 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')
# if bool(False):
# print_live_object_warnings('after session shutdown')
def garbage_collect() -> None:
@ -196,78 +219,24 @@ def garbage_collect() -> None:
gc.collect()
def print_live_object_warnings(
when: Any,
ignore_session: ba.Session | None = None,
ignore_activity: ba.Activity | None = None,
) -> None:
"""Print warnings for remaining objects in the current context.
IMPORTANT - don't call this in production; usage of gc.get_objects()
can bork Python. See notes at top of efro.debug module.
"""
# pylint: disable=cyclic-import
from ba._session import Session
from ba._actor import Actor
from ba._activity import Activity
sessions: list[ba.Session] = []
activities: list[ba.Activity] = []
actors: list[ba.Actor] = []
# Once we come across leaked stuff, printing again is probably
# redundant.
if _ba.app.printed_live_object_warning:
return
for obj in gc.get_objects():
if isinstance(obj, Actor):
actors.append(obj)
elif isinstance(obj, Session):
sessions.append(obj)
elif isinstance(obj, Activity):
activities.append(obj)
# Complain about any remaining sessions.
for session in sessions:
if session is ignore_session:
continue
_ba.app.printed_live_object_warning = True
print(f'ERROR: Session found {when}: {session}')
# Complain about any remaining activities.
for activity in activities:
if activity is ignore_activity:
continue
_ba.app.printed_live_object_warning = True
print(f'ERROR: Activity found {when}: {activity}')
# Complain about any remaining actors.
for actor in actors:
_ba.app.printed_live_object_warning = True
print(f'ERROR: Actor found {when}: {actor}')
def print_corrupt_file_error() -> None:
"""Print an error if a corrupt file is found."""
from ba._general import Call
from ba._generated.enums import TimeType
_ba.timer(
2.0,
lambda: _ba.screenmessage(
_ba.app.lang.get_resource('internal.corruptFileText').replace(
'${EMAIL}', 'support@froemling.net'
# 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),
),
color=(1, 0, 0),
),
timetype=TimeType.REAL,
)
_ba.timer(
2.0, Call(_ba.playsound, _ba.getsound('error')), timetype=TimeType.REAL
)
)
_babase.apptimer(2.0, _babase.getsimplesound('error').play)
_tbfiles: list[TextIO] = []
_tb_held_files: list[TextIO] = []
@ioprepped
@ -302,7 +271,6 @@ def dump_app_state(
"""
# pylint: disable=consider-using-with
import faulthandler
from ba._generated.enums import TimeType
# Dump our metadata immediately. If a delay is passed, it generally
# means we expect things to hang momentarily, so we should not delay
@ -311,14 +279,14 @@ def dump_app_state(
# the dump in that case.
try:
mdpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
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=_ba.time(TimeType.REAL),
app_time=_babase.apptime(),
log_level=log_level,
)
)
@ -329,14 +297,15 @@ def dump_app_state(
return
tbpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_tb'
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.
tbfile = open(tbpath, 'w', encoding='utf-8')
_tbfiles.append(tbfile)
_tb_held_files.append(tbfile)
if delay > 0.0:
faulthandler.dump_traceback_later(delay, file=tbfile)
@ -347,10 +316,8 @@ def dump_app_state(
# 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.
_ba.pushcall(
lambda: _ba.timer(
delay + 1.0, log_dumped_app_state, timetype=TimeType.REAL
),
_babase.pushcall(
tpartial(_babase.apptimer, delay + 1.0, log_dumped_app_state),
from_other_thread=True,
suppress_other_thread_warning=True,
)
@ -362,20 +329,33 @@ def log_dumped_app_state() -> None:
try:
out = ''
mdpath = os.path.join(
os.path.dirname(_ba.app.config_file_path), '_appstate_dump_md'
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:
metadata = dataclass_from_json(
DumpedAppStateMetadata, infile.read()
)
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(_ba.app.config_file_path), '_appstate_dump_tb'
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:
@ -386,11 +366,12 @@ def log_dumped_app_state() -> None:
logging.exception('Error logging dumped app state.')
class AppHealthMonitor:
class AppHealthMonitor(AppSubsystem):
"""Logs things like app-not-responding issues."""
def __init__(self) -> None:
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
super().__init__()
self._running = True
self._thread = Thread(target=self._app_monitor_thread_main, daemon=True)
self._thread.start()
@ -398,14 +379,13 @@ class AppHealthMonitor:
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 _ba.in_logic_thread()
assert _babase.in_logic_thread()
self._response = True
def _check_running(self) -> bool:
@ -417,7 +397,6 @@ class AppHealthMonitor:
import time
while bool(True):
# Always sleep a bit between checks.
time.sleep(1.234)
@ -428,9 +407,8 @@ class AppHealthMonitor:
# Wait for the logic thread to run something we send it.
starttime = time.monotonic()
self._response = False
_ba.pushcall(self._set_response, raw=True)
_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
@ -459,51 +437,9 @@ class AppHealthMonitor:
self._first_check = False
def on_app_pause(self) -> None:
"""Should be called when the app pauses."""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
self._running = False
def on_app_resume(self) -> None:
"""Should be called when the app resumes."""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
self._running = True
def on_too_many_file_descriptors() -> None:
"""Called when too many file descriptors are open; trying to debug."""
from ba._generated.enums import TimeType
real_time = _ba.time(TimeType.REAL)
def _do_log() -> None:
pid = os.getpid()
try:
fdcount: int | str = len(os.listdir(f'/proc/{pid}/fd'))
except Exception as exc:
fdcount = f'? ({exc})'
logging.warning(
'TOO MANY FDS at %.2f. We are pid %d. FDCount is %s.',
real_time,
pid,
fdcount,
)
Thread(target=_do_log, daemon=True).start()
# import io
# from efro.debug import printtypes
# with io.StringIO() as fstr:
# fstr.write('Too many FDs.\n')
# printtypes(file=fstr)
# fstr.seek(0)
# logging.warning(fstr.read())
# import socket
# objs: list[Any] = []
# for obj in gc.get_objects():
# if isinstance(obj, socket.socket):
# objs.append(obj)
# test = open('/Users/ericf/.zshrc', 'r', encoding='utf-8')
# reveal_type(test)
# print('FOUND', len(objs))

View file

@ -21,7 +21,7 @@ from efro.dataclassio import (
dataclass_from_json,
dataclass_to_json,
)
import _ba
import _babase
if TYPE_CHECKING:
from bacommon.assets import AssetPackageFlavor
@ -184,7 +184,13 @@ def fetch_url(url: str, filename: Path, asset_gather: AssetGather) -> None:
# Pass a very short timeout to urllib so we have opportunities
# to cancel even with network blockage.
req = urllib.request.urlopen(url, context=_ba.app.net.sslcontext, timeout=1)
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:,}')

View file

@ -16,10 +16,10 @@ import time
import os
if TYPE_CHECKING:
import ba
import babase
# Our timer and event loop for the ballistica logic thread.
_asyncio_timer: ba.Timer | None = None
_asyncio_timer: babase.AppTimer | None = None
_asyncio_event_loop: asyncio.AbstractEventLoop | None = None
DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1'
@ -29,11 +29,10 @@ def setup_asyncio() -> asyncio.AbstractEventLoop:
"""Setup asyncio functionality for the logic thread."""
# pylint: disable=global-statement
import _ba
import ba
from ba._generated.enums import TimeType
import _babase
import babase
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
# Create our event-loop. We don't expect there to be one
# running on this thread before we do.
@ -43,9 +42,9 @@ def setup_asyncio() -> asyncio.AbstractEventLoop:
except RuntimeError:
pass
global _asyncio_event_loop # pylint: disable=invalid-name
global _asyncio_event_loop
_asyncio_event_loop = asyncio.new_event_loop()
_asyncio_event_loop.set_default_executor(ba.app.threadpool)
_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
@ -73,10 +72,8 @@ def setup_asyncio() -> asyncio.AbstractEventLoop:
warn_time,
)
global _asyncio_timer # pylint: disable=invalid-name
_asyncio_timer = _ba.Timer(
1.0 / 30.0, run_cycle, timetype=TimeType.REAL, repeat=True
)
global _asyncio_timer
_asyncio_timer = _babase.AppTimer(1.0 / 30.0, run_cycle, repeat=True)
if bool(False):
@ -87,6 +84,6 @@ def setup_asyncio() -> asyncio.AbstractEventLoop:
await asyncio.sleep(2.0)
print('TEST AIO TASK ENDING')
_asyncio_event_loop.create_task(aio_test())
_testtask = _asyncio_event_loop.create_task(aio_test())
return _asyncio_event_loop

View file

@ -7,7 +7,8 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, overload
import _ba
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
from typing import Callable, Any
@ -22,7 +23,7 @@ DEBUG_LOG = False
# internal protocols.
class CloudSubsystem:
class CloudSubsystem(AppSubsystem):
"""Manages communication with cloud components."""
def is_connected(self) -> bool:
@ -33,20 +34,17 @@ class CloudSubsystem:
"""
return False # Needs to be overridden
def on_app_pause(self) -> None:
"""Should be called when the app pauses."""
def on_app_resume(self) -> None:
"""Should be called when the app resumes."""
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)
_ba.app.accounts_v2.on_cloud_connectivity_changed(connected)
plus.accounts.on_cloud_connectivity_changed(connected)
@overload
def send_message_cb(
@ -114,11 +112,11 @@ class CloudSubsystem:
The provided on_response call will be run in the logic thread
and passed either the response or the error that occurred.
"""
from ba._general import Call
from babase._general import Call
del msg # Unused.
_ba.pushcall(
_babase.pushcall(
Call(
on_response,
RuntimeError('Cloud functionality is not available.'),
@ -155,10 +153,8 @@ def cloud_console_exec(code: str) -> None:
"""Called by the cloud console to run code in the logic thread."""
import sys
import __main__
from ba._generated.enums import TimeType
try:
# First try it as eval.
try:
evalcode = compile(code, '<console>', 'eval')
@ -187,7 +183,7 @@ def cloud_console_exec(code: str) -> None:
except Exception:
import traceback
apptime = _ba.time(TimeType.REAL)
apptime = _babase.apptime()
print(f'Exec error at time {apptime:.2f}.', file=sys.stderr)
traceback.print_exc()

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)

View file

@ -6,29 +6,10 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
import _babase
if TYPE_CHECKING:
from typing import Any
import ba
class DependencyError(Exception):
"""Exception raised when one or more ba.Dependency items are missing.
Category: **Exception Classes**
(this will generally be missing assets).
"""
def __init__(self, deps: list[ba.Dependency]):
super().__init__()
self._deps = deps
@property
def deps(self) -> list[ba.Dependency]:
"""The list of missing dependencies causing this error."""
return self._deps
class ContextError(Exception):
@ -49,28 +30,28 @@ class NotFoundError(Exception):
class PlayerNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Player does not exist.
"""Exception raised when an expected player does not exist.
Category: **Exception Classes**
"""
class SessionPlayerNotFoundError(NotFoundError):
"""Exception raised when an expected ba.SessionPlayer does not exist.
"""Exception raised when an expected session-player does not exist.
Category: **Exception Classes**
"""
class TeamNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Team does not exist.
"""Exception raised when an expected bascenev1.Team does not exist.
Category: **Exception Classes**
"""
class MapNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Map does not exist.
"""Exception raised when an expected bascenev1.Map does not exist.
Category: **Exception Classes**
"""
@ -84,49 +65,49 @@ class DelegateNotFoundError(NotFoundError):
class SessionTeamNotFoundError(NotFoundError):
"""Exception raised when an expected ba.SessionTeam does not exist.
"""Exception raised when an expected session-team does not exist.
Category: **Exception Classes**
"""
class NodeNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Node does not exist.
"""Exception raised when an expected Node does not exist.
Category: **Exception Classes**
"""
class ActorNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Actor does not exist.
"""Exception raised when an expected actor does not exist.
Category: **Exception Classes**
"""
class ActivityNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Activity does not exist.
"""Exception raised when an expected bascenev1.Activity does not exist.
Category: **Exception Classes**
"""
class SessionNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Session does not exist.
"""Exception raised when an expected session does not exist.
Category: **Exception Classes**
"""
class InputDeviceNotFoundError(NotFoundError):
"""Exception raised when an expected ba.InputDevice does not exist.
"""Exception raised when an expected input-device does not exist.
Category: **Exception Classes**
"""
class WidgetNotFoundError(NotFoundError):
"""Exception raised when an expected ba.Widget does not exist.
"""Exception raised when an expected widget does not exist.
Category: **Exception Classes**
"""
@ -156,12 +137,12 @@ def print_exception(*args: Any, **keywds: Any) -> None:
try:
# If we're only printing once and already have, bail.
if keywds.get('once', False):
if not _ba.do_once():
if not _babase.do_once():
return
err_str = ' '.join([str(a) for a in args])
print('ERROR:', err_str)
_ba.print_context()
_babase.print_context()
print('PRINTED-FROM:')
# Basically the output of traceback.print_stack()
@ -174,7 +155,7 @@ def print_exception(*args: Any, **keywds: Any) -> None:
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 ba.print_exception():')
print('ERROR: exception in babase.print_exception():')
traceback.print_exc()
@ -193,15 +174,15 @@ def print_error(err_str: str, once: bool = False) -> None:
try:
# If we're only printing once and already have, bail.
if once:
if not _ba.do_once():
if not _babase.do_once():
return
print('ERROR:', err_str)
_ba.print_context()
_babase.print_context()
# Basically the output of traceback.print_stack()
stackstr = ''.join(traceback.format_stack())
print(stackstr, end='')
except Exception:
print('ERROR: exception in ba.print_error():')
print('ERROR: exception in babase.print_error():')
traceback.print_exc()

View file

@ -7,18 +7,29 @@ import types
import weakref
import random
import inspect
from typing import TYPE_CHECKING, TypeVar, Protocol
from typing import TYPE_CHECKING, TypeVar, Protocol, NewType
from efro.terminal import Clr
import _ba
from ba._error import print_error, print_exception
from ba._generated.enums import TimeType
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.
@ -34,7 +45,7 @@ T = TypeVar('T')
def existing(obj: ExistableT | None) -> ExistableT | None:
"""Convert invalid references to None for any ba.Existable object.
"""Convert invalid references to None for any babase.Existable object.
Category: **Gameplay Functions**
@ -96,7 +107,7 @@ def json_prep(data: Any) -> Any:
try:
return data.decode(errors='ignore')
except Exception:
from ba import _error
from babase import _error
print_error('json_prep encountered utf-8 decode error', once=True)
return data.decode(errors='ignore')
@ -147,18 +158,18 @@ class _WeakCall:
we overwrite its variable with None because the bound method
we pass as a timer callback (foo.bar) strong-references it
>>> foo = FooClass()
... ba.timer(5.0, foo.bar)
... 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()
... ba.timer(5.0, ba.WeakCall(foo.bar))
... babase.apptimer(5.0, ba.WeakCall(foo.bar))
... foo = None
**EXAMPLE C:** Wrap a method call with some positional and keyword args:
>>> myweakcall = ba.WeakCall(self.dostuff, argval1,
>>> 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)
@ -171,6 +182,8 @@ class _WeakCall:
to wrap them in weakrefs manually if desired.
"""
_did_invalid_call_warning = False
def __init__(self, *args: Any, **keywds: Any) -> None:
"""Instantiate a WeakCall.
@ -180,21 +193,21 @@ class _WeakCall:
if hasattr(args[0], '__func__'):
self._call = WeakMethod(args[0])
else:
app = _ba.app
if not app.did_weak_call_warning:
app = _babase.app
if not self._did_invalid_call_warning:
print(
(
'Warning: callable passed to ba.WeakCall() is not'
'Warning: callable passed to babase.WeakCall() is not'
' weak-referencable ('
+ str(args[0])
+ '); use ba.Call() instead to avoid this '
+ '); use babase.Call() instead to avoid this '
'warning. Stack-trace:'
)
)
import traceback
traceback.print_stack()
app.did_weak_call_warning = True
self._did_invalid_call_warning = True
self._call = args[0]
self._args = args[1:]
self._keywds = keywds
@ -224,7 +237,7 @@ class _Call:
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 ba.WeakCall if you want to pass a method to callback
alive too. Use babase.WeakCall if you want to pass a method to callback
without keeping its object alive.
"""
@ -236,7 +249,7 @@ class _Call:
##### Example
Wrap a method call with 1 positional and 1 keyword arg:
>>> mycall = ba.Call(myobj.dostuff, argval, namedarg=argval2)
>>> 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()
@ -303,18 +316,21 @@ def verify_object_death(obj: object) -> None:
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)
with _ba.Context('ui'):
_ba.timer(
delay, lambda: _verify_object_death(ref), timetype=TimeType.REAL
)
# 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:
@ -353,7 +369,7 @@ def storagename(suffix: str | None = None) -> str:
>>> class MyThingie:
... # This will give something like
... # '_mymodule_submodule_mythingie_data'.
... _STORENAME = ba.storagename('data')
... _STORENAME = babase.storagename('data')
...
... # Use that name to store some data in the Activity we were
... # passed.

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),
)

View file

@ -17,7 +17,7 @@ class Keyboard:
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 ba.Keyboard.
On-screen keyboard uses chars from active babase.Keyboard.
"""
name: str

View file

@ -3,35 +3,365 @@
"""Language related functionality."""
from __future__ import annotations
import json
import os
import json
import logging
from typing import TYPE_CHECKING, overload
import _ba
import _babase
from babase._appsubsystem import AppSubsystem
if TYPE_CHECKING:
import ba
from typing import Any, Sequence
import babase
class LanguageSubsystem:
"""Wraps up language related app functionality.
class LanguageSubsystem(AppSubsystem):
"""Language functionality for the app.
Category: **App Classes**
To use this class, access the single instance of it at 'ba.app.lang'.
Access the single instance of this class at 'babase.app.lang'.
"""
def __init__(self) -> None:
self.language_target: AttrDict | None = None
self.language_merged: AttrDict | None = None
self.default_language = self._get_default_language()
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.
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 :-(.
@ -48,23 +378,11 @@ class LanguageSubsystem:
'Thai',
'Tamil',
}
and not _ba.can_display_full_unicode()
and not _babase.can_display_full_unicode()
):
return False
return True
@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
ba.App.language, which is the language the game is using
(which may differ from locale if the user sets a language, etc.)
"""
env = _ba.env()
assert isinstance(env['locale'], str)
return env['locale']
def _get_default_language(self) -> str:
languages = {
'ar': 'Arabic',
@ -102,8 +420,9 @@ class LanguageSubsystem:
'vi': 'Vietnamese',
}
# Special case for Chinese: map specific variations to traditional.
# (otherwise will map to 'Chinese' which is simplified)
# 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:
@ -112,340 +431,42 @@ class LanguageSubsystem:
language = 'English'
return language
@property
def language(self) -> str:
"""The name of the language the game is running in.
This can be selected explicitly by the user or may be set
automatically based on ba.App.locale or other factors.
"""
assert isinstance(_ba.app.config, dict)
return _ba.app.config.get('Lang', self.default_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('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 ba 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 language used for the game.
Pass None to use OS default language.
"""
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
cfg = _ba.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(
'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 = (
'ba_data/data/languages/' + language.lower() + '.json'
)
with open(lmodfile, encoding='utf-8') as infile:
lmodvalues = json.loads(infile.read())
except Exception:
from ba import _error
_error.print_exception('Exception importing language:', language)
_ba.screenmessage(
"Error setting language to '"
+ language
+ "'; see log for details",
color=(1, 0, 0),
)
switched = False
lmodvalues = None
# 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 != '']
_ba.set_internal_language_keys(internal_vals, random_names)
if switched and print_change:
_ba.screenmessage(
Lstr(
resource='languageSetText',
subs=[
('${LANGUAGE}', Lstr(translate=('languages', language)))
],
),
color=(0, 1, 0),
)
def get_resource(
self,
resource: str,
fallback_resource: str | None = None,
fallback_value: Any = None,
) -> Any:
"""Return a translation resource by name.
DEPRECATED; use ba.Lstr functionality for these purposes.
"""
try:
# If we have no language set, go ahead and set it.
if self.language_merged is None:
language = self.language
try:
self.setlanguage(
language, print_change=False, store_to_config=False
)
except Exception:
from ba import _error
_error.print_exception(
'exception setting language to', language
)
# Try english as a fallback.
if language != 'English':
print('Resorting to fallback language (English)')
try:
self.setlanguage(
'English',
print_change=False,
store_to_config=False,
)
except Exception:
_error.print_exception(
'error setting language to english fallback'
)
# 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 ba 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 ba.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
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.
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.
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 = ba.Lstr(resource='audioSettingsWindow.titleText')
>>> 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 = ba.Lstr(translate=('gameDescriptions',
>>> 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 = ba.Lstr(value='${A} / ${B}',
>>> mynode.text = babase.Lstr(value='${A} / ${B}',
... subs=[('${A}', str(score)), ('${B}', str(total))])
EXAMPLE 4: ba.Lstr's can be nested. This example would display the
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 = ba.Lstr(
>>> mytextnode.text = babase.Lstr(
... resource='res_a',
... subs=[('${NAME}', ba.Lstr(resource='res_b'))])
... subs=[('${NAME}', babase.Lstr(resource='res_b'))])
"""
# pylint: disable=dangerous-default-value
@ -528,7 +549,7 @@ class Lstr:
keywds['v'] = keywds['value']
del keywds['value']
if 'fallback' in keywds:
from ba import _error
from babase import _error
_error.print_error(
'deprecated "fallback" arg passed to Lstr(); use '
@ -553,15 +574,15 @@ class Lstr:
You should avoid doing this as much as possible and instead pass
and store Lstr values.
"""
return _ba.evaluate_lstr(self._get_json())
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.
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', []))
@ -569,7 +590,7 @@ class Lstr:
try:
return json.dumps(self.args, separators=(',', ':'))
except Exception:
from ba import _error
from babase import _error
_error.print_exception('_get_json failed for', self.args)
return 'JSON_ERR'
@ -581,8 +602,8 @@ class Lstr:
return '<ba.Lstr: ' + self._get_json() + '>'
@staticmethod
def from_json(json_string: str) -> ba.Lstr:
"""Given a json string, returns a ba.Lstr. Does no data validation."""
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
@ -625,4 +646,4 @@ class AttrDict(dict):
return val
def __setattr__(self, attr: str, value: Any) -> None:
raise Exception()
raise AttributeError()

View file

@ -10,7 +10,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, final
from bacommon.login import LoginType
import _ba
import _babase
if TYPE_CHECKING:
from typing import Callable
@ -45,12 +45,12 @@ class LoginAdapter:
display_name: str
def __init__(self, login_type: LoginType):
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
self.login_type = login_type
self._implicit_login_state: LoginAdapter.ImplicitLoginState | None = (
None
)
self._on_app_launch_called = False
self._on_app_loading_called = False
self._implicit_login_state_dirty = False
self._back_end_active = False
@ -61,11 +61,11 @@ class LoginAdapter:
self._last_sign_in_time: float | None = None
self._last_sign_in_desc: str | None = None
def on_app_launch(self) -> None:
"""Should be called for each adapter in on_app_launch."""
def on_app_loading(self) -> None:
"""Should be called for each adapter in on_app_loading."""
assert not self._on_app_launch_called
self._on_app_launch_called = True
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.
@ -79,7 +79,7 @@ class LoginAdapter:
This should be called by the adapter back-end when an account
of their associated type gets logged in or out.
"""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
# Ignore redundant sets.
if state == self._implicit_login_state:
@ -118,7 +118,7 @@ class LoginAdapter:
Note that the logins dict passed in should be immutable as
only a reference to it is stored, not a copy.
"""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
if DEBUG_LOG:
logging.debug(
'LoginAdapter: %s adapter got active logins %s.',
@ -139,7 +139,7 @@ class LoginAdapter:
UIs, etc. When not active it should ignore everything and behave
as if logged out, even if it technically is still logged in.
"""
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
del active # Unused.
@final
@ -154,30 +154,29 @@ class LoginAdapter:
the adapter will attempt to sign in if possible. An exception will
be returned if the sign-in attempt fails.
"""
assert _ba.in_logic_thread()
from ba._general import Call
from ba._generated.enums import TimeType
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 = _ba.time(TimeType.REAL)
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-real-time=%.2f.',
' ba-app-time=%.2f.',
self.login_type.name,
since_last,
description,
self._last_sign_in_desc,
appnow,
)
_ba.pushcall(
_babase.pushcall(
Call(
result_cb,
self,
@ -207,7 +206,7 @@ class LoginAdapter:
' aborting sign-in.',
self.login_type.name,
)
_ba.pushcall(
_babase.pushcall(
Call(
result_cb,
self,
@ -229,7 +228,6 @@ class LoginAdapter:
def _got_sign_in_response(
response: bacommon.cloud.SignInResponse | Exception,
) -> None:
if isinstance(response, Exception):
if DEBUG_LOG:
logging.debug(
@ -238,7 +236,7 @@ class LoginAdapter:
self.login_type.name,
response,
)
_ba.pushcall(Call(result_cb, self, response))
_babase.pushcall(Call(result_cb, self, response))
else:
if DEBUG_LOG:
logging.debug(
@ -257,9 +255,10 @@ class LoginAdapter:
result2 = self.SignInResult(
credentials=response.credentials
)
_ba.pushcall(Call(result_cb, self, result2))
_babase.pushcall(Call(result_cb, self, result2))
_ba.app.cloud.send_message_cb(
assert _babase.app.plus is not None
_babase.app.plus.cloud.send_message_cb(
bacommon.cloud.SignInMessage(
self.login_type,
result,
@ -288,18 +287,18 @@ class LoginAdapter:
The provided completion_cb should then be called with either a token
or None if sign in failed or was cancelled.
"""
from ba._general import Call
from babase._general import Call
# Default implementation simply fails immediately.
_ba.pushcall(Call(completion_cb, None))
_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_launch_called:
from ba._general import Call
if self._implicit_login_state_dirty and self._on_app_loading_called:
from babase._general import Call
if DEBUG_LOG:
logging.debug(
@ -308,9 +307,10 @@ class LoginAdapter:
self.login_type.name,
)
_ba.pushcall(
assert _babase.app.plus is not None
_babase.pushcall(
Call(
_ba.app.accounts_v2.on_implicit_login_state_changed,
_babase.app.plus.accounts.on_implicit_login_state_changed,
self.login_type,
self._implicit_login_state,
)
@ -353,14 +353,18 @@ class LoginAdapterNative(LoginAdapter):
attempt_id = self._sign_in_attempt_num
self._sign_in_attempts[attempt_id] = completion_cb
self._sign_in_attempt_num += 1
_ba.login_adapter_get_sign_in_token(self.login_type.value, attempt_id)
_babase.login_adapter_get_sign_in_token(
self.login_type.value, attempt_id
)
def on_back_end_active_change(self, active: bool) -> None:
_ba.login_adapter_back_end_active_change(self.login_type.value, active)
_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 _ba.in_logic_thread()
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

View file

@ -13,7 +13,7 @@ from typing import TYPE_CHECKING, TypeVar
from dataclasses import dataclass, field
from efro.call import tpartial
import _ba
import _babase
if TYPE_CHECKING:
from typing import Callable
@ -21,20 +21,15 @@ if TYPE_CHECKING:
# 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-Tags
CURRENT_API_VERSION = 6 #TODO update it to latest
# current API version is 7 , im downgrading it to 6 to support mini games which i cant update to 7 bcoz of encryption
# shouldn't be a issue , I manually updated all plugin on_app_launch to on_app_running and that was the only change btw API 6 and 7
# 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': 'ba.Plugin',
'keyboard': 'ba.Keyboard',
'game': 'ba.GameActivity',
'plugin': 'babase.Plugin',
'keyboard': 'babase.Keyboard',
}
T = TypeVar('T')
@ -45,8 +40,8 @@ class ScanResults:
"""Final results from a meta-scan."""
exports: dict[str, list[str]] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
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."""
@ -58,11 +53,10 @@ class MetadataSubsystem:
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.meta'.
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.
@ -85,7 +79,14 @@ class MetadataSubsystem:
self._scan_complete_cb = scan_complete_cb
self._scan = DirectoryScan(
[_ba.app.python_directory_app, _ba.app.python_directory_user]
[
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()
@ -132,7 +133,7 @@ class MetadataSubsystem:
completion_cb: Callable[[list[type[T]]], None],
completion_cb_in_bg_thread: bool,
) -> None:
from ba._general import getclass
from babase._general import getclass
classes: list[type[T]] = []
try:
@ -150,14 +151,14 @@ class MetadataSubsystem:
if completion_cb_in_bg_thread:
completion_call()
else:
_ba.pushcall(completion_call, from_other_thread=True)
_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 _ba.in_logic_thread():
if _babase.in_logic_thread():
logging.warning(
'ba.meta._wait_for_scan_results()'
'babase.meta._wait_for_scan_results()'
' called in logic thread before scan completed;'
' this can cause hitches.'
)
@ -180,43 +181,61 @@ class MetadataSubsystem:
self._scan.run()
results = self._scan.results
self._scan = None
except Exception as exc:
results = ScanResults(errors=[f'Scan exception: {exc}'])
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
_ba.pushcall(self._handle_scan_results, from_other_thread=True)
_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 ba._language import Lstr
from babase._language import Lstr
assert _ba.in_logic_thread()
assert _babase.in_logic_thread()
results = self.scanresults
assert results is not None
# Spit out any warnings/errors that happened.
# Warnings generally only get printed locally for users' benefit
# (things like out-of-date scripts being ignored, etc.)
# Errors are more serious and will get included in the regular log.
if results.warnings or results.errors:
import textwrap
do_play_error_sound = False
_ba.screenmessage(
# 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)
)
_ba.playsound(_ba.getsound('error'))
if results.warnings:
allwarnings = textwrap.indent(
'\n'.join(results.warnings), 'Warning (meta-scan): '
)
logging.warning(allwarnings)
if results.errors:
allerrors = textwrap.indent(
'\n'.join(results.errors), 'Error (meta-scan): '
)
logging.error(allerrors)
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
@ -248,7 +267,6 @@ class DirectoryScan:
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:
@ -261,11 +279,7 @@ class DirectoryScan:
try:
self._scan_module(moduledir, subpath)
except Exception:
import traceback
self.results.warnings.append(
f"Error scanning '{subpath}': " + traceback.format_exc()
)
logging.exception("metascan: Error scanning '%s'.", subpath)
# Sort our results
for exportlist in self.results.exports.values():
@ -276,20 +290,22 @@ class DirectoryScan:
) -> None:
"""Scan provided path and add module entries to provided list."""
try:
# Special case: let's save some time and skip the whole 'ba'
# 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)
if name != 'ba'
# Actually scratch that for now; trying to avoid special cases.
# if name != 'babase'
]
except PermissionError:
# Expected sometimes.
entries = []
except Exception as exc:
except Exception:
# Unexpected; report this.
self.results.errors.append(str(exc))
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.
@ -328,10 +344,16 @@ class DirectoryScan:
# 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:
self.results.warnings += (
f'Warning: {subpath} requires api {required_api} but'
f' we are running {CURRENT_API_VERSION}; ignoring module.'
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
@ -347,11 +369,13 @@ class DirectoryScan:
if submodule[1].name != '__init__.py':
self._scan_module(submodule[0], submodule[1])
except Exception:
import traceback
logging.exception('metascan: Error scanning %s.', subpath)
self.results.warnings.append(
f"Error scanning '{subpath}': {traceback.format_exc()}"
)
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]]
@ -361,10 +385,12 @@ class DirectoryScan:
# meta_lines is just anything containing '# ba_meta '; make sure
# the ba_meta is in the right place.
if mline[0] != 'ba_meta':
self.results.warnings.append(
f'Warning: {subpath}:'
f' malformed ba_meta statement on line {lindex + 1}.'
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'
):
@ -373,15 +399,15 @@ class DirectoryScan:
elif len(mline) != 3 or mline[1] != 'export':
# Currently we only support 'ba_meta export FOO';
# complain for anything else we see.
self.results.warnings.append(
f'Warning: {subpath}'
f': unrecognized ba_meta statement on line {lindex + 1}.'
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 = '.'.join(subpath.parts)
if subpath.name.endswith('.py'):
modulename = modulename[:-3]
modulename = self._module_name_for_subpath(subpath)
exporttypestr = mline[2]
export_class_name = self._get_export_class_name(
subpath, flines, lindex
@ -389,15 +415,30 @@ class DirectoryScan:
if export_class_name is not None:
classname = modulename + '.' + export_class_name
# 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
)
# 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
@ -420,10 +461,13 @@ class DirectoryScan:
classname = cbits[0]
break # Success!
if classname is None:
self.results.warnings.append(
f'Warning: {subpath}: class definition not found below'
f' "ba_meta export" statement on line {lindexorig + 1}.'
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(
@ -446,23 +490,26 @@ class DirectoryScan:
and l[3].isdigit()
]
# We're successful if we find exactly one properly formatted line.
# 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:
self.results.warnings.append(
f'Warning: {subpath}: multiple'
' "# ba_meta require api <NUM>" lines found;'
' ignoring module.'
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.
self.results.warnings.append(
f'Warning: {subpath}:'
' no valid "# ba_meta require api <NUM>" line found;'
' ignoring module.'
# 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

@ -152,7 +152,7 @@ class SpecialChar(Enum):
TROPHY0B = 37
TROPHY4 = 38
LOCAL_ACCOUNT = 39
ALIBABA_LOGO = 40
EXPLODINARY_LOGO = 40
FLAG_UNITED_STATES = 41
FLAG_MEXICO = 42
FLAG_GERMANY = 43

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)

View file

@ -13,14 +13,14 @@ from typing import TYPE_CHECKING
from efro.call import tpartial
from efro.error import CleanError
import _ba
import _babase
import bacommon.cloud
from bacommon.transfer import DirectoryManifest
if TYPE_CHECKING:
from typing import Callable
import ba
import babase
class WorkspaceSubsystem:
@ -36,7 +36,7 @@ class WorkspaceSubsystem:
def set_active_workspace(
self,
account: ba.AccountV2Handle,
account: babase.AccountV2Handle,
workspaceid: str,
workspacename: str,
on_completed: Callable[[], None],
@ -55,36 +55,38 @@ class WorkspaceSubsystem:
daemon=True,
).start()
def _errmsg(self, msg: ba.Lstr) -> None:
_ba.screenmessage(msg, color=(1, 0, 0))
_ba.playsound(_ba.getsound('error'))
def _errmsg(self, msg: babase.Lstr) -> None:
_babase.screenmessage(msg, color=(1, 0, 0))
_babase.getsimplesound('error').play()
def _successmsg(self, msg: ba.Lstr) -> None:
_ba.screenmessage(msg, color=(0, 1, 0))
_ba.playsound(_ba.getsound('gunCocking'))
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: ba.AccountV2Handle,
account: babase.AccountV2Handle,
workspaceid: str,
workspacename: str,
on_completed: Callable[[], None],
) -> None:
from ba._language import Lstr
from babase._language import Lstr
class _SkipSyncError(RuntimeError):
pass
plus = _babase.app.plus
assert plus is not None
set_path = True
wspath = Path(
_ba.get_volatile_data_directory(), 'workspaces', workspaceid
_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 _ba.app.cloud.is_connected():
if not plus.cloud.is_connected():
raise _SkipSyncError()
manifest = DirectoryManifest.create_from_disk(wspath)
@ -95,7 +97,7 @@ class WorkspaceSubsystem:
while True:
with account:
response = _ba.app.cloud.send_message(
response = plus.cloud.send_message(
bacommon.cloud.WorkspaceFetchMessage(
workspaceid=workspaceid, state=state
)
@ -115,7 +117,7 @@ class WorkspaceSubsystem:
break
state.iteration += 1
_ba.pushcall(
_babase.pushcall(
tpartial(
self._successmsg,
Lstr(
@ -127,7 +129,7 @@ class WorkspaceSubsystem:
)
except _SkipSyncError:
_ba.pushcall(
_babase.pushcall(
tpartial(
self._errmsg,
Lstr(
@ -142,7 +144,7 @@ class WorkspaceSubsystem:
# Avoid reusing existing if we fail in the middle; could
# be in wonky state.
set_path = False
_ba.pushcall(
_babase.pushcall(
tpartial(self._errmsg, Lstr(value=str(exc))),
from_other_thread=True,
)
@ -150,7 +152,7 @@ class WorkspaceSubsystem:
# Ditto.
set_path = False
logging.exception("Error syncing workspace '%s'.", workspacename)
_ba.pushcall(
_babase.pushcall(
tpartial(
self._errmsg,
Lstr(
@ -165,10 +167,10 @@ class WorkspaceSubsystem:
# Add to Python paths and also to list of stuff to be scanned
# for meta tags.
sys.path.insert(0, str(wspath))
_ba.app.meta.extra_scan_dirs.append(str(wspath))
_babase.app.meta.extra_scan_dirs.append(str(wspath))
# Job's done!
_ba.pushcall(on_completed, from_other_thread=True)
_babase.pushcall(on_completed, from_other_thread=True)
def _handle_deletes(self, workspace_dir: Path, deletes: list[str]) -> None:
"""Handle file deletes."""

View file

@ -6,7 +6,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import os
import _ba
import _babase
if TYPE_CHECKING:
from typing import Sequence
@ -17,7 +17,7 @@ def get_human_readable_user_scripts_path() -> str:
This is NOT a valid filesystem path; may be something like "(SD Card)".
"""
app = _ba.app
app = _babase.app
path: str | None = app.python_directory_user
if path is None:
return '<Not Available>'
@ -35,7 +35,7 @@ def get_human_readable_user_scripts_path() -> str:
# 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.platform == 'android':
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)
@ -45,27 +45,37 @@ def get_human_readable_user_scripts_path() -> str:
def _request_storage_permission() -> bool:
"""If needed, requests storage permission from the user (& return true)."""
from ba._language import Lstr
from ba._generated.enums import Permission
from babase._language import Lstr
if not _ba.have_permission(Permission.STORAGE):
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
# 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)
)
_ba.timer(1.0, lambda: _ba.request_permission(Permission.STORAGE))
_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 = _ba.app
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)
@ -76,7 +86,7 @@ def show_user_scripts() -> None:
# 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.platform == 'android':
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):
@ -89,32 +99,38 @@ def show_user_scripts() -> None:
)
except Exception:
from ba import _error
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.platform in ['mac', 'windows']:
_ba.open_dir_externally(app.python_directory_user)
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:
_ba.screenmessage(get_human_readable_user_scripts_path())
_babase.screenmessage(get_human_readable_user_scripts_path())
def create_user_system_scripts() -> None:
"""Set up a copy of Ballistica system scripts under your user scripts dir.
"""Set up a copy of Ballistica app scripts under user scripts dir.
(for editing and experiment with)
(for editing and experimenting)
"""
import shutil
app = _ba.app
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):
@ -138,10 +154,10 @@ def create_user_system_scripts() -> None:
shutil.move(pathtmp, path)
print(
f"Created system scripts at :'{path}"
f"'\nRestart {_ba.appname()} to use them."
f' (use ba.quit() to exit the game)'
f"'\nRestart {_babase.appname()} to use them."
f' (use babase.quit() to exit the game)'
)
if app.platform == 'android':
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.'
@ -152,17 +168,21 @@ def delete_user_system_scripts() -> None:
"""Clean out the scripts created by create_user_system_scripts()."""
import shutil
app = _ba.app
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 {_ba.appname()} to use internal'
f' scripts. (use ba.quit() to exit the game)'
f'Restart {_babase.appname()} to use internal'
f' scripts. (use babase.quit() to exit the game)'
)
else:
print('User system scripts not found.')
print(f"User system scripts not found at '{path}'.")
# If the sys path is empty, kill it.
dpath = app.python_directory_user + '/sys'

View file

@ -0,0 +1,48 @@
# Released under the MIT License. See LICENSE for details.
#
"""Classic ballistica components.
This package is used as a 'dumping ground' for functionality that is
necessary to keep legacy parts of the app working, but which may no
longer be the best way to do things going forward.
New code should try to avoid using code from here when possible.
Functionality in this package should be exposed through the
ClassicSubsystem. This allows type-checked code to go through the
babase.app.classic singleton which forces it to explicitly handle the
possibility of babase.app.classic being None. When code instead imports
classic submodules directly, it is much harder to make it cleanly handle
classic not being present.
"""
# ba_meta require api 8
# Note: Code relying on classic should import things from here *only*
# for type-checking and use the versions in app.classic at runtime; that
# way type-checking will cleanly cover the classic-not-present case
# (app.classic being None).
import logging
from baclassic._subsystem import ClassicSubsystem
from baclassic._achievement import Achievement, AchievementSubsystem
__all__ = [
'ClassicSubsystem',
'Achievement',
'AchievementSubsystem',
]
# Sanity check: we want to keep ballistica's dependencies and
# bootstrapping order clearly defined; let's check a few particular
# modules to make sure they never directly or indirectly import us
# before their own execs complete.
if __debug__:
for _mdl in 'babase', '_babase':
if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'):
logging.warning(
'%s was imported before %s finished importing;'
' should not happen.',
__name__,
_mdl,
)

View file

@ -8,8 +8,7 @@ import copy
import time
from typing import TYPE_CHECKING
import _ba
from ba import _internal
import babase
if TYPE_CHECKING:
from typing import Any
@ -20,7 +19,7 @@ class AccountV1Subsystem:
Category: **App Classes**
Access the single shared instance of this class at 'ba.app.accounts_v1'.
Access the single instance of this class at 'ba.app.classic.accounts'.
"""
def __init__(self) -> None:
@ -35,18 +34,20 @@ class AccountV1Subsystem:
# not be signed in yet; go ahead and queue them up in that case.
self.pending_promo_codes: list[str] = []
def on_app_launch(self) -> None:
def on_app_loading(self) -> None:
"""Called when the app is done bootstrapping."""
# Auto-sign-in to a local account in a moment if we're set to.
def do_auto_sign_in() -> None:
if babase.app.plus is None:
return
if (
_ba.app.headless_mode
or _ba.app.config.get('Auto Account State') == 'Local'
babase.app.headless_mode
or babase.app.config.get('Auto Account State') == 'Local'
):
_internal.sign_in_v1('Local')
babase.app.plus.sign_in_v1('Local')
_ba.pushcall(do_auto_sign_in)
babase.pushcall(do_auto_sign_in)
def on_app_pause(self) -> None:
"""Should be called when app is pausing."""
@ -64,16 +65,14 @@ class AccountV1Subsystem:
(internal)
"""
from ba._language import Lstr
_ba.screenmessage(
Lstr(
babase.screenmessage(
babase.Lstr(
resource='getTicketsWindow.receivedTicketsText',
subs=[('${COUNT}', str(count))],
),
color=(0, 1, 0),
)
_ba.playsound(_ba.getsound('cashRegister'))
babase.getsimplesound('cashRegister').play()
def cache_league_rank_data(self, data: Any) -> None:
"""(internal)"""
@ -96,7 +95,8 @@ class AccountV1Subsystem:
total_ach_value = data['at']
else:
total_ach_value = 0
for ach in _ba.app.ach.achievements:
assert babase.app.classic is not None
for ach in babase.app.classic.ach.achievements:
if ach.complete:
total_ach_value += ach.power_ranking_value
@ -126,15 +126,18 @@ class AccountV1Subsystem:
raise ValueError('invalid subset value: ' + str(subset))
if data['p']:
pro_mult = (
1.0
+ float(
_internal.get_v1_account_misc_read_val(
'proPowerRankingBoost', 0.0
if babase.app.plus is None:
pro_mult = 1.0
else:
pro_mult = (
1.0
+ float(
babase.app.plus.get_v1_account_misc_read_val(
'proPowerRankingBoost', 0.0
)
)
* 0.01
)
* 0.01
)
else:
pro_mult = 1.0
@ -147,7 +150,6 @@ class AccountV1Subsystem:
def cache_tournament_info(self, info: Any) -> None:
"""(internal)"""
from ba._generated.enums import TimeType, TimeFormat
for entry in info:
cache_entry = self.tournament_info[
@ -156,24 +158,25 @@ class AccountV1Subsystem:
# Also store the time we received this, so we can adjust
# time-remaining values/etc.
cache_entry['timeReceived'] = _ba.time(
TimeType.REAL, TimeFormat.MILLISECONDS
)
cache_entry['timeReceived'] = babase.apptime()
cache_entry['valid'] = True
def get_purchased_icons(self) -> list[str]:
"""(internal)"""
# pylint: disable=cyclic-import
from ba import _store
if _internal.get_v1_account_state() != 'signed_in':
plus = babase.app.plus
if plus is None:
return []
if plus.get_v1_account_state() != 'signed_in':
return []
icons = []
store_items = _store.get_store_items()
store_items: dict[str, Any] = (
babase.app.classic.store.get_store_items()
if babase.app.classic is not None
else {}
)
for item_name, item in list(store_items.items()):
if item_name.startswith('icons.') and _internal.get_purchased(
item_name
):
if item_name.startswith('icons.') and plus.get_purchased(item_name):
icons.append(item['icon'])
return icons
@ -184,25 +187,27 @@ class AccountV1Subsystem:
(internal)
"""
plus = babase.app.plus
if plus is None:
return
# This only applies when we're signed in.
if _internal.get_v1_account_state() != 'signed_in':
if plus.get_v1_account_state() != 'signed_in':
return
# If the short version of our account name currently cant be
# displayed by the game, cancel.
if not _ba.have_chars(
_internal.get_v1_account_display_string(full=False)
if not babase.have_chars(
plus.get_v1_account_display_string(full=False)
):
return
config = _ba.app.config
config = babase.app.config
if (
'Player Profiles' not in config
or '__account__' not in config['Player Profiles']
):
# Create a spaz with a nice default purply color.
_internal.add_transaction(
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYER_PROFILE',
'name': '__account__',
@ -213,18 +218,22 @@ class AccountV1Subsystem:
},
}
)
_internal.run_transactions()
plus.run_v1_account_transactions()
def have_pro(self) -> bool:
"""Return whether pro is currently unlocked."""
plus = babase.app.plus
if plus is None:
return False
# Check our tickets-based pro upgrade and our two real-IAP based
# upgrades. Also always unlock this stuff in ballistica-core builds.
return bool(
_internal.get_purchased('upgrades.pro')
or _internal.get_purchased('static.pro')
or _internal.get_purchased('static.pro_sale')
or 'ballistica' + 'core' == _ba.appname()
plus.get_purchased('upgrades.pro')
or plus.get_purchased('static.pro')
or plus.get_purchased('static.pro_sale')
or 'ballistica' + 'kit' == babase.appname()
)
def have_pro_options(self) -> bool:
@ -234,70 +243,75 @@ class AccountV1Subsystem:
before Pro was a requirement for these options.
"""
plus = babase.app.plus
if plus is None:
return False
# We expose pro options if the server tells us to
# (which is generally just when we own pro),
# or also if we've been grandfathered in
# or are using ballistica-core builds.
return self.have_pro() or bool(
_internal.get_v1_account_misc_read_val_2(
'proOptionsUnlocked', False
)
or _ba.app.config.get('lc14292', 0) > 1
plus.get_v1_account_misc_read_val_2('proOptionsUnlocked', False)
or babase.app.config.get('lc14292', 0) > 1
)
def show_post_purchase_message(self) -> None:
"""(internal)"""
from ba._language import Lstr
from ba._generated.enums import TimeType
cur_time = _ba.time(TimeType.REAL)
cur_time = babase.apptime()
if (
self.last_post_purchase_message_time is None
or cur_time - self.last_post_purchase_message_time > 3.0
):
self.last_post_purchase_message_time = cur_time
with _ba.Context('ui'):
_ba.screenmessage(
Lstr(
resource='updatingAccountText',
fallback_resource='purchasingText',
),
color=(0, 1, 0),
)
_ba.playsound(_ba.getsound('click01'))
babase.screenmessage(
babase.Lstr(
resource='updatingAccountText',
fallback_resource='purchasingText',
),
color=(0, 1, 0),
)
babase.getsimplesound('click01').play()
def on_account_state_changed(self) -> None:
"""(internal)"""
from ba._language import Lstr
plus = babase.app.plus
if plus is None:
return
# Run any pending promo codes we had queued up while not signed in.
if (
_internal.get_v1_account_state() == 'signed_in'
plus.get_v1_account_state() == 'signed_in'
and self.pending_promo_codes
):
for code in self.pending_promo_codes:
_ba.screenmessage(
Lstr(resource='submittingPromoCodeText'), color=(0, 1, 0)
babase.screenmessage(
babase.Lstr(resource='submittingPromoCodeText'),
color=(0, 1, 0),
)
_internal.add_transaction(
plus.add_v1_account_transaction(
{
'type': 'PROMO_CODE',
'expire_time': time.time() + 5,
'code': code,
}
)
_internal.run_transactions()
plus.run_v1_account_transactions()
self.pending_promo_codes = []
def add_pending_promo_code(self, code: str) -> None:
"""(internal)"""
from ba._language import Lstr
from ba._generated.enums import TimeType
plus = babase.app.plus
if plus is None:
babase.screenmessage(
babase.Lstr(resource='errorText'), color=(1, 0, 0)
)
babase.getsimplesound('error').play()
return
# If we're not signed in, queue up the code to run the next time we
# are and issue a warning if we haven't signed in within the next
# few seconds.
if _internal.get_v1_account_state() != 'signed_in':
if plus.get_v1_account_state() != 'signed_in':
def check_pending_codes() -> None:
"""(internal)"""
@ -305,18 +319,19 @@ class AccountV1Subsystem:
# If we're still not signed in and have pending codes,
# inform the user that they need to sign in to use them.
if self.pending_promo_codes:
_ba.screenmessage(
Lstr(resource='signInForPromoCodeText'), color=(1, 0, 0)
babase.screenmessage(
babase.Lstr(resource='signInForPromoCodeText'),
color=(1, 0, 0),
)
_ba.playsound(_ba.getsound('error'))
babase.getsimplesound('error').play()
self.pending_promo_codes.append(code)
_ba.timer(6.0, check_pending_codes, timetype=TimeType.REAL)
babase.apptimer(6.0, check_pending_codes)
return
_ba.screenmessage(
Lstr(resource='submittingPromoCodeText'), color=(0, 1, 0)
babase.screenmessage(
babase.Lstr(resource='submittingPromoCodeText'), color=(0, 1, 0)
)
_internal.add_transaction(
plus.add_v1_account_transaction(
{'type': 'PROMO_CODE', 'expire_time': time.time() + 5, 'code': code}
)
_internal.run_transactions()
plus.run_v1_account_transactions()

View file

@ -3,15 +3,17 @@
"""Various functionality related to achievements."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import _ba
from ba import _internal
from ba._error import print_exception
import babase
import bascenev1
import bauiv1
if TYPE_CHECKING:
from typing import Any, Sequence
import ba
import baclassic
# This could use some cleanup.
# We wear the cone of shame.
@ -73,8 +75,10 @@ class AchievementSubsystem:
def __init__(self) -> None:
self.achievements: list[Achievement] = []
self.achievements_to_display: (list[tuple[ba.Achievement, bool]]) = []
self.achievement_display_timer: _ba.Timer | None = None
self.achievements_to_display: (
list[tuple[baclassic.Achievement, bool]]
) = []
self.achievement_display_timer: bascenev1.BaseTimer | None = None
self.last_achievement_display_time: float = 0.0
self.achievement_completion_banner_slots: set[int] = set()
self._init_achievements()
@ -508,15 +512,18 @@ class AchievementSubsystem:
def award_local_achievement(self, achname: str) -> None:
"""For non-game-based achievements such as controller-connection."""
plus = babase.app.plus
if plus is None:
logging.warning('achievements require plus feature-set')
return
try:
ach = self.get_achievement(achname)
if not ach.complete:
# Report new achievements to the game-service.
_internal.report_achievement(achname)
plus.report_achievement(achname)
# And to our account.
_internal.add_transaction(
plus.add_v1_account_transaction(
{'type': 'ACHIEVEMENT', 'name': achname}
)
@ -524,7 +531,7 @@ class AchievementSubsystem:
self.display_achievement_banner(achname)
except Exception:
print_exception()
logging.exception('Error in award_local_achievement.')
def display_achievement_banner(self, achname: str) -> None:
"""Display a completion banner for an achievement.
@ -538,12 +545,12 @@ class AchievementSubsystem:
# purely local context somehow instead of trying to inject these
# into whatever activity happens to be active
# (since that won't work while in client mode).
activity = _ba.get_foreground_host_activity()
activity = bascenev1.get_foreground_host_activity()
if activity is not None:
with _ba.Context(activity):
with activity.context:
self.get_achievement(achname).announce_completion()
except Exception:
print_exception('error showing server ach')
logging.exception('Error in display_achievement_banner.')
def set_completed_achievements(self, achs: Sequence[str]) -> None:
"""Set the current state of completed achievements.
@ -557,7 +564,7 @@ class AchievementSubsystem:
# us which achievements we currently have. We always defer to them,
# even if that means we have to un-set an achievement we think we have.
cfg = _ba.app.config
cfg = babase.app.config
cfg['Achievements'] = {}
for a_name in achs:
self.get_achievement(a_name).set_complete(True)
@ -586,7 +593,6 @@ class AchievementSubsystem:
def _test(self) -> None:
"""For testing achievement animations."""
from ba._generated.enums import TimeType
def testcall1() -> None:
self.achievements[0].announce_completion()
@ -598,8 +604,8 @@ class AchievementSubsystem:
self.achievements[4].announce_completion()
self.achievements[5].announce_completion()
_ba.timer(3.0, testcall1, timetype=TimeType.BASE)
_ba.timer(7.0, testcall2, timetype=TimeType.BASE)
bascenev1.basetimer(3.0, testcall1)
bascenev1.basetimer(7.0, testcall2)
def _get_ach_mult(include_pro_bonus: bool = False) -> int:
@ -607,28 +613,33 @@ def _get_ach_mult(include_pro_bonus: bool = False) -> int:
(just for display; changing this here won't affect actual rewards)
"""
val: int = _internal.get_v1_account_misc_read_val('achAwardMult', 5)
plus = babase.app.plus
classic = babase.app.classic
if plus is None or classic is None:
return 5
val: int = plus.get_v1_account_misc_read_val('achAwardMult', 5)
assert isinstance(val, int)
if include_pro_bonus and _ba.app.accounts_v1.have_pro():
if include_pro_bonus and classic.accounts.have_pro():
val *= 2
return val
def _display_next_achievement() -> None:
# Pull the first achievement off the list and display it, or kill the
# display-timer if the list is empty.
app = _ba.app
if app.ach.achievements_to_display:
app = babase.app
assert app.classic is not None
ach_ss = app.classic.ach
if app.classic.ach.achievements_to_display:
try:
ach, sound = app.ach.achievements_to_display.pop(0)
ach, sound = ach_ss.achievements_to_display.pop(0)
ach.show_completion_banner(sound)
except Exception:
print_exception('error showing next achievement')
app.ach.achievements_to_display = []
app.ach.achievement_display_timer = None
logging.exception('Error in _display_next_achievement.')
ach_ss.achievements_to_display = []
ach_ss.achievement_display_timer = None
else:
app.ach.achievement_display_timer = None
ach_ss.achievement_display_timer = None
class Achievement:
@ -664,9 +675,15 @@ class Achievement:
"""The name of the level this achievement applies to."""
return self._level_name
def get_icon_texture(self, complete: bool) -> ba.Texture:
def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
"""Return the icon texture to display for this achievement"""
return _ba.gettexture(
return bauiv1.gettexture(
self._icon_name if complete else 'achievementEmpty'
)
def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
"""Return the icon texture to display for this achievement"""
return bascenev1.gettexture(
self._icon_name if complete else 'achievementEmpty'
)
@ -690,20 +707,26 @@ class Achievement:
def announce_completion(self, sound: bool = True) -> None:
"""Kick off an announcement for this achievement's completion."""
from ba._generated.enums import TimeType
app = _ba.app
app = babase.app
plus = app.plus
classic = app.classic
if plus is None or classic is None:
logging.warning('ach account_completion not available.')
return
ach_ss = classic.ach
# Even though there are technically achievements when we're not
# signed in, lets not show them (otherwise we tend to get
# confusing 'controller connected' achievements popping up while
# waiting to sign in which can be confusing).
if _internal.get_v1_account_state() != 'signed_in':
if plus.get_v1_account_state() != 'signed_in':
return
# If we're being freshly complete, display/report it and whatnot.
if (self, sound) not in app.ach.achievements_to_display:
app.ach.achievements_to_display.append((self, sound))
if (self, sound) not in ach_ss.achievements_to_display:
ach_ss.achievements_to_display.append((self, sound))
# If there's no achievement display timer going, kick one off
# (if one's already running it will pick this up before it dies).
@ -711,15 +734,11 @@ class Achievement:
# Need to check last time too; its possible our timer wasn't able to
# clear itself if an activity died and took it down with it.
if (
app.ach.achievement_display_timer is None
or _ba.time(TimeType.REAL) - app.ach.last_achievement_display_time
> 2.0
) and _ba.getactivity(doraise=False) is not None:
app.ach.achievement_display_timer = _ba.Timer(
1.0,
_display_next_achievement,
repeat=True,
timetype=TimeType.BASE,
ach_ss.achievement_display_timer is None
or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
) and bascenev1.getactivity(doraise=False) is not None:
ach_ss.achievement_display_timer = bascenev1.BaseTimer(
1.0, _display_next_achievement, repeat=True
)
# Show the first immediately.
@ -736,18 +755,16 @@ class Achievement:
config['Complete'] = complete
@property
def display_name(self) -> ba.Lstr:
"""Return a ba.Lstr for this Achievement's name."""
from ba._language import Lstr
name: ba.Lstr | str
def display_name(self) -> babase.Lstr:
"""Return a babase.Lstr for this Achievement's name."""
name: babase.Lstr | str
try:
if self._level_name != '':
from ba._campaign import getcampaign
campaignname, campaign_level = self._level_name.split(':')
classic = babase.app.classic
assert classic is not None
name = (
getcampaign(campaignname)
classic.getcampaign(campaignname)
.getlevel(campaign_level)
.displayname
)
@ -755,51 +772,49 @@ class Achievement:
name = ''
except Exception:
name = ''
print_exception()
return Lstr(
logging.exception('Error calcing achievement display-name.')
return babase.Lstr(
resource='achievements.' + self._name + '.name',
subs=[('${LEVEL}', name)],
)
@property
def description(self) -> ba.Lstr:
"""Get a ba.Lstr for the Achievement's brief description."""
from ba._language import Lstr
def description(self) -> babase.Lstr:
"""Get a babase.Lstr for the Achievement's brief description."""
if (
'description'
in _ba.app.lang.get_resource('achievements')[self._name]
in babase.app.lang.get_resource('achievements')[self._name]
):
return Lstr(resource='achievements.' + self._name + '.description')
return Lstr(resource='achievements.' + self._name + '.descriptionFull')
return babase.Lstr(
resource='achievements.' + self._name + '.description'
)
return babase.Lstr(
resource='achievements.' + self._name + '.descriptionFull'
)
@property
def description_complete(self) -> ba.Lstr:
"""Get a ba.Lstr for the Achievement's description when completed."""
from ba._language import Lstr
def description_complete(self) -> babase.Lstr:
"""Get a babase.Lstr for the Achievement's description when complete."""
if (
'descriptionComplete'
in _ba.app.lang.get_resource('achievements')[self._name]
in babase.app.lang.get_resource('achievements')[self._name]
):
return Lstr(
return babase.Lstr(
resource='achievements.' + self._name + '.descriptionComplete'
)
return Lstr(
return babase.Lstr(
resource='achievements.' + self._name + '.descriptionFullComplete'
)
@property
def description_full(self) -> ba.Lstr:
"""Get a ba.Lstr for the Achievement's full description."""
from ba._language import Lstr
return Lstr(
def description_full(self) -> babase.Lstr:
"""Get a babase.Lstr for the Achievement's full description."""
return babase.Lstr(
resource='achievements.' + self._name + '.descriptionFull',
subs=[
(
'${LEVEL}',
Lstr(
babase.Lstr(
translate=(
'coopLevelNames',
ACH_LEVEL_NAMES.get(self._name, '?'),
@ -810,16 +825,14 @@ class Achievement:
)
@property
def description_full_complete(self) -> ba.Lstr:
"""Get a ba.Lstr for the Achievement's full desc. when completed."""
from ba._language import Lstr
return Lstr(
def description_full_complete(self) -> babase.Lstr:
"""Get a babase.Lstr for the Achievement's full desc. when completed."""
return babase.Lstr(
resource='achievements.' + self._name + '.descriptionFullComplete',
subs=[
(
'${LEVEL}',
Lstr(
babase.Lstr(
translate=(
'coopLevelNames',
ACH_LEVEL_NAMES.get(self._name, '?'),
@ -831,7 +844,10 @@ class Achievement:
def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
"""Get the ticket award value for this achievement."""
val: int = _internal.get_v1_account_misc_read_val(
plus = babase.app.plus
if plus is None:
return 0
val: int = plus.get_v1_account_misc_read_val(
'achAward.' + self._name, self._award
) * _get_ach_mult(include_pro_bonus)
assert isinstance(val, int)
@ -840,7 +856,10 @@ class Achievement:
@property
def power_ranking_value(self) -> int:
"""Get the power-ranking award value for this achievement."""
val: int = _internal.get_v1_account_misc_read_val(
plus = babase.app.plus
if plus is None:
return 0
val: int = plus.get_v1_account_misc_read_val(
'achLeaguePoints.' + self._name, self._award
)
assert isinstance(val, int)
@ -854,17 +873,15 @@ class Achievement:
outdelay: float | None = None,
color: Sequence[float] | None = None,
style: str = 'post_game',
) -> list[ba.Actor]:
) -> list[bascenev1.Actor]:
"""Create a display for the Achievement.
Shows the Achievement icon, name, and description.
"""
# pylint: disable=cyclic-import
from ba._language import Lstr
from ba._generated.enums import SpecialChar
from ba._coopsession import CoopSession
from bastd.actor.image import Image
from bastd.actor.text import Text
from bascenev1 import CoopSession
from bascenev1lib.actor.image import Image
from bascenev1lib.actor.text import Text
# Yeah this needs cleaning up.
if style == 'post_game':
@ -894,7 +911,7 @@ class Achievement:
hmo = False
else:
try:
session = _ba.getsession()
session = bascenev1.getsession()
if isinstance(session, CoopSession):
campaign = session.campaign
assert campaign is not None
@ -902,10 +919,10 @@ class Achievement:
else:
hmo = False
except Exception:
print_exception('Error determining campaign.')
logging.exception('Error determining campaign.')
hmo = False
objs: list[ba.Actor]
objs: list[bascenev1.Actor]
if in_game_colors:
objs = []
@ -978,7 +995,7 @@ class Achievement:
if hmo:
txtactor = Text(
Lstr(resource='difficultyHardOnlyText'),
babase.Lstr(resource='difficultyHardOnlyText'),
host_only=True,
maxwidth=txt2_max_w * 0.7,
position=(x + 60, y + 5),
@ -1002,7 +1019,7 @@ class Achievement:
award_x = -100
objs.append(
Text(
_ba.charstr(SpecialChar.TICKET),
babase.charstr(babase.SpecialChar.TICKET),
host_only=True,
position=(x + award_x + 33, y + 7),
transition=Text.Transition.FADE_IN,
@ -1057,9 +1074,11 @@ class Achievement:
if complete:
objs.append(
Image(
_ba.gettexture('achievementOutline'),
bascenev1.gettexture('achievementOutline'),
host_only=True,
model_transparent=_ba.getmodel('achievementOutline'),
mesh_transparent=bascenev1.getmesh(
'achievementOutline'
),
color=(2, 1.4, 0.4, 1),
vr_depth=8,
position=(x - 25, y + 5),
@ -1075,7 +1094,7 @@ class Achievement:
award_x = -100
objs.append(
Text(
_ba.charstr(SpecialChar.TICKET),
babase.charstr(babase.SpecialChar.TICKET),
host_only=True,
position=(x + award_x + 33, y + 7),
transition=Text.Transition.IN_RIGHT,
@ -1084,9 +1103,7 @@ class Achievement:
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(1, 1, 1, 0.4)
if complete
else (1, 1, 1, (0.1 if hmo else 0.2)),
color=(1, 1, 1, (0.1 if hmo else 0.2)),
transition_delay=delay + 0.05,
transition_out_delay=None,
).autoretain()
@ -1103,11 +1120,7 @@ class Achievement:
v_attach=v_attach,
h_align=Text.HAlign.CENTER,
v_align=Text.VAlign.CENTER,
color=(
(0.8, 0.93, 0.8, 1.0)
if complete
else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
),
color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
transition_delay=delay + 0.05,
transition_out_delay=None,
).autoretain()
@ -1117,7 +1130,7 @@ class Achievement:
# when that's the case.
if hmo:
txtactor = Text(
Lstr(resource='difficultyHardOnlyText'),
babase.Lstr(resource='difficultyHardOnlyText'),
host_only=True,
maxwidth=300 * 0.7,
position=(x + 60, y + 5),
@ -1186,35 +1199,33 @@ class Achievement:
Return the sub-dict in settings where this achievement's
state is stored, creating it if need be.
"""
val: dict[str, Any] = _ba.app.config.setdefault(
val: dict[str, Any] = babase.app.config.setdefault(
'Achievements', {}
).setdefault(self._name, {'Complete': False})
assert isinstance(val, dict)
return val
def _remove_banner_slot(self) -> None:
classic = babase.app.classic
assert classic is not None
assert self._completion_banner_slot is not None
_ba.app.ach.achievement_completion_banner_slots.remove(
classic.ach.achievement_completion_banner_slots.remove(
self._completion_banner_slot
)
self._completion_banner_slot = None
def show_completion_banner(self, sound: bool = True) -> None:
"""Create the banner/sound for an acquired achievement announcement."""
from ba import _gameutils
from bastd.actor.text import Text
from bastd.actor.image import Image
from ba._general import WeakCall
from ba._language import Lstr
from ba._messages import DieMessage
from ba._generated.enums import TimeType, SpecialChar
from bascenev1lib.actor.text import Text
from bascenev1lib.actor.image import Image
app = _ba.app
app.ach.last_achievement_display_time = _ba.time(TimeType.REAL)
app = babase.app
assert app.classic is not None
app.classic.ach.last_achievement_display_time = babase.apptime()
# Just piggy-back onto any current activity
# (should we use the session instead?..)
activity = _ba.getactivity(doraise=False)
activity = bascenev1.getactivity(doraise=False)
# If this gets called while this achievement is occupying a slot
# already, ignore it. (probably should never happen in real
@ -1227,10 +1238,10 @@ class Achievement:
return
if sound:
_ba.playsound(_ba.getsound('achievement'), host_only=True)
bascenev1.getsound('achievement').play(host_only=True)
else:
_ba.timer(
0.5, lambda: _ba.playsound(_ba.getsound('ding'), host_only=True)
bascenev1.timer(
0.5, lambda: bascenev1.getsound('ding').play(host_only=True)
)
in_time = 0.300
@ -1241,26 +1252,24 @@ class Achievement:
# Find the first free slot.
i = 0
while True:
if i not in app.ach.achievement_completion_banner_slots:
app.ach.achievement_completion_banner_slots.add(i)
if i not in app.classic.ach.achievement_completion_banner_slots:
app.classic.ach.achievement_completion_banner_slots.add(i)
self._completion_banner_slot = i
# Remove us from that slot when we close.
# Use a real-timer in the UI context so the removal runs even
# if our activity/session dies.
with _ba.Context('ui'):
_ba.timer(
in_time + out_time,
self._remove_banner_slot,
timetype=TimeType.REAL,
# Use an app-timer in an empty context so the removal
# runs even if our activity/session dies.
with babase.ContextRef.empty():
babase.apptimer(
in_time + out_time, self._remove_banner_slot
)
break
i += 1
assert self._completion_banner_slot is not None
y_offs = 110 * self._completion_banner_slot
objs: list[ba.Actor] = []
objs: list[bascenev1.Actor] = []
obj = Image(
_ba.gettexture('shadow'),
bascenev1.gettexture('shadow'),
position=(-30, 30 + y_offs),
front=True,
attach=Image.Attach.BOTTOM_CENTER,
@ -1275,7 +1284,7 @@ class Achievement:
assert obj.node
obj.node.host_only = True
obj = Image(
_ba.gettexture('light'),
bascenev1.gettexture('light'),
position=(-180, 60 + y_offs),
front=True,
attach=Image.Attach.BOTTOM_CENTER,
@ -1290,8 +1299,10 @@ class Achievement:
assert obj.node
obj.node.host_only = True
obj.node.premultiplied = True
combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 2})
_gameutils.animate(
combine = bascenev1.newnode(
'combine', owner=obj.node, attrs={'size': 2}
)
bascenev1.animate(
combine,
'input0',
{
@ -1302,7 +1313,7 @@ class Achievement:
in_time + 2.0: 0,
},
)
_gameutils.animate(
bascenev1.animate(
combine,
'input1',
{
@ -1314,7 +1325,7 @@ class Achievement:
},
)
combine.connectattr('output', obj.node, 'scale')
_gameutils.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
obj = Image(
self.get_icon_texture(True),
position=(-180, 60 + y_offs),
@ -1332,7 +1343,9 @@ class Achievement:
# Flash.
color = self.get_icon_color(True)
combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3})
combine = bascenev1.newnode(
'combine', owner=obj.node, attrs={'size': 3}
)
keys = {
in_time: 1.0 * color[0],
in_time + 0.4: 1.5 * color[0],
@ -1340,7 +1353,7 @@ class Achievement:
in_time + 0.6: 1.5 * color[0],
in_time + 2.0: 1.0 * color[0],
}
_gameutils.animate(combine, 'input0', keys)
bascenev1.animate(combine, 'input0', keys)
keys = {
in_time: 1.0 * color[1],
in_time + 0.4: 1.5 * color[1],
@ -1348,7 +1361,7 @@ class Achievement:
in_time + 0.6: 1.5 * color[1],
in_time + 2.0: 1.0 * color[1],
}
_gameutils.animate(combine, 'input1', keys)
bascenev1.animate(combine, 'input1', keys)
keys = {
in_time: 1.0 * color[2],
in_time + 0.4: 1.5 * color[2],
@ -1356,12 +1369,12 @@ class Achievement:
in_time + 0.6: 1.5 * color[2],
in_time + 2.0: 1.0 * color[2],
}
_gameutils.animate(combine, 'input2', keys)
bascenev1.animate(combine, 'input2', keys)
combine.connectattr('output', obj.node, 'color')
obj = Image(
_ba.gettexture('achievementOutline'),
model_transparent=_ba.getmodel('achievementOutline'),
bascenev1.gettexture('achievementOutline'),
mesh_transparent=bascenev1.getmesh('achievementOutline'),
position=(-180, 60 + y_offs),
front=True,
attach=Image.Attach.BOTTOM_CENTER,
@ -1376,7 +1389,9 @@ class Achievement:
# Flash.
color = (2, 1.4, 0.4, 1)
combine = _ba.newnode('combine', owner=obj.node, attrs={'size': 3})
combine = bascenev1.newnode(
'combine', owner=obj.node, attrs={'size': 3}
)
keys = {
in_time: 1.0 * color[0],
in_time + 0.4: 1.5 * color[0],
@ -1384,7 +1399,7 @@ class Achievement:
in_time + 0.6: 1.5 * color[0],
in_time + 2.0: 1.0 * color[0],
}
_gameutils.animate(combine, 'input0', keys)
bascenev1.animate(combine, 'input0', keys)
keys = {
in_time: 1.0 * color[1],
in_time + 0.4: 1.5 * color[1],
@ -1392,7 +1407,7 @@ class Achievement:
in_time + 0.6: 1.5 * color[1],
in_time + 2.0: 1.0 * color[1],
}
_gameutils.animate(combine, 'input1', keys)
bascenev1.animate(combine, 'input1', keys)
keys = {
in_time: 1.0 * color[2],
in_time + 0.4: 1.5 * color[2],
@ -1400,13 +1415,14 @@ class Achievement:
in_time + 0.6: 1.5 * color[2],
in_time + 2.0: 1.0 * color[2],
}
_gameutils.animate(combine, 'input2', keys)
bascenev1.animate(combine, 'input2', keys)
combine.connectattr('output', obj.node, 'color')
objs.append(obj)
objt = Text(
Lstr(
value='${A}:', subs=[('${A}', Lstr(resource='achievementText'))]
babase.Lstr(
value='${A}:',
subs=[('${A}', babase.Lstr(resource='achievementText'))],
),
position=(-120, 91 + y_offs),
front=True,
@ -1442,7 +1458,7 @@ class Achievement:
objt.node.host_only = True
objt = Text(
_ba.charstr(SpecialChar.TICKET),
babase.charstr(babase.SpecialChar.TICKET),
position=(-120 - 170 + 5, 75 + y_offs - 20),
front=True,
v_attach=Text.VAttach.BOTTOM,
@ -1482,7 +1498,7 @@ class Achievement:
objt.node.host_only = True
# Add the 'x 2' if we've got pro.
if app.accounts_v1.have_pro():
if app.classic.accounts.have_pro():
objt = Text(
'x 2',
position=(-120 - 180 + 45, 80 + y_offs - 50),
@ -1522,6 +1538,7 @@ class Achievement:
objt.node.host_only = True
for actor in objs:
_ba.timer(
out_time + 1.000, WeakCall(actor.handlemessage, DieMessage())
bascenev1.timer(
out_time + 1.000,
babase.WeakCall(actor.handlemessage, bascenev1.DieMessage()),
)

View file

@ -6,8 +6,9 @@ from __future__ import annotations
import time
from typing import TYPE_CHECKING
import _ba
from ba import _internal
import babase
import bauiv1
import bascenev1
if TYPE_CHECKING:
from typing import Callable, Any
@ -33,32 +34,34 @@ class AdsSubsystem:
def do_remove_in_game_ads_message(self) -> None:
"""(internal)"""
from ba._language import Lstr
from ba._generated.enums import TimeType
# Print this message once every 10 minutes at most.
tval = _ba.time(TimeType.REAL)
tval = babase.apptime()
if self.last_in_game_ad_remove_message_show_time is None or (
tval - self.last_in_game_ad_remove_message_show_time > 60 * 10
):
self.last_in_game_ad_remove_message_show_time = tval
with _ba.Context('ui'):
_ba.timer(
with babase.ContextRef.empty():
babase.apptimer(
1.0,
lambda: _ba.screenmessage(
Lstr(
lambda: babase.screenmessage(
babase.Lstr(
resource='removeInGameAdsText',
subs=[
(
'${PRO}',
Lstr(resource='store.bombSquadProNameText'),
babase.Lstr(
resource='store.bombSquadProNameText'
),
),
(
'${APP_NAME}',
babase.Lstr(resource='titleText'),
),
('${APP_NAME}', Lstr(resource='titleText')),
],
),
color=(1, 1, 0),
),
timetype=TimeType.REAL,
)
def show_ad(
@ -66,7 +69,7 @@ class AdsSubsystem:
) -> None:
"""(internal)"""
self.last_ad_purpose = purpose
_ba.show_ad(purpose, on_completion_call)
bauiv1.show_ad(purpose, on_completion_call)
def show_ad_2(
self,
@ -75,25 +78,28 @@ class AdsSubsystem:
) -> None:
"""(internal)"""
self.last_ad_purpose = purpose
_ba.show_ad_2(purpose, on_completion_call)
bauiv1.show_ad_2(purpose, on_completion_call)
def call_after_ad(self, call: Callable[[], Any]) -> None:
"""Run a call after potentially showing an ad."""
# pylint: disable=too-many-statements
# pylint: disable=too-many-branches
# pylint: disable=too-many-locals
from ba._generated.enums import TimeType
app = _ba.app
app = babase.app
plus = app.plus
classic = app.classic
assert plus is not None
assert classic is not None
show = True
# No ads without net-connections, etc.
if not _ba.can_show_ad():
if not bauiv1.can_show_ad():
show = False
if app.accounts_v1.have_pro():
if classic.accounts.have_pro():
show = False # Pro disables interstitials.
try:
session = _ba.get_foreground_host_session()
session = bascenev1.get_foreground_host_session()
assert session is not None
is_tournament = session.tournament_id is not None
except Exception:
@ -107,19 +113,17 @@ class AdsSubsystem:
# If we're seeing short ads we may want to space them differently.
interval_mult = (
_internal.get_v1_account_misc_read_val(
'ads.shortIntervalMult', 1.0
)
plus.get_v1_account_misc_read_val('ads.shortIntervalMult', 1.0)
if self.last_ad_was_short
else 1.0
)
if self.ad_amt is None:
if launch_count <= 1:
self.ad_amt = _internal.get_v1_account_misc_read_val(
self.ad_amt = plus.get_v1_account_misc_read_val(
'ads.startVal1', 0.99
)
else:
self.ad_amt = _internal.get_v1_account_misc_read_val(
self.ad_amt = plus.get_v1_account_misc_read_val(
'ads.startVal2', 1.0
)
interval = None
@ -128,23 +132,19 @@ class AdsSubsystem:
# ad-show-threshold and see if we should *actually* show
# (we reach our threshold faster the longer we've been
# playing).
base = 'ads' if _ba.has_video_ads() else 'ads2'
min_lc = _internal.get_v1_account_misc_read_val(
base + '.minLC', 0.0
)
max_lc = _internal.get_v1_account_misc_read_val(
base + '.maxLC', 5.0
)
min_lc_scale = _internal.get_v1_account_misc_read_val(
base = 'ads' if bauiv1.has_video_ads() else 'ads2'
min_lc = plus.get_v1_account_misc_read_val(base + '.minLC', 0.0)
max_lc = plus.get_v1_account_misc_read_val(base + '.maxLC', 5.0)
min_lc_scale = plus.get_v1_account_misc_read_val(
base + '.minLCScale', 0.25
)
max_lc_scale = _internal.get_v1_account_misc_read_val(
max_lc_scale = plus.get_v1_account_misc_read_val(
base + '.maxLCScale', 0.34
)
min_lc_interval = _internal.get_v1_account_misc_read_val(
min_lc_interval = plus.get_v1_account_misc_read_val(
base + '.minLCInterval', 360
)
max_lc_interval = _internal.get_v1_account_misc_read_val(
max_lc_interval = plus.get_v1_account_misc_read_val(
base + '.maxLCInterval', 300
)
if launch_count < min_lc:
@ -170,7 +170,7 @@ class AdsSubsystem:
self.last_ad_completion_time is None
or (
interval is not None
and _ba.time(TimeType.REAL) - self.last_ad_completion_time
and babase.apptime() - self.last_ad_completion_time
> (interval * interval_mult)
)
):
@ -192,34 +192,25 @@ class AdsSubsystem:
def run(self, fallback: bool = False) -> None:
"""Run fallback call (and issue a warning about it)."""
assert app.classic is not None
if not self._ran:
if fallback:
lanst = app.classic.ads.last_ad_network_set_time
print(
(
'ERROR: relying on fallback ad-callback! '
'last network: '
+ app.ads.last_ad_network
+ ' (set '
+ str(
int(
time.time()
- app.ads.last_ad_network_set_time
)
)
+ 's ago); purpose='
+ app.ads.last_ad_purpose
)
'ERROR: relying on fallback ad-callback! '
'last network: '
+ app.classic.ads.last_ad_network
+ ' (set '
+ str(int(time.time() - lanst))
+ 's ago); purpose='
+ app.classic.ads.last_ad_purpose
)
_ba.pushcall(self._call)
babase.pushcall(self._call)
self._ran = True
payload = _Payload(call)
with _ba.Context('ui'):
_ba.timer(
5.0,
lambda: payload.run(fallback=True),
timetype=TimeType.REAL,
)
with babase.ContextRef.empty():
babase.apptimer(5.0, lambda: payload.run(fallback=True))
self.show_ad('between_game', on_completion_call=payload.run)
else:
_ba.pushcall(call) # Just run the callback without the ad.
babase.pushcall(call) # Just run the callback without the ad.

View file

@ -6,7 +6,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
import babase
import bascenev1
if TYPE_CHECKING:
pass
@ -16,13 +17,17 @@ def game_begin_analytics() -> None:
"""Update analytics events for the start of a game."""
# pylint: disable=too-many-branches
# pylint: disable=cyclic-import
from ba._dualteamsession import DualTeamSession
from ba._freeforallsession import FreeForAllSession
from ba._coopsession import CoopSession
from ba._gameactivity import GameActivity
from bascenev1 import (
DualTeamSession,
FreeForAllSession,
CoopSession,
GameActivity,
)
activity = _ba.getactivity(False)
session = _ba.getsession(False)
assert babase.app.classic is not None
activity = bascenev1.getactivity(False)
session = bascenev1.getsession(False)
# Fail gracefully if we didn't cleanly get a session and game activity.
if not activity or not session or not isinstance(activity, GameActivity):
@ -31,53 +36,60 @@ def game_begin_analytics() -> None:
if isinstance(session, CoopSession):
campaign = session.campaign
assert campaign is not None
_ba.set_analytics_screen(
babase.set_analytics_screen(
'Coop Game: '
+ campaign.name
+ ' '
+ campaign.getlevel(_ba.app.coop_session_args['level']).name
+ campaign.getlevel(
babase.app.classic.coop_session_args['level']
).name
)
_ba.increment_analytics_count('Co-op round start')
babase.increment_analytics_count('Co-op round start')
if len(activity.players) == 1:
_ba.increment_analytics_count('Co-op round start 1 human player')
babase.increment_analytics_count('Co-op round start 1 human player')
elif len(activity.players) == 2:
_ba.increment_analytics_count('Co-op round start 2 human players')
babase.increment_analytics_count(
'Co-op round start 2 human players'
)
elif len(activity.players) == 3:
_ba.increment_analytics_count('Co-op round start 3 human players')
babase.increment_analytics_count(
'Co-op round start 3 human players'
)
elif len(activity.players) >= 4:
_ba.increment_analytics_count('Co-op round start 4+ human players')
babase.increment_analytics_count(
'Co-op round start 4+ human players'
)
elif isinstance(session, DualTeamSession):
_ba.set_analytics_screen('Teams Game: ' + activity.getname())
_ba.increment_analytics_count('Teams round start')
babase.set_analytics_screen('Teams Game: ' + activity.getname())
babase.increment_analytics_count('Teams round start')
if len(activity.players) == 1:
_ba.increment_analytics_count('Teams round start 1 human player')
babase.increment_analytics_count('Teams round start 1 human player')
elif 1 < len(activity.players) < 8:
_ba.increment_analytics_count(
babase.increment_analytics_count(
'Teams round start '
+ str(len(activity.players))
+ ' human players'
)
elif len(activity.players) >= 8:
_ba.increment_analytics_count('Teams round start 8+ human players')
babase.increment_analytics_count(
'Teams round start 8+ human players'
)
elif isinstance(session, FreeForAllSession):
_ba.set_analytics_screen('FreeForAll Game: ' + activity.getname())
_ba.increment_analytics_count('Free-for-all round start')
babase.set_analytics_screen('FreeForAll Game: ' + activity.getname())
babase.increment_analytics_count('Free-for-all round start')
if len(activity.players) == 1:
_ba.increment_analytics_count(
babase.increment_analytics_count(
'Free-for-all round start 1 human player'
)
elif 1 < len(activity.players) < 8:
_ba.increment_analytics_count(
babase.increment_analytics_count(
'Free-for-all round start '
+ str(len(activity.players))
+ ' human players'
)
elif len(activity.players) >= 8:
_ba.increment_analytics_count(
babase.increment_analytics_count(
'Free-for-all round start 8+ human players'
)
# For some analytics tracking on the c layer.
_ba.reset_game_activity_tracking()

View file

@ -5,9 +5,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import babase
if TYPE_CHECKING:
from typing import Callable
import ba
import bascenev1
class AppDelegate:
@ -18,8 +20,8 @@ class AppDelegate:
def create_default_game_settings_ui(
self,
gameclass: type[ba.GameActivity],
sessiontype: type[ba.Session],
gameclass: type[bascenev1.GameActivity],
sessiontype: type[bascenev1.Session],
settings: dict | None,
completion_call: Callable[[dict | None], None],
) -> None:
@ -28,9 +30,16 @@ class AppDelegate:
It should manipulate the contents of config and call completion_call
when done.
"""
del gameclass, sessiontype, settings, completion_call # Unused.
from ba import _error
# Replace the main window once we come up successfully.
from bauiv1lib.playlist.editgame import PlaylistEditGameWindow
_error.print_error(
"create_default_game_settings_ui needs to be overridden"
assert babase.app.classic is not None
babase.app.ui_v1.clear_main_menu_window(transition='out_left')
babase.app.ui_v1.set_main_menu_window(
PlaylistEditGameWindow(
gameclass,
sessiontype,
settings,
completion_call=completion_call,
).get_root_widget()
)

View file

@ -6,48 +6,45 @@ from __future__ import annotations
import random
from typing import TYPE_CHECKING
import _ba
import babase
import bascenev1
if TYPE_CHECKING:
from typing import Any, Sequence
import ba
def run_cpu_benchmark() -> None:
"""Run a cpu benchmark."""
# pylint: disable=cyclic-import
from bastd import tutorial
from ba._session import Session
from bascenev1lib import tutorial
class BenchmarkSession(Session):
class BenchmarkSession(bascenev1.Session):
"""Session type for cpu benchmark."""
def __init__(self) -> None:
# print('FIXME: BENCHMARK SESSION WOULD CALC DEPS.')
depsets: Sequence[ba.DependencySet] = []
depsets: Sequence[bascenev1.DependencySet] = []
super().__init__(depsets)
# Store old graphics settings.
self._old_quality = _ba.app.config.resolve('Graphics Quality')
cfg = _ba.app.config
self._old_quality = babase.app.config.resolve('Graphics Quality')
cfg = babase.app.config
cfg['Graphics Quality'] = 'Low'
cfg.apply()
self.benchmark_type = 'cpu'
self.setactivity(_ba.newactivity(tutorial.TutorialActivity))
self.setactivity(bascenev1.newactivity(tutorial.TutorialActivity))
def __del__(self) -> None:
# When we're torn down, restore old graphics settings.
cfg = _ba.app.config
cfg = babase.app.config
cfg['Graphics Quality'] = self._old_quality
cfg.apply()
def on_player_request(self, player: ba.SessionPlayer) -> bool:
def on_player_request(self, player: bascenev1.SessionPlayer) -> bool:
return False
_ba.new_host_session(BenchmarkSession, benchmark_type='cpu')
bascenev1.new_host_session(BenchmarkSession, benchmark_type='cpu')
def run_stress_test(
@ -57,15 +54,13 @@ def run_stress_test(
round_duration: int = 30,
) -> None:
"""Run a stress test."""
from ba import modutils
from ba._general import Call
from ba._generated.enums import TimeType
from babase import modutils
_ba.screenmessage(
babase.screenmessage(
"Beginning stress test.. use 'End Test' to stop testing.",
color=(1, 1, 0),
)
with _ba.Context('ui'):
with babase.ContextRef.empty():
start_stress_test(
{
'playlist_type': playlist_type,
@ -74,46 +69,45 @@ def run_stress_test(
'round_duration': round_duration,
}
)
_ba.timer(
babase.apptimer(
7.0,
Call(
_ba.screenmessage,
babase.Call(
babase.screenmessage,
(
'stats will be written to '
+ modutils.get_human_readable_user_scripts_path()
+ '/stress_test_stats.csv'
),
),
timetype=TimeType.REAL,
)
def stop_stress_test() -> None:
"""End a running stress test."""
_ba.set_stress_testing(False, 0)
babase.set_stress_testing(False, 0)
assert babase.app.classic is not None
try:
if _ba.app.stress_test_reset_timer is not None:
_ba.screenmessage('Ending stress test...', color=(1, 1, 0))
if babase.app.classic.stress_test_reset_timer is not None:
babase.screenmessage('Ending stress test...', color=(1, 1, 0))
except Exception:
pass
_ba.app.stress_test_reset_timer = None
babase.app.classic.stress_test_reset_timer = None
def start_stress_test(args: dict[str, Any]) -> None:
"""(internal)"""
from ba._general import Call
from ba._dualteamsession import DualTeamSession
from ba._freeforallsession import FreeForAllSession
from ba._generated.enums import TimeType, TimeFormat
from bascenev1 import DualTeamSession, FreeForAllSession
appconfig = _ba.app.config
assert babase.app.classic is not None
appconfig = babase.app.config
playlist_type = args['playlist_type']
if playlist_type == 'Random':
if random.random() < 0.5:
playlist_type = 'Teams'
else:
playlist_type = 'Free-For-All'
_ba.screenmessage(
babase.screenmessage(
'Running Stress Test (listType="'
+ playlist_type
+ '", listName="'
@ -123,76 +117,67 @@ def start_stress_test(args: dict[str, Any]) -> None:
if playlist_type == 'Teams':
appconfig['Team Tournament Playlist Selection'] = args['playlist_name']
appconfig['Team Tournament Playlist Randomize'] = 1
_ba.timer(
babase.apptimer(
1.0,
Call(_ba.pushcall, Call(_ba.new_host_session, DualTeamSession)),
timetype=TimeType.REAL,
babase.Call(
babase.pushcall,
babase.Call(bascenev1.new_host_session, DualTeamSession),
),
)
else:
appconfig['Free-for-All Playlist Selection'] = args['playlist_name']
appconfig['Free-for-All Playlist Randomize'] = 1
_ba.timer(
babase.apptimer(
1.0,
Call(_ba.pushcall, Call(_ba.new_host_session, FreeForAllSession)),
timetype=TimeType.REAL,
babase.Call(
babase.pushcall,
babase.Call(bascenev1.new_host_session, FreeForAllSession),
),
)
_ba.set_stress_testing(True, args['player_count'])
_ba.app.stress_test_reset_timer = _ba.Timer(
args['round_duration'] * 1000,
Call(_reset_stress_test, args),
timetype=TimeType.REAL,
timeformat=TimeFormat.MILLISECONDS,
babase.set_stress_testing(True, args['player_count'])
babase.app.classic.stress_test_reset_timer = babase.AppTimer(
args['round_duration'], babase.Call(_reset_stress_test, args)
)
def _reset_stress_test(args: dict[str, Any]) -> None:
from ba._general import Call
from ba._generated.enums import TimeType
_ba.set_stress_testing(False, args['player_count'])
_ba.screenmessage('Resetting stress test...')
session = _ba.get_foreground_host_session()
babase.set_stress_testing(False, args['player_count'])
babase.screenmessage('Resetting stress test...')
session = bascenev1.get_foreground_host_session()
assert session is not None
session.end()
_ba.timer(1.0, Call(start_stress_test, args), timetype=TimeType.REAL)
babase.apptimer(1.0, babase.Call(start_stress_test, args))
def run_gpu_benchmark() -> None:
"""Kick off a benchmark to test gpu speeds."""
# FIXME: Not wired up yet.
_ba.screenmessage('Not wired up yet.', color=(1, 0, 0))
babase.screenmessage('Not wired up yet.', color=(1, 0, 0))
def run_media_reload_benchmark() -> None:
"""Kick off a benchmark to test media reloading speeds."""
from ba._general import Call
from ba._generated.enums import TimeType
_ba.reload_media()
_ba.show_progress_bar()
babase.reload_media()
babase.show_progress_bar()
def delay_add(start_time: float) -> None:
def doit(start_time_2: float) -> None:
_ba.screenmessage(
_ba.app.lang.get_resource(
babase.screenmessage(
babase.app.lang.get_resource(
'debugWindow.totalReloadTimeText'
).replace(
'${TIME}', str(_ba.time(TimeType.REAL) - start_time_2)
)
).replace('${TIME}', str(babase.apptime() - start_time_2))
)
_ba.print_load_info()
if _ba.app.config.resolve('Texture Quality') != 'High':
_ba.screenmessage(
_ba.app.lang.get_resource(
babase.print_load_info()
if babase.app.config.resolve('Texture Quality') != 'High':
babase.screenmessage(
babase.app.lang.get_resource(
'debugWindow.reloadBenchmarkBestResultsText'
),
color=(1, 1, 0),
)
_ba.add_clean_frame_callback(Call(doit, start_time))
babase.add_clean_frame_callback(babase.Call(doit, start_time))
# The reload starts (should add a completion callback to the
# reload func to fix this).
_ba.timer(
0.05, Call(delay_add, _ba.time(TimeType.REAL)), timetype=TimeType.REAL
)
babase.apptimer(0.05, babase.Call(delay_add, babase.apptime()))

View file

@ -4,16 +4,17 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import logging
import _ba
from ba._internal import get_v1_account_display_string
import babase
if TYPE_CHECKING:
from typing import Any
import ba
def get_device_value(device: ba.InputDevice, name: str) -> Any:
def get_input_device_mapped_value(
devicename: str, unique_id: str, name: str
) -> Any:
"""Returns a mapped value for an input device.
This checks the user config and falls back to default values
@ -22,16 +23,16 @@ def get_device_value(device: ba.InputDevice, name: str) -> Any:
# pylint: disable=too-many-statements
# pylint: disable=too-many-return-statements
# pylint: disable=too-many-branches
devicename = device.name
unique_id = device.unique_identifier
app = _ba.app
useragentstring = app.user_agent_string
platform = app.platform
subplatform = app.subplatform
appconfig = _ba.app.config
app = babase.app
assert app.classic is not None
useragentstring = app.classic.legacy_user_agent_string
platform = app.classic.platform
subplatform = app.classic.subplatform
appconfig = babase.app.config
# iiRcade: hard-code for a/b/c/x for now...
if _ba.app.iircade_mode:
if babase.app.iircade_mode:
return {
'triggerRun2': 19,
'unassignedButtonsRun': False,
@ -64,7 +65,6 @@ def get_device_value(device: ba.InputDevice, name: str) -> Any:
return mapping.get(name, -1)
if platform == 'windows':
# XInput (hopefully this mapping is consistent?...)
if devicename.startswith('XInput Controller'):
return {
@ -98,7 +98,7 @@ def get_device_value(device: ba.InputDevice, name: str) -> Any:
}.get(name, -1)
# Look for some exact types.
if _ba.is_running_on_fire_tv():
if babase.is_running_on_fire_tv():
if devicename in ['Thunder', 'Amazon Fire Game Controller']:
return {
'triggerRun2': 23,
@ -211,7 +211,6 @@ def get_device_value(device: ba.InputDevice, name: str) -> Any:
'buttonIgnored': 17,
}.get(name, -1)
if devicename in ['Wireless 360 Controller', 'Controller']:
# Xbox360 gamepads
return {
'analogStickDeadZone': 1.2,
@ -325,7 +324,6 @@ def get_device_value(device: ba.InputDevice, name: str) -> Any:
# Generic android...
if platform == 'android':
# Steelseries stratus xl.
if devicename == 'SteelSeries Stratus XL':
return {
@ -519,8 +517,7 @@ def get_device_value(device: ba.InputDevice, name: str) -> Any:
# Reasonable defaults.
if platform == 'android':
if _ba.is_running_on_fire_tv():
if babase.is_running_on_fire_tv():
# Mostly same as default firetv controller.
return {
'triggerRun2': 23,
@ -579,15 +576,11 @@ def _gen_android_input_hash() -> str:
except PermissionError:
pass
except Exception:
from ba import _error
_error.print_exception(
'error in _gen_android_input_hash inner loop'
)
logging.exception('Error in _gen_android_input_hash inner loop.')
return md5.hexdigest()
def get_input_map_hash(inputdevice: ba.InputDevice) -> str:
def get_input_device_map_hash() -> str:
"""Given an input device, return a hash based on its raw input values.
This lets us avoid sharing mappings across devices that may
@ -595,34 +588,34 @@ def get_input_map_hash(inputdevice: ba.InputDevice) -> str:
(Different Android versions, for example, may return different
key codes for button presses on a given type of controller)
"""
del inputdevice # Currently unused.
app = _ba.app
try:
if app.input_map_hash is None:
if app.platform == 'android':
app.input_map_hash = _gen_android_input_hash()
else:
app.input_map_hash = ''
return app.input_map_hash
except Exception:
from ba import _error
app = babase.app
_error.print_exception('Exception in get_input_map_hash')
return ''
# Currently only using this when classic is present.
# Need to replace with a modern equivalent.
if app.classic is not None:
try:
if app.classic.input_map_hash is None:
if app.classic.platform == 'android':
app.classic.input_map_hash = _gen_android_input_hash()
else:
app.classic.input_map_hash = ''
return app.classic.input_map_hash
except Exception:
logging.exception('Error in get_input_map_hash.')
return ''
return ''
def get_input_device_config(
device: ba.InputDevice, default: bool
name: str, unique_id: str, default: bool
) -> tuple[dict, str]:
"""Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
"""
cfg = _ba.app.config
name = device.name
cfg = babase.app.config
ccfgs: dict[str, Any] = cfg.setdefault('Controllers', {})
ccfgs.setdefault(name, {})
unique_id = device.unique_identifier
if default:
if unique_id in ccfgs[name]:
del ccfgs[name][unique_id]
@ -632,26 +625,3 @@ def get_input_device_config(
if unique_id not in ccfgs[name]:
ccfgs[name][unique_id] = {}
return ccfgs[name], unique_id
def get_last_player_name_from_input_device(device: ba.InputDevice) -> str:
"""Return a reasonable player name associated with a device.
(generally the last one used there)
"""
appconfig = _ba.app.config
# Look for a default player profile name for them;
# otherwise default to their current random name.
profilename = '_random'
key_name = device.name + ' ' + device.unique_identifier
if (
'Default Player Profiles' in appconfig
and key_name in appconfig['Default Player Profiles']
):
profilename = appconfig['Default Player Profiles'][key_name]
if profilename == '_random':
profilename = device.get_default_player_name()
if profilename == '__account__':
profilename = get_v1_account_display_string()
return profilename

View file

@ -4,49 +4,17 @@
from __future__ import annotations
import copy
import logging
from typing import TYPE_CHECKING
from dataclasses import dataclass
from enum import Enum
import _ba
import babase
import bascenev1
from bascenev1 import MusicType
if TYPE_CHECKING:
from typing import Callable, Any
import ba
class MusicType(Enum):
"""Types of music available to play in-game.
Category: **Enums**
These do not correspond to specific pieces of music, but rather to
'situations'. The actual music played for each type can be overridden
by the game or by the user.
"""
MENU = 'Menu'
VICTORY = 'Victory'
CHAR_SELECT = 'CharSelect'
RUN_AWAY = 'RunAway'
ONSLAUGHT = 'Onslaught'
KEEP_AWAY = 'Keep Away'
RACE = 'Race'
EPIC_RACE = 'Epic Race'
SCORES = 'Scores'
GRAND_ROMP = 'GrandRomp'
TO_THE_DEATH = 'ToTheDeath'
CHOSEN_ONE = 'Chosen One'
FORWARD_MARCH = 'ForwardMarch'
FLAG_CATCHER = 'FlagCatcher'
SURVIVAL = 'Survival'
EPIC = 'Epic'
SPORTS = 'Sports'
HOCKEY = 'Hockey'
FOOTBALL = 'Football'
FLYING = 'Flying'
SCARY = 'Scary'
MARCHING = 'Marching'
class MusicPlayMode(Enum):
@ -118,7 +86,8 @@ class MusicSubsystem:
def __init__(self) -> None:
# pylint: disable=cyclic-import
self._music_node: _ba.Node | None = None
# self._music_node: _bascenev1.Node | None = None
self._playing_internal_music = False
self._music_mode: MusicPlayMode = MusicPlayMode.REGULAR
self._music_player: MusicPlayer | None = None
self._music_player_type: type[MusicPlayer] | None = None
@ -133,31 +102,29 @@ class MusicSubsystem:
# Our standard asset playback should probably just be one of them
# instead of a special case.
if self.supports_soundtrack_entry_type('musicFile'):
from ba.osmusic import OSMusicPlayer
from baclassic.osmusic import OSMusicPlayer
self._music_player_type = OSMusicPlayer
elif self.supports_soundtrack_entry_type('iTunesPlaylist'):
from ba.macmusicapp import MacMusicAppMusicPlayer
from baclassic.macmusicapp import MacMusicAppMusicPlayer
self._music_player_type = MacMusicAppMusicPlayer
def on_app_launch(self) -> None:
"""Should be called by app on_app_launch()."""
def on_app_loading(self) -> None:
"""Should be called by app on_app_loading()."""
# If we're using a non-default playlist, lets go ahead and get our
# music-player going since it may hitch (better while we're faded
# out than later).
try:
cfg = _ba.app.config
cfg = babase.app.config
if 'Soundtrack' in cfg and cfg['Soundtrack'] not in [
'__default__',
'Default Soundtrack',
]:
self.get_music_player()
except Exception:
from ba import _error
_error.print_exception('error prepping music-player')
logging.exception('Error prepping music-player.')
def on_app_shutdown(self) -> None:
"""Should be called when the app is shutting down."""
@ -188,7 +155,6 @@ class MusicSubsystem:
old_mode = self._music_mode
self._music_mode = mode
if old_mode != self._music_mode or force_restart:
# If we're switching into test mode we don't
# actually play anything until its requested.
# If we're switching *out* of test mode though
@ -199,7 +165,7 @@ class MusicSubsystem:
def supports_soundtrack_entry_type(self, entry_type: str) -> bool:
"""Return whether provided soundtrack entry type is supported here."""
uas = _ba.env()['user_agent_string']
uas = babase.env()['legacy_user_agent_string']
assert isinstance(uas, str)
# FIXME: Generalize this.
@ -208,7 +174,7 @@ class MusicSubsystem:
if entry_type in ('musicFile', 'musicFolder'):
return (
'android' in uas
and _ba.android_get_external_files_dir() is not None
and babase.android_get_external_files_dir() is not None
)
if entry_type == 'default':
return True
@ -246,9 +212,7 @@ class MusicSubsystem:
return entry_type
raise ValueError('invalid soundtrack entry:' + str(entry))
except Exception:
from ba import _error
_error.print_exception()
logging.exception('Error in get_soundtrack_entry_type.')
return 'default'
def get_soundtrack_entry_name(self, entry: Any) -> str:
@ -272,14 +236,12 @@ class MusicSubsystem:
return entry['name']
raise ValueError('invalid soundtrack entry:' + str(entry))
except Exception:
from ba import _error
_error.print_exception()
logging.exception('Error in get_soundtrack_entry_name.')
return 'default'
def on_app_resume(self) -> None:
"""Should be run when the app resumes from a suspended state."""
if _ba.is_os_playing_music():
if babase.is_os_playing_music():
self.do_play_music(None)
def do_play_music(
@ -305,8 +267,7 @@ class MusicSubsystem:
print(f"Invalid music type: '{musictype}'")
musictype = None
with _ba.Context('ui'):
with babase.ContextRef.empty():
# If they don't want to restart music and we're already
# playing what's requested, we're done.
if continuous and self.music_types[mode] is musictype:
@ -315,7 +276,7 @@ class MusicSubsystem:
# If the OS tells us there's currently music playing,
# all our operations default to playing nothing.
if _ba.is_os_playing_music():
if babase.is_os_playing_music():
musictype = None
# If we're not in the mode this music is being set for,
@ -346,7 +307,7 @@ class MusicSubsystem:
def _get_user_soundtrack(self) -> dict[str, Any]:
"""Return current user soundtrack or empty dict otherwise."""
cfg = _ba.app.config
cfg = babase.app.config
soundtrack: dict[str, Any] = {}
soundtrackname = cfg.get('Soundtrack')
if soundtrackname is not None and soundtrackname != '__default__':
@ -358,44 +319,53 @@ class MusicSubsystem:
return soundtrack
def _play_music_player_music(self, entry: Any) -> None:
# Stop any existing internal music.
if self._music_node is not None:
self._music_node.delete()
self._music_node = None
# if self._music_node is not None:
# self._music_node.delete()
# self._music_node = None
if self._playing_internal_music:
bascenev1.set_internal_music(None)
self._playing_internal_music = False
# Do the thing.
self.get_music_player().play(entry)
def _play_internal_music(self, musictype: MusicType | None) -> None:
# Stop any existing music-player playback.
if self._music_player is not None:
self._music_player.stop()
# Stop any existing internal music.
if self._music_node:
self._music_node.delete()
self._music_node = None
# if self._music_node:
# self._music_node.delete()
# self._music_node = None
if self._playing_internal_music:
bascenev1.set_internal_music(None)
self._playing_internal_music = False
# Start up new internal music.
if musictype is not None:
entry = ASSET_SOUNDTRACK_ENTRIES.get(musictype)
if entry is None:
print(f"Unknown music: '{musictype}'")
entry = ASSET_SOUNDTRACK_ENTRIES[MusicType.FLAG_CATCHER]
self._music_node = _ba.newnode(
type='sound',
attrs={
'sound': _ba.getsound(entry.assetname),
'positional': False,
'music': True,
'volume': entry.volume * 5.0,
'loop': entry.loop,
},
# self._music_node = _bascenev1.newnode(
# type='sound',
# attrs={
# 'sound': _bascenev1.getsound(entry.assetname),
# 'positional': False,
# 'music': True,
# 'volume': entry.volume * 5.0,
# 'loop': entry.loop,
# },
# )
bascenev1.set_internal_music(
babase.getsimplesound(entry.assetname),
volume=entry.volume * 5.0,
loop=entry.loop,
)
self._playing_internal_music = True
class MusicPlayer:
@ -433,7 +403,7 @@ class MusicPlayer:
def play(self, entry: Any) -> None:
"""Play provided entry."""
if not self._have_set_initial_volume:
self._volume = _ba.app.config.resolve('Music Volume')
self._volume = babase.app.config.resolve('Music Volume')
self.on_set_volume(self._volume)
self._have_set_initial_volume = True
self._entry_to_play = copy.deepcopy(entry)
@ -482,46 +452,18 @@ class MusicPlayer:
"""Called on final app shutdown."""
def _update_play_state(self) -> None:
# If we aren't playing, should be, and have positive volume, do so.
if not self._actually_playing:
if self._entry_to_play is not None and self._volume > 0.0:
self.on_play(self._entry_to_play)
self._actually_playing = True
else:
if self._actually_playing and (
self._entry_to_play is None or self._volume <= 0.0
):
if self._entry_to_play is None or self._volume <= 0.0:
self.on_stop()
self._actually_playing = False
def setmusic(musictype: ba.MusicType | None, continuous: bool = False) -> None:
"""Set the app to play (or stop playing) a certain type of music.
category: **Gameplay Functions**
This function will handle loading and playing sound assets as necessary,
and also supports custom user soundtracks on specific platforms so the
user can override particular game music with their own.
Pass None to stop music.
if 'continuous' is True and musictype is the same as what is already
playing, the playing track will not be restarted.
"""
# All we do here now is set a few music attrs on the current globals
# node. The foreground globals' current playing music then gets fed to
# the do_play_music call in our music controller. This way we can
# seamlessly support custom soundtracks in replays/etc since we're being
# driven purely by node data.
gnode = _ba.getactivity().globalsnode
gnode.music_continuous = continuous
gnode.music = '' if musictype is None else musictype.value
gnode.music_count += 1
def do_play_music(*args: Any, **keywds: Any) -> None:
"""A passthrough used by the C++ layer."""
_ba.app.music.do_play_music(*args, **keywds)
assert babase.app.classic is not None
babase.app.classic.music.do_play_music(*args, **keywds)

View file

@ -3,95 +3,30 @@
"""Networking related functionality."""
from __future__ import annotations
import ssl
import copy
import threading
import weakref
from enum import Enum
from typing import TYPE_CHECKING
import _ba
import babase
from babase import DEFAULT_REQUEST_TIMEOUT_SECONDS
import bascenev1
if TYPE_CHECKING:
from typing import Any, Callable
import socket
MasterServerCallback = Callable[[None | dict[str, Any]], None]
# 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
class MasterServerResponseType(Enum):
"""How to interpret responses from the master-server."""
"""How to interpret responses from the v1 master-server."""
JSON = 0
class MasterServerCallThread(threading.Thread):
"""Thread to communicate with the master-server."""
class MasterServerV1CallThread(threading.Thread):
"""Thread to communicate with the v1 master-server."""
def __init__(
self,
@ -109,10 +44,10 @@ class MasterServerCallThread(threading.Thread):
self._response_type = response_type
self._data = {} if data is None else copy.deepcopy(data)
self._callback: MasterServerCallback | None = callback
self._context = _ba.Context('current')
self._context = babase.ContextRef()
# Save and restore the context we were created from.
activity = _ba.getactivity(doraise=False)
activity = bascenev1.getactivity(doraise=False)
self._activity = weakref.ref(activity) if activity is not None else None
def _run_callback(self, arg: None | dict[str, Any]) -> None:
@ -140,38 +75,44 @@ class MasterServerCallThread(threading.Thread):
import json
from efro.error import is_urllib_communication_error
from ba._general import Call, utf8_all
from ba._internal import get_master_server_address
plus = babase.app.plus
assert plus is not None
response_data: Any = None
url: str | None = None
try:
self._data = utf8_all(self._data)
_ba.set_thread_name('BA_ServerCallThread')
classic = babase.app.classic
assert classic is not None
self._data = babase.utf8_all(self._data)
babase.set_thread_name('BA_ServerCallThread')
if self._request_type == 'get':
url = (
get_master_server_address()
plus.get_master_server_address()
+ '/'
+ self._request
+ '?'
+ urllib.parse.urlencode(self._data)
)
assert url is not None
response = urllib.request.urlopen(
urllib.request.Request(
url, None, {'User-Agent': _ba.app.user_agent_string}
url,
None,
{'User-Agent': classic.legacy_user_agent_string},
),
context=_ba.app.net.sslcontext,
context=babase.app.net.sslcontext,
timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS,
)
elif self._request_type == 'post':
url = get_master_server_address() + '/' + self._request
url = plus.get_master_server_address() + '/' + self._request
assert url is not None
response = urllib.request.urlopen(
urllib.request.Request(
url,
urllib.parse.urlencode(self._data).encode(),
{'User-Agent': _ba.app.user_agent_string},
{'User-Agent': classic.legacy_user_agent_string},
),
context=_ba.app.net.sslcontext,
context=babase.app.net.sslcontext,
timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS,
)
else:
@ -192,7 +133,6 @@ class MasterServerCallThread(threading.Thread):
raise TypeError(f'invalid responsetype: {self._response_type}')
except Exception as exc:
# Ignore common network errors; note unexpected ones.
if not is_urllib_communication_error(exc, url=url):
print(
@ -208,30 +148,7 @@ class MasterServerCallThread(threading.Thread):
response_data = None
if self._callback is not None:
_ba.pushcall(
Call(self._run_callback, response_data), from_other_thread=True
babase.pushcall(
babase.Call(self._run_callback, response_data),
from_other_thread=True,
)
def master_server_get(
request: str,
data: dict[str, Any],
callback: MasterServerCallback | None = None,
response_type: MasterServerResponseType = MasterServerResponseType.JSON,
) -> None:
"""Make a call to the master server via a http GET."""
MasterServerCallThread(
request, 'get', data, callback, response_type
).start()
def master_server_post(
request: str,
data: dict[str, Any],
callback: MasterServerCallback | None = None,
response_type: MasterServerResponseType = MasterServerResponseType.JSON,
) -> None:
"""Make a call to the master server via a http POST."""
MasterServerCallThread(
request, 'post', data, callback, response_type
).start()

View file

@ -19,17 +19,12 @@ from bacommon.servermanager import (
ClientListCommand,
KickCommand,
)
import _ba
from ba._internal import add_transaction, run_transactions, get_v1_account_state
from ba._generated.enums import TimeType
from ba._freeforallsession import FreeForAllSession
from ba._dualteamsession import DualTeamSession
from ba._coopsession import CoopSession
import babase
import bascenev1
if TYPE_CHECKING:
from typing import Any
import ba
from bacommon.servermanager import ServerConfig
@ -37,33 +32,35 @@ def _cmd(command_data: bytes) -> None:
"""Handle commands coming in from our server manager parent process."""
import pickle
assert babase.app.classic is not None
command = pickle.loads(command_data)
assert isinstance(command, ServerCommand)
if isinstance(command, StartServerModeCommand):
assert _ba.app.server is None
_ba.app.server = ServerController(command.config)
assert babase.app.classic.server is None
babase.app.classic.server = ServerController(command.config)
return
if isinstance(command, ShutdownCommand):
assert _ba.app.server is not None
_ba.app.server.shutdown(
assert babase.app.classic.server is not None
babase.app.classic.server.shutdown(
reason=command.reason, immediate=command.immediate
)
return
if isinstance(command, ChatMessageCommand):
assert _ba.app.server is not None
_ba.chatmessage(command.message, clients=command.clients)
assert babase.app.classic.server is not None
bascenev1.chatmessage(command.message, clients=command.clients)
return
if isinstance(command, ScreenMessageCommand):
assert _ba.app.server is not None
assert babase.app.classic.server is not None
# Note: we have to do transient messages if
# clients is specified, so they won't show up
# in replays.
_ba.screenmessage(
bascenev1.broadcastmessage(
command.message,
color=command.color,
clients=command.clients,
@ -72,13 +69,13 @@ def _cmd(command_data: bytes) -> None:
return
if isinstance(command, ClientListCommand):
assert _ba.app.server is not None
_ba.app.server.print_client_list()
assert babase.app.classic.server is not None
babase.app.classic.server.print_client_list()
return
if isinstance(command, KickCommand):
assert _ba.app.server is not None
_ba.app.server.kick(
assert babase.app.classic.server is not None
babase.app.classic.server.kick(
client_id=command.client_id, ban_time=command.ban_time
)
return
@ -96,11 +93,10 @@ class ServerController:
"""
def __init__(self, config: ServerConfig) -> None:
self._config = config
self._playlist_name = '__default__'
self._ran_access_check = False
self._prep_timer: ba.Timer | None = None
self._prep_timer: babase.AppTimer | None = None
self._next_stuck_login_warn_time = time.time() + 10.0
self._first_run = True
self._shutdown_reason: ShutdownReason | None = None
@ -116,19 +112,16 @@ class ServerController:
# Now sit around doing any pre-launch prep such as waiting for
# account sign-in or fetching playlists; this will kick off the
# session once done.
with _ba.Context('ui'):
self._prep_timer = _ba.Timer(
0.25,
self._prepare_to_serve,
timetype=TimeType.REAL,
repeat=True,
with babase.ContextRef.empty():
self._prep_timer = babase.AppTimer(
0.25, self._prepare_to_serve, repeat=True
)
def print_client_list(self) -> None:
"""Print info about all connected clients."""
import json
roster = _ba.get_game_roster()
roster = bascenev1.get_game_roster()
title1 = 'Client ID'
title2 = 'Account Name'
title3 = 'Players'
@ -161,7 +154,7 @@ class ServerController:
if ban_time is None:
ban_time = 300
_ba.disconnect_client(client_id=client_id, ban_time=ban_time)
bascenev1.disconnect_client(client_id=client_id, ban_time=ban_time)
def shutdown(self, reason: ShutdownReason, immediate: bool) -> None:
"""Set the app to quit either now or at the next clean opportunity."""
@ -177,7 +170,7 @@ class ServerController:
)
def handle_transition(self) -> bool:
"""Handle transitioning to a new ba.Session or quitting the app.
"""Handle transitioning to a new bascenev1.Session or quitting the app.
Will be called once at the end of an activity that is marked as
a good 'end-point' (such as a final score screen).
@ -190,15 +183,13 @@ class ServerController:
return False
def _execute_shutdown(self) -> None:
from ba._language import Lstr
if self._executing_shutdown:
return
self._executing_shutdown = True
timestrval = time.strftime('%c')
if self._shutdown_reason is ShutdownReason.RESTARTING:
_ba.screenmessage(
Lstr(resource='internal.serverRestartingText'),
bascenev1.broadcastmessage(
babase.Lstr(resource='internal.serverRestartingText'),
color=(1, 0.5, 0.0),
)
print(
@ -206,24 +197,24 @@ class ServerController:
f' at {timestrval}.{Clr.RST}'
)
else:
_ba.screenmessage(
Lstr(resource='internal.serverShuttingDownText'),
bascenev1.broadcastmessage(
babase.Lstr(resource='internal.serverShuttingDownText'),
color=(1, 0.5, 0.0),
)
print(
f'{Clr.SBLU}Exiting for server-shutdown'
f' at {timestrval}.{Clr.RST}'
)
with _ba.Context('ui'):
_ba.timer(2.0, _ba.quit, timetype=TimeType.REAL)
with babase.ContextRef.empty():
babase.apptimer(2.0, babase.quit)
def _run_access_check(self) -> None:
"""Check with the master server to see if we're likely joinable."""
from ba._net import master_server_get
assert babase.app.classic is not None
master_server_get(
babase.app.classic.master_server_v1_get(
'bsAccessCheck',
{'port': _ba.get_game_port(), 'b': _ba.app.build_number},
{'port': bascenev1.get_game_port(), 'b': babase.app.build_number},
callback=self._access_check_response,
)
@ -235,7 +226,7 @@ class ServerController:
else:
addr = data['address']
port = data['port']
show_addr = True
show_addr = os.environ.get('BA_ACCESS_CHECK_VERBOSE', '0') == '1'
if show_addr:
addrstr = f' {addr}'
poststr = ''
@ -250,31 +241,22 @@ class ServerController:
f'{Clr.SBLU}Master server access check of{addrstr}'
f' udp port {port} succeeded.\n'
f'Your server appears to be'
f' joinable from the internet .{poststr}{Clr.RST}'
f' joinable from the internet.{poststr}{Clr.RST}'
)
if self._config.party_is_public:
print(
f'{Clr.SBLU}Your party {self._config.party_name}'
f' visible in public party list.{Clr.RST}'
)
else:
print(
f'{Clr.SBLU}Your private party {self._config.party_name}'
f'can be joined by {addrstr} {port}.{Clr.RST}'
)
else:
print(
f'{Clr.SRED}Master server access check of{addrstr}'
f' udp port {port} failed.\n'
f'Your server does not appear to be'
f' joinable from the internet. Please check your firewall or instance security group.{poststr}{Clr.RST}'
f' joinable from the internet.{poststr}{Clr.RST}'
)
def _prepare_to_serve(self) -> None:
"""Run in a timer to do prep before beginning to serve."""
signed_in = get_v1_account_state() == 'signed_in'
plus = babase.app.plus
assert plus is not None
signed_in = plus.get_v1_account_state() == 'signed_in'
if not signed_in:
# Signing in to the local server account should not take long;
# complain if it does...
curtime = time.time()
@ -294,7 +276,7 @@ class ServerController:
f'{Clr.SBLU}Requesting shared-playlist'
f' {self._config.playlist_code}...{Clr.RST}'
)
add_transaction(
plus.add_v1_account_transaction(
{
'type': 'IMPORT_PLAYLIST',
'code': str(self._config.playlist_code),
@ -302,7 +284,7 @@ class ServerController:
},
callback=self._on_playlist_fetch_response,
)
run_transactions()
plus.run_v1_account_transactions()
self._playlist_fetch_sent_request = True
if self._playlist_fetch_got_response:
@ -311,7 +293,7 @@ class ServerController:
if can_launch:
self._prep_timer = None
_ba.pushcall(self._launch_server_session)
babase.pushcall(self._launch_server_session)
def _on_playlist_fetch_response(
self,
@ -319,8 +301,7 @@ class ServerController:
) -> None:
if result is None:
print('Error fetching playlist; aborting.')
import _ba
_ba.quit()
sys.exit(-1)
# Once we get here, simply modify our config to use this playlist.
typename = (
@ -336,15 +317,15 @@ class ServerController:
self._config.session_type = typename
self._playlist_name = result['playlistName']
def _get_session_type(self) -> type[ba.Session]:
def _get_session_type(self) -> type[bascenev1.Session]:
# Convert string session type to the class.
# Hmm should we just keep this as a string?
if self._config.session_type == 'ffa':
return FreeForAllSession
return bascenev1.FreeForAllSession
if self._config.session_type == 'teams':
return DualTeamSession
return bascenev1.DualTeamSession
if self._config.session_type == 'coop':
return CoopSession
return bascenev1.CoopSession
raise RuntimeError(
f'Invalid session_type: "{self._config.session_type}"'
)
@ -352,11 +333,16 @@ class ServerController:
def _launch_server_session(self) -> None:
"""Kick off a host-session based on the current server config."""
# pylint: disable=too-many-branches
app = _ba.app
# pylint: disable=too-many-statements
app = babase.app
classic = app.classic
plus = app.plus
assert plus is not None
assert classic is not None
appcfg = app.config
sessiontype = self._get_session_type()
if get_v1_account_state() != 'signed_in':
if plus.get_v1_account_state() != 'signed_in':
print(
'WARNING: launch_server_session() expects to run '
'with a signed in server account'
@ -369,18 +355,18 @@ class ServerController:
and self._config.playlist_inline is not None
):
self._playlist_name = 'ServerModePlaylist'
if sessiontype is FreeForAllSession:
if sessiontype is bascenev1.FreeForAllSession:
ptypename = 'Free-for-All'
elif sessiontype is DualTeamSession:
elif sessiontype is bascenev1.DualTeamSession:
ptypename = 'Team Tournament'
elif sessiontype is CoopSession:
elif sessiontype is bascenev1.CoopSession:
ptypename = 'Coop'
else:
raise RuntimeError(f'Unknown session type {sessiontype}')
# Need to add this in a transaction instead of just setting
# it directly or it will get overwritten by the master-server.
add_transaction(
plus.add_v1_account_transaction(
{
'type': 'ADD_PLAYLIST',
'playlistType': ptypename,
@ -388,67 +374,66 @@ class ServerController:
'playlist': self._config.playlist_inline,
}
)
run_transactions()
plus.run_v1_account_transactions()
if self._first_run:
curtimestr = time.strftime('%c')
startupmsg = (
f'{Clr.BLD}{Clr.BLU}{_ba.appnameupper()} {app.version}'
f'{Clr.BLD}{Clr.BLU}{babase.appnameupper()} {app.version}'
f' ({app.build_number})'
f' entering server-mode {curtimestr}{Clr.RST}'
)
logging.info(startupmsg)
if sessiontype is FreeForAllSession:
if sessiontype is bascenev1.FreeForAllSession:
appcfg['Free-for-All Playlist Selection'] = self._playlist_name
appcfg[
'Free-for-All Playlist Randomize'
] = self._config.playlist_shuffle
elif sessiontype is DualTeamSession:
elif sessiontype is bascenev1.DualTeamSession:
appcfg['Team Tournament Playlist Selection'] = self._playlist_name
appcfg[
'Team Tournament Playlist Randomize'
] = self._config.playlist_shuffle
elif sessiontype is CoopSession:
app.coop_session_args = {
elif sessiontype is bascenev1.CoopSession:
classic.coop_session_args = {
'campaign': self._config.coop_campaign,
'level': self._config.coop_level,
}
else:
raise RuntimeError(f'Unknown session type {sessiontype}')
app.teams_series_length = self._config.teams_series_length
app.ffa_series_length = self._config.ffa_series_length
classic.teams_series_length = self._config.teams_series_length
classic.ffa_series_length = self._config.ffa_series_length
_ba.set_authenticate_clients(self._config.authenticate_clients)
bascenev1.set_authenticate_clients(self._config.authenticate_clients)
_ba.set_enable_default_kick_voting(
bascenev1.set_enable_default_kick_voting(
self._config.enable_default_kick_voting
)
_ba.set_admins(self._config.admins)
bascenev1.set_admins(self._config.admins)
# Call set-enabled last (will push state to the cloud).
_ba.set_public_party_max_size(self._config.max_party_size)
_ba.set_public_party_queue_enabled(self._config.enable_queue)
_ba.set_public_party_name(self._config.party_name)
_ba.set_public_party_stats_url(self._config.stats_url)
_ba.set_public_party_enabled(self._config.party_is_public)
bascenev1.set_public_party_max_size(self._config.max_party_size)
bascenev1.set_public_party_queue_enabled(self._config.enable_queue)
bascenev1.set_public_party_name(self._config.party_name)
bascenev1.set_public_party_stats_url(self._config.stats_url)
bascenev1.set_public_party_enabled(self._config.party_is_public)
# And here.. we.. go.
if self._config.stress_test_players is not None:
# Special case: run a stress test.
from ba.internal import run_stress_test
run_stress_test(
assert babase.app.classic is not None
babase.app.classic.run_stress_test(
playlist_type='Random',
playlist_name='__default__',
player_count=self._config.stress_test_players,
round_duration=30,
)
else:
_ba.new_host_session(sessiontype)
bascenev1.new_host_session(sessiontype)
# Run an access check if we're trying to make a public party.
if not self._ran_access_check:
if not self._ran_access_check and self._config.party_is_public:
self._run_access_check()
self._ran_access_check = True

570
dist/ba_data/python/baclassic/_store.py vendored Normal file
View file

@ -0,0 +1,570 @@
# Released under the MIT License. See LICENSE for details.
#
"""Store related functionality for classic mode."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
import babase
import bascenev1
if TYPE_CHECKING:
from typing import Any
class StoreSubsystem:
"""Wrangles classic store."""
def get_store_item(self, item: str) -> dict[str, Any]:
"""(internal)"""
return self.get_store_items()[item]
def get_store_item_name_translated(self, item_name: str) -> babase.Lstr:
"""Return a babase.Lstr for a store item name."""
# pylint: disable=cyclic-import
item_info = self.get_store_item(item_name)
if item_name.startswith('characters.'):
return babase.Lstr(
translate=('characterNames', item_info['character'])
)
if item_name in ['merch']:
return babase.Lstr(resource='merchText')
if item_name in ['upgrades.pro', 'pro']:
return babase.Lstr(
resource='store.bombSquadProNameText',
subs=[('${APP_NAME}', babase.Lstr(resource='titleText'))],
)
if item_name.startswith('maps.'):
map_type: type[bascenev1.Map] = item_info['map_type']
return bascenev1.get_map_display_string(map_type.name)
if item_name.startswith('games.'):
gametype: type[bascenev1.GameActivity] = item_info['gametype']
return gametype.get_display_string()
if item_name.startswith('icons.'):
return babase.Lstr(resource='editProfileWindow.iconText')
raise ValueError('unrecognized item: ' + item_name)
def get_store_item_display_size(
self, item_name: str
) -> tuple[float, float]:
"""(internal)"""
if item_name.startswith('characters.'):
return 340 * 0.6, 430 * 0.6
if item_name in ['pro', 'upgrades.pro', 'merch']:
assert babase.app.classic is not None
return 650 * 0.9, 500 * (
0.72
if (
babase.app.config.get('Merch Link')
and babase.app.ui_v1.uiscale is babase.UIScale.SMALL
)
else 0.85
)
if item_name.startswith('maps.'):
return 510 * 0.6, 450 * 0.6
if item_name.startswith('icons.'):
return 265 * 0.6, 250 * 0.6
return 450 * 0.6, 450 * 0.6
def get_store_items(self) -> dict[str, dict]:
"""Returns info about purchasable items.
(internal)
"""
# pylint: disable=cyclic-import
from bascenev1lib import maps
assert babase.app.classic is not None
if babase.app.classic.store_items is None:
from bascenev1lib.game import ninjafight
from bascenev1lib.game import meteorshower
from bascenev1lib.game import targetpractice
from bascenev1lib.game import easteregghunt
# IMPORTANT - need to keep this synced with the master server.
# (doing so manually for now)
babase.app.classic.store_items = {
'characters.kronk': {'character': 'Kronk'},
'characters.zoe': {'character': 'Zoe'},
'characters.jackmorgan': {'character': 'Jack Morgan'},
'characters.mel': {'character': 'Mel'},
'characters.snakeshadow': {'character': 'Snake Shadow'},
'characters.bones': {'character': 'Bones'},
'characters.bernard': {
'character': 'Bernard',
'highlight': (0.6, 0.5, 0.8),
},
'characters.pixie': {'character': 'Pixel'},
'characters.wizard': {'character': 'Grumbledorf'},
'characters.frosty': {'character': 'Frosty'},
'characters.pascal': {'character': 'Pascal'},
'characters.cyborg': {'character': 'B-9000'},
'characters.agent': {'character': 'Agent Johnson'},
'characters.taobaomascot': {'character': 'Taobao Mascot'},
'characters.santa': {'character': 'Santa Claus'},
'characters.bunny': {'character': 'Easter Bunny'},
'merch': {},
'pro': {},
'maps.lake_frigid': {'map_type': maps.LakeFrigid},
'games.ninja_fight': {
'gametype': ninjafight.NinjaFightGame,
'previewTex': 'courtyardPreview',
},
'games.meteor_shower': {
'gametype': meteorshower.MeteorShowerGame,
'previewTex': 'rampagePreview',
},
'games.target_practice': {
'gametype': targetpractice.TargetPracticeGame,
'previewTex': 'doomShroomPreview',
},
'games.easter_egg_hunt': {
'gametype': easteregghunt.EasterEggHuntGame,
'previewTex': 'towerDPreview',
},
'icons.flag_us': {
'icon': babase.charstr(
babase.SpecialChar.FLAG_UNITED_STATES
)
},
'icons.flag_mexico': {
'icon': babase.charstr(babase.SpecialChar.FLAG_MEXICO)
},
'icons.flag_germany': {
'icon': babase.charstr(babase.SpecialChar.FLAG_GERMANY)
},
'icons.flag_brazil': {
'icon': babase.charstr(babase.SpecialChar.FLAG_BRAZIL)
},
'icons.flag_russia': {
'icon': babase.charstr(babase.SpecialChar.FLAG_RUSSIA)
},
'icons.flag_china': {
'icon': babase.charstr(babase.SpecialChar.FLAG_CHINA)
},
'icons.flag_uk': {
'icon': babase.charstr(
babase.SpecialChar.FLAG_UNITED_KINGDOM
)
},
'icons.flag_canada': {
'icon': babase.charstr(babase.SpecialChar.FLAG_CANADA)
},
'icons.flag_india': {
'icon': babase.charstr(babase.SpecialChar.FLAG_INDIA)
},
'icons.flag_japan': {
'icon': babase.charstr(babase.SpecialChar.FLAG_JAPAN)
},
'icons.flag_france': {
'icon': babase.charstr(babase.SpecialChar.FLAG_FRANCE)
},
'icons.flag_indonesia': {
'icon': babase.charstr(babase.SpecialChar.FLAG_INDONESIA)
},
'icons.flag_italy': {
'icon': babase.charstr(babase.SpecialChar.FLAG_ITALY)
},
'icons.flag_south_korea': {
'icon': babase.charstr(babase.SpecialChar.FLAG_SOUTH_KOREA)
},
'icons.flag_netherlands': {
'icon': babase.charstr(babase.SpecialChar.FLAG_NETHERLANDS)
},
'icons.flag_uae': {
'icon': babase.charstr(
babase.SpecialChar.FLAG_UNITED_ARAB_EMIRATES
)
},
'icons.flag_qatar': {
'icon': babase.charstr(babase.SpecialChar.FLAG_QATAR)
},
'icons.flag_egypt': {
'icon': babase.charstr(babase.SpecialChar.FLAG_EGYPT)
},
'icons.flag_kuwait': {
'icon': babase.charstr(babase.SpecialChar.FLAG_KUWAIT)
},
'icons.flag_algeria': {
'icon': babase.charstr(babase.SpecialChar.FLAG_ALGERIA)
},
'icons.flag_saudi_arabia': {
'icon': babase.charstr(babase.SpecialChar.FLAG_SAUDI_ARABIA)
},
'icons.flag_malaysia': {
'icon': babase.charstr(babase.SpecialChar.FLAG_MALAYSIA)
},
'icons.flag_czech_republic': {
'icon': babase.charstr(
babase.SpecialChar.FLAG_CZECH_REPUBLIC
)
},
'icons.flag_australia': {
'icon': babase.charstr(babase.SpecialChar.FLAG_AUSTRALIA)
},
'icons.flag_singapore': {
'icon': babase.charstr(babase.SpecialChar.FLAG_SINGAPORE)
},
'icons.flag_iran': {
'icon': babase.charstr(babase.SpecialChar.FLAG_IRAN)
},
'icons.flag_poland': {
'icon': babase.charstr(babase.SpecialChar.FLAG_POLAND)
},
'icons.flag_argentina': {
'icon': babase.charstr(babase.SpecialChar.FLAG_ARGENTINA)
},
'icons.flag_philippines': {
'icon': babase.charstr(babase.SpecialChar.FLAG_PHILIPPINES)
},
'icons.flag_chile': {
'icon': babase.charstr(babase.SpecialChar.FLAG_CHILE)
},
'icons.fedora': {
'icon': babase.charstr(babase.SpecialChar.FEDORA)
},
'icons.hal': {'icon': babase.charstr(babase.SpecialChar.HAL)},
'icons.crown': {
'icon': babase.charstr(babase.SpecialChar.CROWN)
},
'icons.yinyang': {
'icon': babase.charstr(babase.SpecialChar.YIN_YANG)
},
'icons.eyeball': {
'icon': babase.charstr(babase.SpecialChar.EYE_BALL)
},
'icons.skull': {
'icon': babase.charstr(babase.SpecialChar.SKULL)
},
'icons.heart': {
'icon': babase.charstr(babase.SpecialChar.HEART)
},
'icons.dragon': {
'icon': babase.charstr(babase.SpecialChar.DRAGON)
},
'icons.helmet': {
'icon': babase.charstr(babase.SpecialChar.HELMET)
},
'icons.mushroom': {
'icon': babase.charstr(babase.SpecialChar.MUSHROOM)
},
'icons.ninja_star': {
'icon': babase.charstr(babase.SpecialChar.NINJA_STAR)
},
'icons.viking_helmet': {
'icon': babase.charstr(babase.SpecialChar.VIKING_HELMET)
},
'icons.moon': {'icon': babase.charstr(babase.SpecialChar.MOON)},
'icons.spider': {
'icon': babase.charstr(babase.SpecialChar.SPIDER)
},
'icons.fireball': {
'icon': babase.charstr(babase.SpecialChar.FIREBALL)
},
'icons.mikirog': {
'icon': babase.charstr(babase.SpecialChar.MIKIROG)
},
'icons.explodinary': {
'icon': babase.charstr(babase.SpecialChar.EXPLODINARY_LOGO)
},
}
return babase.app.classic.store_items
def get_store_layout(self) -> dict[str, list[dict[str, Any]]]:
"""Return what's available in the store at a given time.
Categorized by tab and by section.
"""
plus = babase.app.plus
classic = babase.app.classic
assert classic is not None
assert plus is not None
if classic.store_layout is None:
classic.store_layout = {
'characters': [{'items': []}],
'extras': [{'items': ['pro']}],
'maps': [{'items': ['maps.lake_frigid']}],
'minigames': [],
'icons': [
{
'items': [
'icons.mushroom',
'icons.heart',
'icons.eyeball',
'icons.yinyang',
'icons.hal',
'icons.flag_us',
'icons.flag_mexico',
'icons.flag_germany',
'icons.flag_brazil',
'icons.flag_russia',
'icons.flag_china',
'icons.flag_uk',
'icons.flag_canada',
'icons.flag_india',
'icons.flag_japan',
'icons.flag_france',
'icons.flag_indonesia',
'icons.flag_italy',
'icons.flag_south_korea',
'icons.flag_netherlands',
'icons.flag_uae',
'icons.flag_qatar',
'icons.flag_egypt',
'icons.flag_kuwait',
'icons.flag_algeria',
'icons.flag_saudi_arabia',
'icons.flag_malaysia',
'icons.flag_czech_republic',
'icons.flag_australia',
'icons.flag_singapore',
'icons.flag_iran',
'icons.flag_poland',
'icons.flag_argentina',
'icons.flag_philippines',
'icons.flag_chile',
'icons.moon',
'icons.fedora',
'icons.spider',
'icons.ninja_star',
'icons.skull',
'icons.dragon',
'icons.viking_helmet',
'icons.fireball',
'icons.helmet',
'icons.crown',
]
}
],
}
store_layout = classic.store_layout
store_layout['characters'] = [
{
'items': [
'characters.kronk',
'characters.zoe',
'characters.jackmorgan',
'characters.mel',
'characters.snakeshadow',
'characters.bones',
'characters.bernard',
'characters.agent',
'characters.frosty',
'characters.pascal',
'characters.pixie',
]
}
]
store_layout['minigames'] = [
{
'items': [
'games.ninja_fight',
'games.meteor_shower',
'games.target_practice',
]
}
]
if plus.get_v1_account_misc_read_val('xmas', False):
store_layout['characters'][0]['items'].append('characters.santa')
store_layout['characters'][0]['items'].append('characters.wizard')
store_layout['characters'][0]['items'].append('characters.cyborg')
if plus.get_v1_account_misc_read_val('easter', False):
store_layout['characters'].append(
{
'title': 'store.holidaySpecialText',
'items': ['characters.bunny'],
}
)
store_layout['minigames'].append(
{
'title': 'store.holidaySpecialText',
'items': ['games.easter_egg_hunt'],
}
)
# This will cause merch to show only if the master-server has
# given us a link (which means merch is available in our region).
store_layout['extras'] = [{'items': ['pro']}]
if babase.app.config.get('Merch Link'):
store_layout['extras'][0]['items'].append('merch')
return store_layout
def get_clean_price(self, price_string: str) -> str:
"""(internal)"""
# I'm not brave enough to try and do any numerical
# manipulation on formatted price strings, but lets do a
# few swap-outs to tidy things up a bit.
psubs = {
'$2.99': '$3.00',
'$4.99': '$5.00',
'$9.99': '$10.00',
'$19.99': '$20.00',
'$49.99': '$50.00',
}
return psubs.get(price_string, price_string)
def get_available_purchase_count(self, tab: str | None = None) -> int:
"""(internal)"""
plus = babase.app.plus
if plus is None:
return 0
try:
if plus.get_v1_account_state() != 'signed_in':
return 0
count = 0
our_tickets = plus.get_v1_account_ticket_count()
store_data = self.get_store_layout()
if tab is not None:
tabs = [(tab, store_data[tab])]
else:
tabs = list(store_data.items())
for tab_name, tabval in tabs:
if tab_name == 'icons':
continue # too many of these; don't show..
count = self._calc_count_for_tab(tabval, our_tickets, count)
return count
except Exception:
logging.exception('Error calcing available purchases.')
return 0
def _calc_count_for_tab(
self, tabval: list[dict[str, Any]], our_tickets: int, count: int
) -> int:
plus = babase.app.plus
assert plus
for section in tabval:
for item in section['items']:
ticket_cost = plus.get_v1_account_misc_read_val(
'price.' + item, None
)
if ticket_cost is not None:
if our_tickets >= ticket_cost and not plus.get_purchased(
item
):
count += 1
return count
def get_available_sale_time(self, tab: str) -> int | None:
"""(internal)"""
# pylint: disable=too-many-branches
# pylint: disable=too-many-nested-blocks
plus = babase.app.plus
assert plus is not None
try:
import datetime
app = babase.app
assert app.classic is not None
sale_times: list[int | None] = []
# Calc time for our pro sale (old special case).
if tab == 'extras':
config = app.config
if app.classic.accounts.have_pro():
return None
# If we haven't calced/loaded start times yet.
if app.classic.pro_sale_start_time is None:
# If we've got a time-remaining in our config, start there.
if 'PSTR' in config:
app.classic.pro_sale_start_time = int(
babase.apptime() * 1000
)
app.classic.pro_sale_start_val = config['PSTR']
else:
# We start the timer once we get the duration from
# the server.
start_duration = plus.get_v1_account_misc_read_val(
'proSaleDurationMinutes', None
)
if start_duration is not None:
app.classic.pro_sale_start_time = int(
babase.apptime() * 1000
)
app.classic.pro_sale_start_val = (
60000 * start_duration
)
# If we haven't heard from the server yet, no sale..
else:
return None
assert app.classic.pro_sale_start_val is not None
val: int | None = max(
0,
app.classic.pro_sale_start_val
- (
int(babase.apptime() * 1000.0)
- app.classic.pro_sale_start_time
),
)
# Keep the value in the config up to date. I suppose we should
# write the config occasionally but it should happen often
# enough for other reasons.
config['PSTR'] = val
if val == 0:
val = None
sale_times.append(val)
# Now look for sales in this tab.
sales_raw = plus.get_v1_account_misc_read_val('sales', {})
store_layout = self.get_store_layout()
for section in store_layout[tab]:
for item in section['items']:
if item in sales_raw:
if not plus.get_purchased(item):
to_end = (
datetime.datetime.utcfromtimestamp(
sales_raw[item]['e']
)
- datetime.datetime.utcnow()
).total_seconds()
if to_end > 0:
sale_times.append(int(to_end * 1000))
# Return the smallest time I guess?
sale_times_int = [t for t in sale_times if isinstance(t, int)]
return min(sale_times_int) if sale_times_int else None
except Exception:
logging.exception('Error calcing sale time.')
return None
def get_unowned_maps(self) -> list[str]:
"""Return the list of local maps not owned by the current account.
Category: **Asset Functions**
"""
plus = babase.app.plus
unowned_maps: set[str] = set()
if not babase.app.headless_mode:
for map_section in self.get_store_layout()['maps']:
for mapitem in map_section['items']:
if plus is None or not plus.get_purchased(mapitem):
m_info = self.get_store_item(mapitem)
unowned_maps.add(m_info['map_type'].name)
return sorted(unowned_maps)
def get_unowned_game_types(self) -> set[type[bascenev1.GameActivity]]:
"""Return present game types not owned by the current account."""
try:
plus = babase.app.plus
unowned_games: set[type[bascenev1.GameActivity]] = set()
if not babase.app.headless_mode:
for section in self.get_store_layout()['minigames']:
for mname in section['items']:
if plus is None or not plus.get_purchased(mname):
m_info = self.get_store_item(mname)
unowned_games.add(m_info['gametype'])
return unowned_games
except Exception:
logging.exception('Error calcing un-owned games.')
return set()

View file

@ -0,0 +1,781 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides classic app subsystem."""
from __future__ import annotations
from typing import TYPE_CHECKING
import random
import logging
import weakref
from efro.dataclassio import dataclass_from_dict
import babase
import bauiv1
import bascenev1
import _baclassic
from baclassic._music import MusicSubsystem
from baclassic._accountv1 import AccountV1Subsystem
from baclassic._ads import AdsSubsystem
from baclassic._net import MasterServerResponseType, MasterServerV1CallThread
from baclassic._achievement import AchievementSubsystem
from baclassic._tips import get_all_tips
from baclassic._store import StoreSubsystem
from baclassic import _input
if TYPE_CHECKING:
from typing import Callable, Any, Sequence
from bascenev1lib.actor import spazappearance
from bauiv1lib.party import PartyWindow
from baclassic._appdelegate import AppDelegate
from baclassic._servermode import ServerController
from baclassic._net import MasterServerCallback
class ClassicSubsystem(babase.AppSubsystem):
"""Subsystem for classic functionality in the app.
The single shared instance of this app can be accessed at
babase.app.classic. Note that it is possible for babase.app.classic to
be None if the classic package is not present, and code should handle
that case gracefully.
"""
# pylint: disable=too-many-public-methods
# noinspection PyUnresolvedReferences
from baclassic._music import MusicPlayMode
def __init__(self) -> None:
super().__init__()
self._env = babase.env()
self.accounts = AccountV1Subsystem()
self.ads = AdsSubsystem()
self.ach = AchievementSubsystem()
self.store = StoreSubsystem()
self.music = MusicSubsystem()
# Co-op Campaigns.
self.campaigns: dict[str, bascenev1.Campaign] = {}
self.custom_coop_practice_games: list[str] = []
# Lobby.
self.lobby_random_profile_index: int = 1
self.lobby_random_char_index_offset = random.randrange(1000)
self.lobby_account_profile_device_id: int | None = None
# Misc.
self.tips: list[str] = []
self.stress_test_reset_timer: babase.AppTimer | None = None
self.value_test_defaults: dict = {}
self.special_offer: dict | None = None
self.ping_thread_count = 0
self.allow_ticket_purchases: bool = not babase.app.iircade_mode
# Main Menu.
self.main_menu_did_initial_transition = False
self.main_menu_last_news_fetch_time: float | None = None
# Spaz.
self.spaz_appearances: dict[str, spazappearance.Appearance] = {}
self.last_spaz_turbo_warn_time = babase.AppTime(-99999.0)
# Server Mode.
self.server: ServerController | None = None
self.log_have_new = False
self.log_upload_timer_started = False
self.printed_live_object_warning = False
# We include this extra hash with shared input-mapping names so
# that we don't share mappings between differently-configured
# systems. For instance, different android devices may give different
# key values for the same controller type so we keep their mappings
# distinct.
self.input_map_hash: str | None = None
# Maps.
self.maps: dict[str, type[bascenev1.Map]] = {}
# Gameplay.
self.teams_series_length = 7
self.ffa_series_length = 24
self.coop_session_args: dict = {}
# UI.
self.first_main_menu = True # FIXME: Move to mainmenu class.
self.did_menu_intro = False # FIXME: Move to mainmenu class.
self.main_menu_window_refresh_check_count = 0 # FIXME: Mv to mainmenu.
self.invite_confirm_windows: list[Any] = [] # FIXME: Don't use Any.
self.delegate: AppDelegate | None = None
self.party_window: weakref.ref[PartyWindow] | None = None
# Store.
self.store_layout: dict[str, list[dict[str, Any]]] | None = None
self.store_items: dict[str, dict] | None = None
self.pro_sale_start_time: int | None = None
self.pro_sale_start_val: int | None = None
@property
def platform(self) -> str:
"""Name of the current platform.
Examples are: 'mac', 'windows', android'.
"""
assert isinstance(self._env['platform'], str)
return self._env['platform']
@property
def subplatform(self) -> str:
"""String for subplatform.
Can be empty. For the 'android' platform, subplatform may
be 'google', 'amazon', etc.
"""
assert isinstance(self._env['subplatform'], str)
return self._env['subplatform']
@property
def legacy_user_agent_string(self) -> str:
"""String containing various bits of info about OS/device/etc."""
assert isinstance(self._env['legacy_user_agent_string'], str)
return self._env['legacy_user_agent_string']
def on_app_loading(self) -> None:
from bascenev1lib.actor import spazappearance
from bascenev1lib import maps as stdmaps
from baclassic._appdelegate import AppDelegate
plus = babase.app.plus
assert plus is not None
cfg = babase.app.config
self.music.on_app_loading()
self.delegate = AppDelegate()
# Non-test, non-debug builds should generally be blessed; warn if not.
# (so I don't accidentally release a build that can't play tourneys)
if (
not babase.app.debug_build
and not babase.app.test_build
and not plus.is_blessed()
):
babase.screenmessage('WARNING: NON-BLESSED BUILD', color=(1, 0, 0))
# FIXME: This should not be hard-coded.
for maptype in [
stdmaps.HockeyStadium,
stdmaps.FootballStadium,
stdmaps.Bridgit,
stdmaps.BigG,
stdmaps.Roundabout,
stdmaps.MonkeyFace,
stdmaps.ZigZag,
stdmaps.ThePad,
stdmaps.DoomShroom,
stdmaps.LakeFrigid,
stdmaps.TipTop,
stdmaps.CragCastle,
stdmaps.TowerD,
stdmaps.HappyThoughts,
stdmaps.StepRightUp,
stdmaps.Courtyard,
stdmaps.Rampage,
]:
bascenev1.register_map(maptype)
spazappearance.register_appearances()
bascenev1.init_campaigns()
launch_count = cfg.get('launchCount', 0)
launch_count += 1
# So we know how many times we've run the game at various
# version milestones.
for key in ('lc14173', 'lc14292'):
cfg.setdefault(key, launch_count)
cfg['launchCount'] = launch_count
cfg.commit()
# Run a test in a few seconds to see if we should pop up an existing
# pending special offer.
def check_special_offer() -> None:
assert plus is not None
from bauiv1lib.specialoffer import show_offer
if (
'pendingSpecialOffer' in cfg
and plus.get_v1_account_public_login_id()
== cfg['pendingSpecialOffer']['a']
):
self.special_offer = cfg['pendingSpecialOffer']['o']
show_offer()
if not babase.app.headless_mode:
babase.apptimer(3.0, check_special_offer)
# If there's a leftover log file, attempt to upload it to the
# master-server and/or get rid of it.
babase.handle_leftover_v1_cloud_log_file()
self.accounts.on_app_loading()
def on_app_pause(self) -> None:
self.accounts.on_app_pause()
def on_app_resume(self) -> None:
self.accounts.on_app_resume()
self.music.on_app_resume()
def on_app_shutdown(self) -> None:
self.music.on_app_shutdown()
def pause(self) -> None:
"""Pause the game due to a user request or menu popping up.
If there's a foreground host-activity that says it's pausable, tell it
to pause. Note: we now no longer pause if there are connected clients.
"""
activity: bascenev1.Activity | None = (
bascenev1.get_foreground_host_activity()
)
if (
activity is not None
and activity.allow_pausing
and not bascenev1.have_connected_clients()
):
from babase import Lstr
from bascenev1 import NodeActor
# FIXME: Shouldn't be touching scene stuff here;
# should just pass the request on to the host-session.
with activity.context:
globs = activity.globalsnode
if not globs.paused:
bascenev1.getsound('refWhistle').play()
globs.paused = True
# FIXME: This should not be an attr on Actor.
activity.paused_text = NodeActor(
bascenev1.newnode(
'text',
attrs={
'text': Lstr(resource='pausedByHostText'),
'client_only': True,
'flatness': 1.0,
'h_align': 'center',
},
)
)
def resume(self) -> None:
"""Resume the game due to a user request or menu closing.
If there's a foreground host-activity that's currently paused, tell it
to resume.
"""
# FIXME: Shouldn't be touching scene stuff here;
# should just pass the request on to the host-session.
activity = bascenev1.get_foreground_host_activity()
if activity is not None:
with activity.context:
globs = activity.globalsnode
if globs.paused:
bascenev1.getsound('refWhistle').play()
globs.paused = False
# FIXME: This should not be an actor attr.
activity.paused_text = None
def add_coop_practice_level(self, level: bascenev1.Level) -> None:
"""Adds an individual level to the 'practice' section in Co-op."""
# Assign this level to our catch-all campaign.
self.campaigns['Challenges'].addlevel(level)
# Make note to add it to our challenges UI.
self.custom_coop_practice_games.append(f'Challenges:{level.name}')
def launch_coop_game(
self, game: str, force: bool = False, args: dict | None = None
) -> bool:
"""High level way to launch a local co-op session."""
# pylint: disable=cyclic-import
from bauiv1lib.coop.level import CoopLevelLockedWindow
assert babase.app.classic is not None
if args is None:
args = {}
if game == '':
raise ValueError('empty game name')
campaignname, levelname = game.split(':')
campaign = babase.app.classic.getcampaign(campaignname)
# If this campaign is sequential, make sure we've completed the
# one before this.
if campaign.sequential and not force:
for level in campaign.levels:
if level.name == levelname:
break
if not level.complete:
CoopLevelLockedWindow(
campaign.getlevel(levelname).displayname,
campaign.getlevel(level.name).displayname,
)
return False
# Ok, we're good to go.
self.coop_session_args = {
'campaign': campaignname,
'level': levelname,
}
for arg_name, arg_val in list(args.items()):
self.coop_session_args[arg_name] = arg_val
def _fade_end() -> None:
from bascenev1 import CoopSession
try:
bascenev1.new_host_session(CoopSession)
except Exception:
logging.exception('Error creating coopsession after fade end.')
from bascenev1lib.mainmenu import MainMenuSession
bascenev1.new_host_session(MainMenuSession)
babase.fade_screen(False, endcall=_fade_end)
return True
def return_to_main_menu_session_gracefully(
self, reset_ui: bool = True
) -> None:
"""Attempt to cleanly get back to the main menu."""
# pylint: disable=cyclic-import
from baclassic import _benchmark
from bascenev1lib.mainmenu import MainMenuSession
plus = babase.app.plus
assert plus is not None
if reset_ui:
babase.app.ui_v1.clear_main_menu_window()
if isinstance(bascenev1.get_foreground_host_session(), MainMenuSession):
# It may be possible we're on the main menu but the screen is faded
# so fade back in.
babase.fade_screen(True)
return
_benchmark.stop_stress_test() # Stop stress-test if in progress.
# If we're in a host-session, tell them to end.
# This lets them tear themselves down gracefully.
host_session: bascenev1.Session | None = (
bascenev1.get_foreground_host_session()
)
if host_session is not None:
# Kick off a little transaction so we'll hopefully have all the
# latest account state when we get back to the menu.
plus.add_v1_account_transaction(
{'type': 'END_SESSION', 'sType': str(type(host_session))}
)
plus.run_v1_account_transactions()
host_session.end()
# Otherwise just force the issue.
else:
babase.pushcall(
babase.Call(bascenev1.new_host_session, MainMenuSession)
)
def getmaps(self, playtype: str) -> list[str]:
"""Return a list of bascenev1.Map types supporting a playtype str.
Category: **Asset Functions**
Maps supporting a given playtype must provide a particular set of
features and lend themselves to a certain style of play.
Play Types:
'melee'
General fighting map.
Has one or more 'spawn' locations.
'team_flag'
For games such as Capture The Flag where each team spawns by a flag.
Has two or more 'spawn' locations, each with a corresponding 'flag'
location (based on index).
'single_flag'
For games such as King of the Hill or Keep Away where multiple teams
are fighting over a single flag.
Has two or more 'spawn' locations and 1 'flag_default' location.
'conquest'
For games such as Conquest where flags are spread throughout the map
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
'king_of_the_hill' - has 2+ 'spawn' locations,
1+ 'flag_default' locations, and 1+ 'powerup_spawn' locations
'hockey'
For hockey games.
Has two 'goal' locations, corresponding 'spawn' locations, and one
'flag_default' location (for where puck spawns)
'football'
For football games.
Has two 'goal' locations, corresponding 'spawn' locations, and one
'flag_default' location (for where flag/ball/etc. spawns)
'race'
For racing games where players much touch each region in order.
Has two or more 'race_point' locations.
"""
return sorted(
key
for key, val in self.maps.items()
if playtype in val.get_play_types()
)
def show_online_score_ui(
self,
show: str = 'general',
game: str | None = None,
game_version: str | None = None,
) -> None:
"""(internal)"""
bauiv1.show_online_score_ui(show, game, game_version)
def game_begin_analytics(self) -> None:
"""(internal)"""
from baclassic import _analytics
_analytics.game_begin_analytics()
def master_server_v1_get(
self,
request: str,
data: dict[str, Any],
callback: MasterServerCallback | None = None,
response_type: MasterServerResponseType = MasterServerResponseType.JSON,
) -> None:
"""Make a call to the master server via a http GET."""
MasterServerV1CallThread(
request, 'get', data, callback, response_type
).start()
def master_server_v1_post(
self,
request: str,
data: dict[str, Any],
callback: MasterServerCallback | None = None,
response_type: MasterServerResponseType = MasterServerResponseType.JSON,
) -> None:
"""Make a call to the master server via a http POST."""
MasterServerV1CallThread(
request, 'post', data, callback, response_type
).start()
def get_tournament_prize_strings(self, entry: dict[str, Any]) -> list[str]:
"""Given a tournament entry, return strings for its prize levels."""
from baclassic import _tournament
return _tournament.get_tournament_prize_strings(entry)
def getcampaign(self, name: str) -> bascenev1.Campaign:
"""Return a campaign by name."""
return self.campaigns[name]
def get_next_tip(self) -> str:
"""Returns the next tip to be displayed."""
if not self.tips:
for tip in get_all_tips():
self.tips.insert(random.randint(0, len(self.tips)), tip)
tip = self.tips.pop()
return tip
def run_gpu_benchmark(self) -> None:
"""Kick off a benchmark to test gpu speeds."""
from baclassic._benchmark import run_gpu_benchmark as run
run()
def run_cpu_benchmark(self) -> None:
"""Kick off a benchmark to test cpu speeds."""
from baclassic._benchmark import run_cpu_benchmark as run
run()
def run_media_reload_benchmark(self) -> None:
"""Kick off a benchmark to test media reloading speeds."""
from baclassic._benchmark import run_media_reload_benchmark as run
run()
def run_stress_test(
self,
playlist_type: str = 'Random',
playlist_name: str = '__default__',
player_count: int = 8,
round_duration: int = 30,
) -> None:
"""Run a stress test."""
from baclassic._benchmark import run_stress_test as run
run(playlist_type, playlist_name, player_count, round_duration)
def get_input_device_mapped_value(
self, device: bascenev1.InputDevice, name: str
) -> Any:
"""Returns a mapped value for an input device.
This checks the user config and falls back to default values
where available.
"""
return _input.get_input_device_mapped_value(
device.name, device.unique_identifier, name
)
def get_input_device_map_hash(
self, inputdevice: bascenev1.InputDevice
) -> str:
"""Given an input device, return hash based on its raw input values."""
del inputdevice # unused currently
return _input.get_input_device_map_hash()
def get_input_device_config(
self, inputdevice: bascenev1.InputDevice, default: bool
) -> tuple[dict, str]:
"""Given an input device, return its config dict in the app config.
The dict will be created if it does not exist.
"""
return _input.get_input_device_config(
inputdevice.name, inputdevice.unique_identifier, default
)
def get_player_colors(self) -> list[tuple[float, float, float]]:
"""Return user-selectable player colors."""
return bascenev1.get_player_colors()
def get_player_profile_icon(self, profilename: str) -> str:
"""Given a profile name, returns an icon string for it.
(non-account profiles only)
"""
return bascenev1.get_player_profile_icon(profilename)
def get_player_profile_colors(
self,
profilename: str | None,
profiles: dict[str, dict[str, Any]] | None = None,
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Given a profile, return colors for them."""
return bascenev1.get_player_profile_colors(profilename, profiles)
def get_foreground_host_session(self) -> bascenev1.Session | None:
"""(internal)"""
return bascenev1.get_foreground_host_session()
def get_foreground_host_activity(self) -> bascenev1.Activity | None:
"""(internal)"""
return bascenev1.get_foreground_host_activity()
def show_config_error_window(self) -> bool:
"""(internal)"""
if self.platform in ('mac', 'linux', 'windows'):
from bauiv1lib.configerror import ConfigErrorWindow
babase.pushcall(ConfigErrorWindow)
return True
return False
def value_test(
self,
arg: str,
change: float | None = None,
absolute: float | None = None,
) -> float:
"""(internal)"""
return _baclassic.value_test(arg, change, absolute)
def set_master_server_source(self, source: int) -> None:
"""(internal)"""
bascenev1.set_master_server_source(source)
def get_game_port(self) -> int:
"""(internal)"""
return bascenev1.get_game_port()
def v2_upgrade_window(self, login_name: str, code: str) -> None:
"""(internal)"""
from bauiv1lib.v2upgrade import V2UpgradeWindow
V2UpgradeWindow(login_name, code)
def account_link_code_window(self, data: dict[str, Any]) -> None:
"""(internal)"""
from bauiv1lib.account.link import AccountLinkCodeWindow
AccountLinkCodeWindow(data)
def server_dialog(self, delay: float, data: dict[str, Any]) -> None:
"""(internal)"""
from bauiv1lib.serverdialog import (
ServerDialogData,
ServerDialogWindow,
)
try:
sddata = dataclass_from_dict(ServerDialogData, data)
except Exception:
sddata = None
logging.warning(
'Got malformatted ServerDialogData: %s',
data,
)
if sddata is not None:
babase.apptimer(
delay,
babase.Call(ServerDialogWindow, sddata),
)
def ticket_icon_press(self) -> None:
"""(internal)"""
from bauiv1lib.resourcetypeinfo import ResourceTypeInfoWindow
ResourceTypeInfoWindow(
origin_widget=bauiv1.get_special_widget('tickets_info_button')
)
def show_url_window(self, address: str) -> None:
"""(internal)"""
from bauiv1lib.url import ShowURLWindow
ShowURLWindow(address)
def quit_window(self) -> None:
"""(internal)"""
from bauiv1lib.confirm import QuitWindow
QuitWindow()
def tournament_entry_window(
self,
tournament_id: str,
tournament_activity: bascenev1.Activity | None = None,
position: tuple[float, float] = (0.0, 0.0),
delegate: Any = None,
scale: float | None = None,
offset: tuple[float, float] = (0.0, 0.0),
on_close_call: Callable[[], Any] | None = None,
) -> None:
"""(internal)"""
from bauiv1lib.tournamententry import TournamentEntryWindow
TournamentEntryWindow(
tournament_id,
tournament_activity,
position,
delegate,
scale,
offset,
on_close_call,
)
def get_main_menu_session(self) -> type[bascenev1.Session]:
"""(internal)"""
from bascenev1lib.mainmenu import MainMenuSession
return MainMenuSession
def continues_window(
self,
activity: bascenev1.Activity,
cost: int,
continue_call: Callable[[], Any],
cancel_call: Callable[[], Any],
) -> None:
"""(internal)"""
from bauiv1lib.continues import ContinuesWindow
ContinuesWindow(activity, cost, continue_call, cancel_call)
def profile_browser_window(
self,
transition: str = 'in_right',
in_main_menu: bool = True,
selected_profile: str | None = None,
origin_widget: bauiv1.Widget | None = None,
) -> None:
"""(internal)"""
from bauiv1lib.profile.browser import ProfileBrowserWindow
ProfileBrowserWindow(
transition, in_main_menu, selected_profile, origin_widget
)
def preload_map_preview_media(self) -> None:
"""Preload media needed for map preview UIs.
Category: **Asset Functions**
"""
try:
bauiv1.getmesh('level_select_button_opaque')
bauiv1.getmesh('level_select_button_transparent')
for maptype in list(self.maps.values()):
map_tex_name = maptype.get_preview_texture_name()
if map_tex_name is not None:
bauiv1.gettexture(map_tex_name)
except Exception:
logging.exception('Error preloading map preview media.')
def party_icon_activate(self, origin: Sequence[float]) -> None:
"""(internal)"""
from bauiv1lib.party import PartyWindow
from babase import app
assert not app.headless_mode
bauiv1.getsound('swish').play()
# If it exists, dismiss it; otherwise make a new one.
party_window = (
None if self.party_window is None else self.party_window()
)
if party_window is not None:
party_window.close()
else:
self.party_window = weakref.ref(PartyWindow(origin=origin))
def device_menu_press(self, device_id: int | None) -> None:
"""(internal)"""
from bauiv1lib.mainmenu import MainMenuWindow
from bauiv1 import set_ui_input_device
assert babase.app is not None
in_main_menu = babase.app.ui_v1.has_main_menu_window()
if not in_main_menu:
set_ui_input_device(device_id)
if not babase.app.headless_mode:
bauiv1.getsound('swish').play()
babase.app.ui_v1.set_main_menu_window(
MainMenuWindow().get_root_widget()
)

View file

@ -1,29 +1,18 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to game tips.
"""Functionality related to classic game tips.
These can be shown at opportune times such as between rounds."""
from __future__ import annotations
from typing import TYPE_CHECKING
import random
import _ba
import babase
if TYPE_CHECKING:
pass
def get_next_tip() -> str:
"""Returns the next tip to be displayed."""
app = _ba.app
if not app.tips:
for tip in get_all_tips():
app.tips.insert(random.randint(0, len(app.tips)), tip)
tip = app.tips.pop()
return tip
def get_all_tips() -> list[str]:
"""Return the complete list of tips."""
tips = [
@ -116,14 +105,15 @@ def get_all_tips() -> list[str]:
'color of sparks from its fuse: yellow..orange..red..BOOM.'
),
]
app = _ba.app
app = babase.app
if not app.iircade_mode:
tips += [
'If your framerate is choppy, try turning down resolution\nor '
'visuals in the game\'s graphics settings.'
]
if (
app.platform in ('android', 'ios')
app.classic is not None
and app.classic.platform in ('android', 'ios')
and not app.on_tv
and not app.iircade_mode
):
@ -134,7 +124,11 @@ def get_all_tips() -> list[str]:
'in Settings->Graphics'
),
]
if app.platform in ['mac', 'android'] and not app.iircade_mode:
if (
app.classic is not None
and app.classic.platform in ['mac', 'android']
and not app.iircade_mode
):
tips += [
'Tired of the soundtrack? Replace it with your own!'
'\nSee Settings->Audio->Soundtrack'
@ -142,7 +136,11 @@ def get_all_tips() -> list[str]:
# Hot-plugging is currently only on some platforms.
# FIXME: Should add a platform entry for this so don't forget to update it.
if app.platform in ['mac', 'android', 'windows'] and not app.iircade_mode:
if (
app.classic is not None
and app.classic.platform in ['mac', 'android', 'windows']
and not app.iircade_mode
):
tips += [
'Players can join and leave in the middle of most games,\n'
'and you can also plug and unplug controllers on the fly.',

View file

@ -1,12 +1,12 @@
# Released under the MIT License. See LICENSE for details.
#
"""Functionality related to tournament play."""
"""Functionality related to classic tournament play."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
import babase
if TYPE_CHECKING:
from typing import Any
@ -15,8 +15,7 @@ if TYPE_CHECKING:
def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
"""Given a tournament entry, return strings for its prize levels."""
# pylint: disable=too-many-locals
from ba._generated.enums import SpecialChar
from ba._gameutils import get_trophy_string
from bascenev1 import get_trophy_string
range1 = entry.get('prizeRange1')
range2 = entry.get('prizeRange2')
@ -47,7 +46,11 @@ def get_tournament_prize_strings(entry: dict[str, Any]) -> list[str]:
# If we've got trophies but not for this entry, throw some space
# in to compensate so the ticket counts line up.
if prize is not None:
pvval = _ba.charstr(SpecialChar.TICKET_BACKING) + str(prize) + pvval
pvval = (
babase.charstr(babase.SpecialChar.TICKET_BACKING)
+ str(prize)
+ pvval
)
out_vals.append(prval)
out_vals.append(pvval)
return out_vals

View file

@ -3,12 +3,14 @@
"""Music playback functionality using the Mac Music (formerly iTunes) app."""
from __future__ import annotations
import logging
import threading
from collections import deque
from typing import TYPE_CHECKING
import _ba
from ba._music import MusicPlayer
import babase
from baclassic._music import MusicPlayer
if TYPE_CHECKING:
from typing import Callable, Any
@ -32,7 +34,7 @@ class MacMusicAppMusicPlayer(MusicPlayer):
selection_target_name: str,
) -> Any:
# pylint: disable=cyclic-import
from bastd.ui.soundtrack import entrytypeselect as etsel
from bauiv1lib.soundtrack import entrytypeselect as etsel
return etsel.SoundtrackEntryTypeSelectWindow(
callback, current_entry, selection_target_name
@ -46,7 +48,8 @@ class MacMusicAppMusicPlayer(MusicPlayer):
self._thread.get_playlists(callback)
def on_play(self, entry: Any) -> None:
music = _ba.app.music
assert babase.app.classic is not None
music = babase.app.classic.music
entry_type = music.get_soundtrack_entry_type(entry)
if entry_type == 'iTunesPlaylist':
self._thread.play_playlist(music.get_soundtrack_entry_name(entry))
@ -76,32 +79,27 @@ class _MacMusicAppThread(threading.Thread):
def run(self) -> None:
"""Run the Music.app thread."""
from ba._general import Call
from ba._language import Lstr
from ba._generated.enums import TimeType
_ba.set_thread_name('BA_MacMusicAppThread')
_ba.mac_music_app_init()
babase.set_thread_name('BA_MacMusicAppThread')
babase.mac_music_app_init()
# Let's mention to the user we're launching Music.app in case
# it causes any funny business (this used to background the app
# sometimes, though I think that is fixed now)
def do_print() -> None:
_ba.timer(
babase.apptimer(
1.0,
Call(
_ba.screenmessage,
Lstr(resource='usingItunesText'),
babase.Call(
babase.screenmessage,
babase.Lstr(resource='usingItunesText'),
(0, 1, 0),
),
timetype=TimeType.REAL,
)
_ba.pushcall(do_print, from_other_thread=True)
babase.pushcall(do_print, from_other_thread=True)
# Here we grab this to force the actual launch.
_ba.mac_music_app_get_volume()
_ba.mac_music_app_get_library_source()
babase.mac_music_app_get_volume()
babase.mac_music_app_get_library_source()
done = False
while not done:
self._commands_available.wait()
@ -137,16 +135,15 @@ class _MacMusicAppThread(threading.Thread):
if old_volume > 0.0 and volume == 0.0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
babase.mac_music_app_stop()
babase.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
elif self._volume > 0:
# If volume was zero, store pre-playing volume and start
# playing.
if old_volume == 0.0:
self._orig_volume = _ba.mac_music_app_get_volume()
self._orig_volume = babase.mac_music_app_get_volume()
self._update_mac_music_app_volume()
if old_volume == 0.0:
self._play_current_playlist()
@ -170,10 +167,8 @@ class _MacMusicAppThread(threading.Thread):
def _handle_get_playlists_command(
self, target: Callable[[list[str]], None]
) -> None:
from ba._general import Call
try:
playlists = _ba.mac_music_app_get_playlists()
playlists = babase.mac_music_app_get_playlists()
playlists = [
p
for p in playlists
@ -197,15 +192,15 @@ class _MacMusicAppThread(threading.Thread):
except Exception as exc:
print('Error getting iTunes playlists:', exc)
playlists = []
_ba.pushcall(Call(target, playlists), from_other_thread=True)
babase.pushcall(babase.Call(target, playlists), from_other_thread=True)
def _handle_play_command(self, target: str | None) -> None:
if target is None:
if self._current_playlist is not None and self._volume > 0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
babase.mac_music_app_stop()
babase.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
self._current_playlist = None
@ -215,42 +210,39 @@ class _MacMusicAppThread(threading.Thread):
if self._current_playlist is not None and self._volume > 0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
babase.mac_music_app_stop()
babase.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
# Set our playlist and play it if our volume is up.
self._current_playlist = target
if self._volume > 0:
self._orig_volume = _ba.mac_music_app_get_volume()
self._orig_volume = babase.mac_music_app_get_volume()
self._update_mac_music_app_volume()
self._play_current_playlist()
def _handle_die_command(self) -> None:
# Only stop if we've actually played something
# (we don't want to kill music the user has playing).
if self._current_playlist is not None and self._volume > 0:
try:
assert self._orig_volume is not None
_ba.mac_music_app_stop()
_ba.mac_music_app_set_volume(self._orig_volume)
babase.mac_music_app_stop()
babase.mac_music_app_set_volume(self._orig_volume)
except Exception as exc:
print('Error stopping iTunes music:', exc)
def _play_current_playlist(self) -> None:
try:
from ba._general import Call
assert self._current_playlist is not None
if _ba.mac_music_app_play_playlist(self._current_playlist):
if babase.mac_music_app_play_playlist(self._current_playlist):
pass
else:
_ba.pushcall(
Call(
_ba.screenmessage,
_ba.app.lang.get_resource('playlistNotFoundText')
babase.pushcall(
babase.Call(
babase.screenmessage,
babase.app.lang.get_resource('playlistNotFoundText')
+ ': \''
+ self._current_playlist
+ '\'',
@ -259,13 +251,11 @@ class _MacMusicAppThread(threading.Thread):
from_other_thread=True,
)
except Exception:
from ba import _error
_error.print_exception(
f'error playing playlist {self._current_playlist}'
logging.exception(
"Error playing playlist '%s'.", self._current_playlist
)
def _update_mac_music_app_volume(self) -> None:
_ba.mac_music_app_set_volume(
babase.mac_music_app_set_volume(
max(0, min(100, int(100.0 * self._volume)))
)

View file

@ -5,11 +5,13 @@ from __future__ import annotations
import os
import random
import logging
import threading
from typing import TYPE_CHECKING
import _ba
from ba._music import MusicPlayer
import babase
from baclassic._music import MusicPlayer
if TYPE_CHECKING:
from typing import Callable, Any
@ -38,7 +40,7 @@ class OSMusicPlayer(MusicPlayer):
selection_target_name: str,
) -> Any:
# pylint: disable=cyclic-import
from bastd.ui.soundtrack.entrytypeselect import (
from bauiv1lib.soundtrack.entrytypeselect import (
SoundtrackEntryTypeSelectWindow,
)
@ -47,18 +49,18 @@ class OSMusicPlayer(MusicPlayer):
)
def on_set_volume(self, volume: float) -> None:
_ba.music_player_set_volume(volume)
babase.music_player_set_volume(volume)
def on_play(self, entry: Any) -> None:
music = _ba.app.music
assert babase.app.classic is not None
music = babase.app.classic.music
entry_type = music.get_soundtrack_entry_type(entry)
name = music.get_soundtrack_entry_name(entry)
assert name is not None
if entry_type == 'musicFile':
self._want_to_play = self._actually_playing = True
_ba.music_player_play(name)
babase.music_player_play(name)
elif entry_type == 'musicFolder':
# Launch a thread to scan this folder and give us a random
# valid file within it.
self._want_to_play = True
@ -72,10 +74,8 @@ class OSMusicPlayer(MusicPlayer):
def _on_play_folder_cb(
self, result: str | list[str], error: str | None = None
) -> None:
from ba import _language
if error is not None:
rstr = _language.Lstr(
rstr = babase.Lstr(
resource='internal.errorPlayingMusicText'
).evaluate()
if isinstance(result, str):
@ -88,7 +88,7 @@ class OSMusicPlayer(MusicPlayer):
err_str = (
rstr.replace('${MUSIC}', '<multiple>') + '; ' + str(error)
)
_ba.screenmessage(err_str, color=(1, 0, 0))
babase.screenmessage(err_str, color=(1, 0, 0))
return
# There's a chance a stop could have been issued before our thread
@ -97,15 +97,15 @@ class OSMusicPlayer(MusicPlayer):
print('_on_play_folder_cb called with _want_to_play False')
else:
self._actually_playing = True
_ba.music_player_play(result)
babase.music_player_play(result)
def on_stop(self) -> None:
self._want_to_play = False
self._actually_playing = False
_ba.music_player_stop()
babase.music_player_stop()
def on_app_shutdown(self) -> None:
_ba.music_player_shutdown()
babase.music_player_shutdown()
class _PickFolderSongThread(threading.Thread):
@ -121,12 +121,9 @@ class _PickFolderSongThread(threading.Thread):
self._path = path
def run(self) -> None:
from ba import _language
from ba._general import Call
do_print_error = True
do_log_error = True
try:
_ba.set_thread_name('BA_PickFolderSongThread')
babase.set_thread_name('BA_PickFolderSongThread')
all_files: list[str] = []
valid_extensions = ['.' + x for x in self._valid_extensions]
for root, _subdirs, filenames in os.walk(self._path):
@ -139,25 +136,24 @@ class _PickFolderSongThread(threading.Thread):
root + '/' + fname,
)
if not all_files:
do_print_error = False
do_log_error = False
raise RuntimeError(
_language.Lstr(
babase.Lstr(
resource='internal.noMusicFilesInFolderText'
).evaluate()
)
_ba.pushcall(
Call(self._callback, all_files, None), from_other_thread=True
babase.pushcall(
babase.Call(self._callback, all_files, None),
from_other_thread=True,
)
except Exception as exc:
from ba import _error
if do_print_error:
_error.print_exception()
if do_log_error:
logging.exception('Error in _PickFolderSongThread')
try:
err_str = str(exc)
except Exception:
err_str = '<ENCERR4523>'
_ba.pushcall(
Call(self._callback, self._path, err_str),
babase.pushcall(
babase.Call(self._callback, self._path, err_str),
from_other_thread=True,
)

View file

@ -1,18 +0,0 @@
# Released under the MIT License. See LICENSE for details.
#
"""Error related functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
class RemoteError(Exception):
"""An error occurred on the other end of some connection."""
def __str__(self) -> str:
s = ''.join(str(arg) for arg in self.args)
return f'Remote Exception Follows:\n{s}'

View file

@ -59,7 +59,7 @@ class DirectoryManifest:
sha = hashlib.sha256()
fullfilepath = os.path.join(pathstr, filepath)
if not os.path.isfile(fullfilepath):
raise Exception(f'File not found: "{fullfilepath}"')
raise RuntimeError(f'File not found: "{fullfilepath}".')
with open(fullfilepath, 'rb') as infile:
filebytes = infile.read()
filesize = len(filebytes)

528
dist/ba_data/python/baenv.py vendored Normal file
View file

@ -0,0 +1,528 @@
# Released under the MIT License. See LICENSE for details.
#
"""Manage ballistica execution environment.
This module is used to set up and/or check the global Python environment
before running a ballistica app. This includes things such as paths,
logging, and app-dirs. Because these things are global in nature, this
should be done before any ballistica modules are imported.
This module can also be exec'ed directly to set up a default environment
and then run the app.
Ballistica can be used without explicitly configuring the environment in
order to integrate it in arbitrary Python environments, but this may
cause some features to be disabled or behave differently than expected.
"""
from __future__ import annotations
import os
import sys
import logging
from pathlib import Path
from dataclasses import dataclass
from typing import TYPE_CHECKING
import __main__
if TYPE_CHECKING:
from typing import Any
from efro.log import LogHandler
# IMPORTANT - It is likely (and in some cases expected) that this
# module's code will be exec'ed multiple times. This is because it is
# the job of this module to set up Python paths for an engine run, and
# that may involve modifying sys.path in such a way that this module
# resolves to a different path afterwards (for example from
# /abs/path/to/ba_data/scripts/babase.py to ba_data/scripts/babase.py).
# This can result in the next import of baenv loading us from our 'new'
# location, which may or may not actually be the same file on disk as
# the last load. Either way, however, multiple execs will happen in some
# form.
#
# So we need to do a few things to handle that situation gracefully.
#
# - First, we need to store any mutable global state in the __main__
# module; not in ourself. This way, alternate versions of ourself will
# still know if we already ran configure/etc.
#
# - Second, we should avoid the use of isinstance and similar calls for
# our types. An EnvConfig we create would technically be a different
# type than that created by an alternate baenv.
# Build number and version of the ballistica binary we expect to be
# using.
TARGET_BALLISTICA_BUILD = 21212
TARGET_BALLISTICA_VERSION = '1.7.26'
@dataclass
class EnvConfig:
"""Final config values we provide to the engine."""
# Where app config/state data lives.
config_dir: str
# Directory containing ba_data and any other platform-specific data.
data_dir: str
# Where the app's built-in Python stuff lives.
app_python_dir: str | None
# Where the app's built-in Python stuff lives in the default case.
standard_app_python_dir: str
# Where the app's bundled third party Python stuff lives.
site_python_dir: str | None
# Custom Python provided by the user (mods).
user_python_dir: str | None
# We have a mechanism allowing app scripts to be overridden by
# placing a specially named directory in a user-scripts dir.
# This is true if that is enabled.
is_user_app_python_dir: bool
# Our fancy app log handler. This handles feeding logs, stdout, and
# stderr into the engine so they show up on in-app consoles, etc.
log_handler: LogHandler | None
# Initial data from the ballisticakit-config.json file. This is
# passed mostly as an optimization to avoid reading the same config
# file twice, since config data is first needed in baenv and next in
# the engine. It will be cleared after passing it to the app's
# config management subsystem and should not be accessed by any
# other code.
initial_app_config: Any
@dataclass
class _EnvGlobals:
"""Globals related to baenv's operation.
We store this in __main__ instead of in our own module because it
is likely that multiple versions of our module will be spun up
and we want a single set of globals (see notes at top of our module
code).
"""
config: EnvConfig | None = None
called_configure: bool = False
paths_set_failed: bool = False
modular_main_called: bool = False
@classmethod
def get(cls) -> _EnvGlobals:
"""Create/return our singleton."""
name = '_baenv_globals'
envglobals: _EnvGlobals | None = getattr(__main__, name, None)
if envglobals is None:
envglobals = _EnvGlobals()
setattr(__main__, name, envglobals)
return envglobals
def did_paths_set_fail() -> bool:
"""Did we try to set paths and fail?"""
return _EnvGlobals.get().paths_set_failed
def config_exists() -> bool:
"""Has a config been created?"""
return _EnvGlobals.get().config is not None
def get_config() -> EnvConfig:
"""Return the active config, creating a default if none exists."""
envglobals = _EnvGlobals.get()
# If configure() has not been explicitly called, set up a
# minimally-intrusive default config. We want Ballistica to default
# to being a good citizen when imported into alien environments and
# not blow away logging or otherwise muck with stuff. All official
# paths to run Ballistica apps should be explicitly calling
# configure() first to get a full featured setup.
if not envglobals.called_configure:
configure(setup_logging=False)
config = envglobals.config
if config is None:
raise RuntimeError(
'baenv.configure() has been called but no config exists;'
' perhaps it errored?'
)
return config
def configure(
config_dir: str | None = None,
data_dir: str | None = None,
user_python_dir: str | None = None,
app_python_dir: str | None = None,
site_python_dir: str | None = None,
contains_python_dist: bool = False,
setup_logging: bool = True,
) -> None:
"""Set up the environment for running a Ballistica app.
This includes things such as Python path wrangling and app directory
creation. This must be called before any actual Ballistica modules
are imported; the environment is locked in as soon as that happens.
"""
envglobals = _EnvGlobals.get()
# Keep track of whether we've been *called*, not whether a config
# has been created. Otherwise its possible to get multiple
# overlapping configure calls going.
if envglobals.called_configure:
raise RuntimeError(
'baenv.configure() has already been called;'
' it can only be called once.'
)
envglobals.called_configure = True
# The very first thing we do is setup Python paths (while also
# calculating some engine paths). This code needs to be bulletproof
# since we have no logging yet at this point. We used to set up
# logging first, but this way logging stuff will get loaded from its
# proper final path (otherwise we might wind up using two different
# versions of efro.logging in a single engine run).
(
user_python_dir,
app_python_dir,
site_python_dir,
data_dir,
config_dir,
standard_app_python_dir,
is_user_app_python_dir,
) = _setup_paths(
user_python_dir,
app_python_dir,
site_python_dir,
data_dir,
config_dir,
)
# The second thing we do is set up our logging system and pipe
# Python's stdout/stderr into it. At this point we can at least
# debug problems on systems where native stdout/stderr is not easily
# accessible such as Android.
log_handler = _setup_logging() if setup_logging else None
# We want to always be run in UTF-8 mode; complain if we're not.
if sys.flags.utf8_mode != 1:
logging.warning(
"Python's UTF-8 mode is not set. Running Ballistica without"
' it may lead to errors.'
)
# Attempt to create dirs that we'll write stuff to.
_setup_dirs(config_dir, user_python_dir)
# Get ssl working if needed so we can use https and all that.
_setup_certs(contains_python_dist)
# This is now the active config.
envglobals.config = EnvConfig(
config_dir=config_dir,
data_dir=data_dir,
user_python_dir=user_python_dir,
app_python_dir=app_python_dir,
standard_app_python_dir=standard_app_python_dir,
site_python_dir=site_python_dir,
log_handler=log_handler,
is_user_app_python_dir=is_user_app_python_dir,
initial_app_config=None,
)
def _calc_data_dir(data_dir: str | None) -> str:
if data_dir is None:
# To calc default data_dir, we assume this module was imported
# from that dir's ba_data/python subdir.
assert Path(__file__).parts[-3:-1] == ('ba_data', 'python')
data_dir_path = Path(__file__).parents[2]
# Prefer tidy relative paths like './ba_data' if possible so
# that things like stack traces are easier to read. For best
# results, platforms where CWD doesn't matter can chdir to where
# ba_data lives before calling configure().
#
# NOTE: If there's ever a case where the user is chdir'ing at
# runtime we might want an option to use only abs paths here.
cwd_path = Path.cwd()
data_dir = str(
data_dir_path.relative_to(cwd_path)
if data_dir_path.is_relative_to(cwd_path)
else data_dir_path
)
return data_dir
def _setup_logging() -> LogHandler:
from efro.log import setup_logging, LogLevel
log_handler = setup_logging(
log_path=None,
level=LogLevel.DEBUG,
suppress_non_root_debug=True,
log_stdout_stderr=True,
cache_size_limit=1024 * 1024,
)
return log_handler
def _setup_certs(contains_python_dist: bool) -> None:
# In situations where we're bringing our own Python, let's also
# provide our own root certs so ssl works. We can consider
# overriding this in particular embedded cases if we can verify that
# system certs are working. We also allow forcing this via an env
# var if the user desires.
if (
contains_python_dist
or os.environ.get('BA_USE_BUNDLED_ROOT_CERTS') == '1'
):
import certifi
# Let both OpenSSL and requests (if present) know to use this.
os.environ['SSL_CERT_FILE'] = os.environ[
'REQUESTS_CA_BUNDLE'
] = certifi.where()
def _setup_paths(
user_python_dir: str | None,
app_python_dir: str | None,
site_python_dir: str | None,
data_dir: str | None,
config_dir: str | None,
) -> tuple[str | None, str | None, str | None, str, str, str, bool]:
# First a few paths we can ALWAYS calculate since they don't affect
# Python imports:
envglobals = _EnvGlobals.get()
data_dir = _calc_data_dir(data_dir)
# Default config-dir is simply ~/.ballisticakit
if config_dir is None:
config_dir = str(Path(Path.home(), '.ballisticakit'))
# Standard app-python-dir is simply ba_data/python under data-dir.
standard_app_python_dir = str(Path(data_dir, 'ba_data', 'python'))
# Whether the final app-dir we're returning is a custom user-owned one.
is_user_app_python_dir = False
# If _babase has already been imported, there's not much we can do
# at this point aside from complain and inform for next time.
if '_babase' in sys.modules:
app_python_dir = user_python_dir = site_python_dir = None
# We don't actually complain yet here; we simply take note that
# we weren't able to set paths. Then we complain if/when the app
# is started. This way, non-app uses of babase won't be filled
# with unnecessary warnings.
envglobals.paths_set_failed = True
else:
# Ok; _babase hasn't been imported yet, so we can muck with
# Python paths.
if app_python_dir is None:
app_python_dir = standard_app_python_dir
# Likewise site-python-dir defaults to ba_data/python-site-packages.
if site_python_dir is None:
site_python_dir = str(
Path(data_dir, 'ba_data', 'python-site-packages')
)
# By default, user-python-dir is simply 'mods' under config-dir.
if user_python_dir is None:
user_python_dir = str(Path(config_dir, 'mods'))
# Wherever our user_python_dir is, if we find a sys/FOO dir
# under it where FOO matches our version, use that as our
# app_python_dir. This allows modding built-in stuff on
# platforms where there is no write access to said built-in
# stuff.
check_dir = Path(user_python_dir, 'sys', TARGET_BALLISTICA_VERSION)
if check_dir.is_dir():
app_python_dir = str(check_dir)
is_user_app_python_dir = True
# Ok, now apply these to sys.path.
# First off, strip out any instances of the path containing this
# module. We will *probably* be re-adding the same path in a
# moment so this keeps things cleaner. Though hmm should we
# leave it in there in cases where we *don't* re-add the same
# path?...
our_parent_path = Path(__file__).parent.resolve()
oldpaths: list[str] = [
p for p in sys.path if Path(p).resolve() != our_parent_path
]
# Let's place mods first (so users can override whatever they
# want) followed by our app scripts and lastly our bundled site
# stuff.
# One could make the argument that at least our bundled app &
# site stuff should be placed at the end so actual local site
# stuff could override it. That could be a good thing or a bad
# thing. Maybe we could add an option for that, but for now I'm
# prioritizing our stuff to give as consistent an environment as
# possible.
ourpaths = [user_python_dir, app_python_dir, site_python_dir]
# Special case: our modular builds will have a 'python-dylib'
# dir alongside the 'python' scripts dir which contains our
# binary Python modules. If we see that, add it to the path also.
# Not sure if we'd ever have a need to customize this path.
dylibdir = f'{app_python_dir}-dylib'
if os.path.exists(dylibdir):
ourpaths.append(dylibdir)
sys.path = ourpaths + oldpaths
return (
user_python_dir,
app_python_dir,
site_python_dir,
data_dir,
config_dir,
standard_app_python_dir,
is_user_app_python_dir,
)
def _setup_dirs(config_dir: str | None, user_python_dir: str | None) -> None:
create_dirs: list[tuple[str, str | None]] = [
('config', config_dir),
('user_python', user_python_dir),
]
for cdirname, cdir in create_dirs:
if cdir is not None:
try:
os.makedirs(cdir, exist_ok=True)
except Exception:
# Not the end of the world if we can't make these dirs.
logging.warning(
"Unable to create %s dir at '%s'.", cdirname, cdir
)
def extract_arg(args: list[str], names: list[str], is_dir: bool) -> str | None:
"""Given a list of args and an arg name, returns a value.
The arg flag and value are removed from the arg list. We also check
to make sure the path exists.
raises CleanErrors on any problems.
"""
from efro.error import CleanError
count = sum(args.count(n) for n in names)
if not count:
return None
if count > 1:
raise CleanError(f'Arg {names} passed multiple times.')
for name in names:
if name not in args:
continue
argindex = args.index(name)
if argindex + 1 >= len(args):
raise CleanError(f'No value passed after {name} arg.')
val = args[argindex + 1]
del args[argindex : argindex + 2]
if is_dir and not os.path.isdir(val):
namepretty = names[0].removeprefix('--')
raise CleanError(
f"Provided {namepretty} path '{val}' is not a directory."
)
return val
raise RuntimeError(f'Expected arg name not found from {names}')
def _modular_main() -> None:
from efro.error import CleanError
# Fundamentally, running a Ballistica app consists of the following:
# import baenv; baenv.configure(); import babase; babase.app.run()
#
# First baenv sets up things like Python paths the way the engine
# needs them, and then we import and run the engine.
#
# Below we're doing a slightly fancier version of that. Namely we do
# some processing of command line args to allow overriding of paths
# or running explicit commands or whatever else. Our goal is that
# this modular form of the app should be basically indistinguishable
# from the monolithic form when used from the command line.
try:
# Take note that we're running via modular-main. The native
# layer can key off this to know whether it should apply
# sys.argv or not.
_EnvGlobals.get().modular_main_called = True
# Deal with a few key things here ourself before even running
# configure.
# Extract stuff below modifies this so work with a copy.
args = sys.argv.copy()
# NOTE: We need to keep these arg long/short arg versions synced
# to those in core_config.cc. That code parses these same args
# (even if it doesn't handle them in our case) and will complain
# if unrecognized args come through.
# Our -c arg basically mirrors Python's -c arg. If we get that,
# simply exec it and return; no engine stuff.
command = extract_arg(args, ['--command', '-c'], is_dir=False)
if command is not None:
exec(command) # pylint: disable=exec-used
return
config_dir = extract_arg(args, ['--config-dir', '-C'], is_dir=True)
data_dir = extract_arg(args, ['--data-dir', '-d'], is_dir=True)
mods_dir = extract_arg(args, ['--mods-dir', '-m'], is_dir=True)
# We run configure() BEFORE importing babase. (part of its job
# is to wrangle paths to determine where babase and everything
# else gets loaded from).
configure(
config_dir=config_dir,
data_dir=data_dir,
user_python_dir=mods_dir,
)
import babase
# The engine will have parsed and processed all other args as
# part of the above import. If there were errors or args such as
# --help which should lead to us immediately returning, do so.
code = babase.get_immediate_return_code()
if code is not None:
sys.exit(code)
# Aaaand we're off!
babase.app.run()
# Code wanting us to die with a clean error message instead of an
# ugly stack trace can raise one of these.
except CleanError as clean_exc:
clean_exc.pretty_print()
sys.exit(1)
# Exec'ing this module directly will do a standard app run.
if __name__ == '__main__':
_modular_main()

37
dist/ba_data/python/baplus/__init__.py vendored Normal file
View file

@ -0,0 +1,37 @@
# Released under the MIT License. See LICENSE for details.
#
"""Closed-source bits of ballistica.
This code concerns sensitive things like accounts and master-server
communication so the native C++ parts of it remain closed. Native
precompiled static libraries of this portion are provided for those who
want to compile the rest of the engine, and a fully open-source engine
can also be built by removing this 'plus' feature-set.
"""
from __future__ import annotations
# Note: there's not much here.
# All comms with this feature-set should go through app.plus.
import logging
from baplus._subsystem import PlusSubsystem
__all__ = [
'PlusSubsystem',
]
# Sanity check: we want to keep ballistica's dependencies and
# bootstrapping order clearly defined; let's check a few particular
# modules to make sure they never directly or indirectly import us
# before their own execs complete.
if __debug__:
for _mdl in 'babase', '_babase':
if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'):
logging.warning(
'%s was imported before %s finished importing;'
' should not happen.',
__name__,
_mdl,
)

15
dist/ba_data/python/baplus/_hooks.py vendored Normal file
View file

@ -0,0 +1,15 @@
# Released under the MIT License. See LICENSE for details.
#
"""Snippets of code for use by the c++ layer."""
# (most of these are self-explanatory)
# pylint: disable=missing-function-docstring
from __future__ import annotations
import _baplus
def submit_analytics_counts(sval: str) -> None:
_baplus.add_v1_account_transaction(
{'type': 'ANALYTICS_COUNTS', 'values': sval}
)
_baplus.run_v1_account_transactions()

251
dist/ba_data/python/baplus/_subsystem.py vendored Normal file
View file

@ -0,0 +1,251 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides classic app subsystem."""
from __future__ import annotations
from typing import TYPE_CHECKING
import _baplus
from babase import AppSubsystem
if TYPE_CHECKING:
from typing import Callable, Any
from babase import CloudSubsystem, AccountV2Subsystem
class PlusSubsystem(AppSubsystem):
"""Subsystem for plus functionality in the app.
The single shared instance of this app can be accessed at
babase.app.plus. Note that it is possible for this to be None if the
plus package is not present, and code should handle that case
gracefully.
"""
# pylint: disable=too-many-public-methods
# Note: this is basically just a wrapper around _baplus for
# type-checking purposes. Maybe there's some smart way we could skip
# the overhead of this wrapper at runtime.
accounts: AccountV2Subsystem
cloud: CloudSubsystem
def on_app_loading(self) -> None:
_baplus.on_app_loading()
self.accounts.on_app_loading()
# noinspection PyUnresolvedReferences
@staticmethod
def add_v1_account_transaction(
transaction: dict, callback: Callable | None = None
) -> None:
"""(internal)"""
return _baplus.add_v1_account_transaction(transaction, callback)
@staticmethod
def game_service_has_leaderboard(game: str, config: str) -> bool:
"""(internal)
Given a game and config string, returns whether there is a leaderboard
for it on the game service.
"""
return _baplus.game_service_has_leaderboard(game, config)
@staticmethod
def get_master_server_address(source: int = -1, version: int = 1) -> str:
"""(internal)
Return the address of the master server.
"""
return _baplus.get_master_server_address(source, version)
@staticmethod
def get_news_show() -> str:
"""(internal)"""
return _baplus.get_news_show()
@staticmethod
def get_price(item: str) -> str | None:
"""(internal)"""
return _baplus.get_price(item)
@staticmethod
def get_purchased(item: str) -> bool:
"""(internal)"""
return _baplus.get_purchased(item)
@staticmethod
def get_purchases_state() -> int:
"""(internal)"""
return _baplus.get_purchases_state()
@staticmethod
def get_v1_account_display_string(full: bool = True) -> str:
"""(internal)"""
return _baplus.get_v1_account_display_string(full)
@staticmethod
def get_v1_account_misc_read_val(name: str, default_value: Any) -> Any:
"""(internal)"""
return _baplus.get_v1_account_misc_read_val(name, default_value)
@staticmethod
def get_v1_account_misc_read_val_2(name: str, default_value: Any) -> Any:
"""(internal)"""
return _baplus.get_v1_account_misc_read_val_2(name, default_value)
@staticmethod
def get_v1_account_misc_val(name: str, default_value: Any) -> Any:
"""(internal)"""
return _baplus.get_v1_account_misc_val(name, default_value)
@staticmethod
def get_v1_account_name() -> str:
"""(internal)"""
return _baplus.get_v1_account_name()
@staticmethod
def get_v1_account_public_login_id() -> str | None:
"""(internal)"""
return _baplus.get_v1_account_public_login_id()
@staticmethod
def get_v1_account_state() -> str:
"""(internal)"""
return _baplus.get_v1_account_state()
@staticmethod
def get_v1_account_state_num() -> int:
"""(internal)"""
return _baplus.get_v1_account_state_num()
@staticmethod
def get_v1_account_ticket_count() -> int:
"""(internal)
Returns the number of tickets for the current account.
"""
return _baplus.get_v1_account_ticket_count()
@staticmethod
def get_v1_account_type() -> str:
"""(internal)"""
return _baplus.get_v1_account_type()
@staticmethod
def get_v2_fleet() -> str:
"""(internal)"""
return _baplus.get_v2_fleet()
@staticmethod
def have_outstanding_v1_account_transactions() -> bool:
"""(internal)"""
return _baplus.have_outstanding_v1_account_transactions()
@staticmethod
def in_game_purchase(item: str, price: int) -> None:
"""(internal)"""
return _baplus.in_game_purchase(item, price)
@staticmethod
def is_blessed() -> bool:
"""(internal)"""
return _baplus.is_blessed()
@staticmethod
def mark_config_dirty() -> None:
"""(internal)
Category: General Utility Functions
"""
return _baplus.mark_config_dirty()
@staticmethod
def power_ranking_query(callback: Callable, season: Any = None) -> None:
"""(internal)"""
return _baplus.power_ranking_query(callback, season)
@staticmethod
def purchase(item: str) -> None:
"""(internal)"""
return _baplus.purchase(item)
@staticmethod
def report_achievement(
achievement: str, pass_to_account: bool = True
) -> None:
"""(internal)"""
return _baplus.report_achievement(achievement, pass_to_account)
@staticmethod
def reset_achievements() -> None:
"""(internal)"""
return _baplus.reset_achievements()
@staticmethod
def restore_purchases() -> None:
"""(internal)"""
return _baplus.restore_purchases()
@staticmethod
def run_v1_account_transactions() -> None:
"""(internal)"""
return _baplus.run_v1_account_transactions()
@staticmethod
def sign_in_v1(account_type: str) -> None:
"""(internal)
Category: General Utility Functions
"""
return _baplus.sign_in_v1(account_type)
@staticmethod
def sign_out_v1(v2_embedded: bool = False) -> None:
"""(internal)
Category: General Utility Functions
"""
return _baplus.sign_out_v1(v2_embedded)
@staticmethod
def submit_score(
game: str,
config: str,
name: Any,
score: int | None,
callback: Callable,
order: str = 'increasing',
tournament_id: str | None = None,
score_type: str = 'points',
campaign: str | None = None,
level: str | None = None,
) -> None:
"""(internal)
Submit a score to the server; callback will be called with the results.
As a courtesy, please don't send fake scores to the server. I'd prefer
to devote my time to improving the game instead of trying to make the
score server more mischief-proof.
"""
return _baplus.submit_score(
game,
config,
name,
score,
callback,
order,
tournament_id,
score_type,
campaign,
level,
)
@staticmethod
def tournament_query(
callback: Callable[[dict | None], None], args: dict
) -> None:
"""(internal)"""
return _baplus.tournament_query(callback, args)

View file

@ -0,0 +1,459 @@
# Released under the MIT License. See LICENSE for details.
#
"""Ballistica scene api version 1. Basically all gameplay related code."""
# ba_meta require api 8
# 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.
import logging
# Aside from our own stuff, we also bundle a number of things from ba or
# other modules; the goal is to let most simple mods rely solely on this
# module to keep things simple.
from efro.util import set_canonical_module_names
from babase import (
app,
AppIntent,
AppIntentDefault,
AppIntentExec,
AppMode,
apptime,
AppTime,
apptimer,
AppTimer,
Call,
ContextError,
ContextRef,
displaytime,
DisplayTime,
displaytimer,
DisplayTimer,
existing,
fade_screen,
get_remote_app_name,
increment_analytics_count,
InputType,
is_point_in_box,
lock_all_input,
Lstr,
NodeNotFoundError,
normalized_color,
NotFoundError,
PlayerNotFoundError,
Plugin,
pushcall,
safecolor,
screenmessage,
set_analytics_screen,
storagename,
timestring,
UIScale,
unlock_all_input,
Vec3,
WeakCall,
)
from _bascenev1 import (
ActivityData,
basetime,
basetimer,
BaseTimer,
camerashake,
capture_gamepad_input,
capture_keyboard_input,
chatmessage,
client_info_query_response,
CollisionMesh,
connect_to_party,
Data,
disconnect_client,
disconnect_from_host,
emitfx,
end_host_scanning,
get_chat_messages,
get_connection_to_host_info,
get_foreground_host_activity,
get_foreground_host_session,
get_game_port,
get_game_roster,
get_local_active_input_devices_count,
get_public_party_enabled,
get_public_party_max_size,
get_random_names,
get_replay_speed_exponent,
get_ui_input_device,
getactivity,
getcollisionmesh,
getdata,
getinputdevice,
getmesh,
getnodes,
getsession,
getsound,
gettexture,
have_connected_clients,
have_touchscreen_input,
host_scan_cycle,
InputDevice,
is_in_replay,
ls_input_devices,
ls_objects,
Material,
Mesh,
new_host_session,
new_replay_session,
newactivity,
newnode,
Node,
printnodes,
release_gamepad_input,
release_keyboard_input,
reset_random_player_names,
broadcastmessage,
SessionData,
SessionPlayer,
set_admins,
set_authenticate_clients,
set_debug_speed_exponent,
set_enable_default_kick_voting,
set_internal_music,
set_map_bounds,
set_master_server_source,
set_public_party_enabled,
set_public_party_max_size,
set_public_party_name,
set_public_party_queue_enabled,
set_public_party_stats_url,
set_replay_speed_exponent,
set_touchscreen_editing,
Sound,
Texture,
time,
timer,
Timer,
)
from bascenev1._activity import Activity
from bascenev1._activitytypes import JoinActivity, ScoreScreenActivity
from bascenev1._actor import Actor
from bascenev1._appmode import SceneV1AppMode
from bascenev1._campaign import init_campaigns, Campaign
from bascenev1._collision import Collision, getcollision
from bascenev1._coopgame import CoopGameActivity
from bascenev1._coopsession import CoopSession
from bascenev1._debug import print_live_object_warnings
from bascenev1._dependency import (
Dependency,
DependencyComponent,
DependencySet,
AssetPackage,
)
from bascenev1._dualteamsession import DualTeamSession
from bascenev1._freeforallsession import FreeForAllSession
from bascenev1._gameactivity import GameActivity
from bascenev1._gameresults import GameResults
from bascenev1._gameutils import (
animate,
animate_array,
BaseTime,
cameraflash,
GameTip,
get_trophy_string,
show_damage_count,
Time,
)
from bascenev1._level import Level
from bascenev1._lobby import Lobby, Chooser
from bascenev1._map import (
get_filtered_map_name,
get_map_class,
get_map_display_string,
Map,
register_map,
)
from bascenev1._messages import (
CelebrateMessage,
DeathType,
DieMessage,
DropMessage,
DroppedMessage,
FreezeMessage,
HitMessage,
ImpactDamageMessage,
OutOfBoundsMessage,
PickedUpMessage,
PickUpMessage,
PlayerDiedMessage,
PlayerProfilesChangedMessage,
ShouldShatterMessage,
StandMessage,
ThawMessage,
UNHANDLED,
)
from bascenev1._multiteamsession import (
MultiTeamSession,
DEFAULT_TEAM_COLORS,
DEFAULT_TEAM_NAMES,
)
from bascenev1._music import MusicType, setmusic
from bascenev1._nodeactor import NodeActor
from bascenev1._powerup import get_default_powerup_distribution
from bascenev1._profile import (
get_player_colors,
get_player_profile_icon,
get_player_profile_colors,
)
from bascenev1._player import PlayerInfo, Player, EmptyPlayer, StandLocation
from bascenev1._playlist import (
get_default_free_for_all_playlist,
get_default_teams_playlist,
filter_playlist,
)
from bascenev1._powerup import PowerupMessage, PowerupAcceptMessage
from bascenev1._score import ScoreType, ScoreConfig
from bascenev1._settings import (
BoolSetting,
ChoiceSetting,
FloatChoiceSetting,
FloatSetting,
IntChoiceSetting,
IntSetting,
Setting,
)
from bascenev1._session import Session
from bascenev1._stats import PlayerScoredMessage, PlayerRecord, Stats
from bascenev1._team import SessionTeam, Team, EmptyTeam
from bascenev1._teamgame import TeamGameActivity
__all__ = [
'Activity',
'ActivityData',
'Actor',
'animate',
'animate_array',
'app',
'AppIntent',
'AppIntentDefault',
'AppIntentExec',
'AppMode',
'AppTime',
'apptime',
'apptimer',
'AppTimer',
'AssetPackage',
'basetime',
'BaseTime',
'basetimer',
'BaseTimer',
'BoolSetting',
'Call',
'cameraflash',
'camerashake',
'Campaign',
'capture_gamepad_input',
'capture_keyboard_input',
'CelebrateMessage',
'chatmessage',
'ChoiceSetting',
'Chooser',
'client_info_query_response',
'Collision',
'CollisionMesh',
'connect_to_party',
'ContextError',
'ContextRef',
'CoopGameActivity',
'CoopSession',
'Data',
'DeathType',
'DEFAULT_TEAM_COLORS',
'DEFAULT_TEAM_NAMES',
'Dependency',
'DependencyComponent',
'DependencySet',
'DieMessage',
'disconnect_client',
'disconnect_from_host',
'displaytime',
'DisplayTime',
'displaytimer',
'DisplayTimer',
'DropMessage',
'DroppedMessage',
'DualTeamSession',
'emitfx',
'EmptyPlayer',
'EmptyTeam',
'end_host_scanning',
'existing',
'fade_screen',
'filter_playlist',
'FloatChoiceSetting',
'FloatSetting',
'FreeForAllSession',
'FreezeMessage',
'GameActivity',
'GameResults',
'GameTip',
'get_chat_messages',
'get_connection_to_host_info',
'get_default_free_for_all_playlist',
'get_default_teams_playlist',
'get_default_powerup_distribution',
'get_filtered_map_name',
'get_foreground_host_activity',
'get_foreground_host_session',
'get_game_port',
'get_game_roster',
'get_game_roster',
'get_local_active_input_devices_count',
'get_map_class',
'get_map_display_string',
'get_player_colors',
'get_player_profile_colors',
'get_player_profile_icon',
'get_public_party_enabled',
'get_public_party_max_size',
'get_random_names',
'get_remote_app_name',
'get_replay_speed_exponent',
'get_trophy_string',
'get_ui_input_device',
'getactivity',
'getcollision',
'getcollisionmesh',
'getdata',
'getinputdevice',
'getmesh',
'getnodes',
'getsession',
'getsound',
'gettexture',
'have_connected_clients',
'have_touchscreen_input',
'HitMessage',
'host_scan_cycle',
'ImpactDamageMessage',
'increment_analytics_count',
'init_campaigns',
'InputDevice',
'InputType',
'IntChoiceSetting',
'IntSetting',
'is_in_replay',
'is_point_in_box',
'JoinActivity',
'Level',
'Lobby',
'lock_all_input',
'ls_input_devices',
'ls_objects',
'Lstr',
'Map',
'Material',
'Mesh',
'MultiTeamSession',
'MusicType',
'new_host_session',
'new_replay_session',
'newactivity',
'newnode',
'Node',
'NodeActor',
'NodeNotFoundError',
'normalized_color',
'NotFoundError',
'OutOfBoundsMessage',
'PickedUpMessage',
'PickUpMessage',
'Player',
'PlayerDiedMessage',
'PlayerProfilesChangedMessage',
'PlayerInfo',
'PlayerNotFoundError',
'PlayerRecord',
'PlayerScoredMessage',
'Plugin',
'PowerupAcceptMessage',
'PowerupMessage',
'print_live_object_warnings',
'printnodes',
'pushcall',
'register_map',
'release_gamepad_input',
'release_keyboard_input',
'reset_random_player_names',
'safecolor',
'screenmessage',
'SceneV1AppMode',
'ScoreConfig',
'ScoreScreenActivity',
'ScoreType',
'broadcastmessage',
'Session',
'SessionData',
'SessionPlayer',
'SessionTeam',
'set_admins',
'set_analytics_screen',
'set_authenticate_clients',
'set_debug_speed_exponent',
'set_debug_speed_exponent',
'set_enable_default_kick_voting',
'set_internal_music',
'set_map_bounds',
'set_master_server_source',
'set_public_party_enabled',
'set_public_party_max_size',
'set_public_party_name',
'set_public_party_queue_enabled',
'set_public_party_stats_url',
'set_replay_speed_exponent',
'set_touchscreen_editing',
'setmusic',
'Setting',
'ShouldShatterMessage',
'show_damage_count',
'Sound',
'StandLocation',
'StandMessage',
'Stats',
'storagename',
'Team',
'TeamGameActivity',
'Texture',
'ThawMessage',
'time',
'Time',
'timer',
'Timer',
'timestring',
'UIScale',
'UNHANDLED',
'unlock_all_input',
'Vec3',
'WeakCall',
]
# We want stuff here to show up as bascenev1.Foo instead of
# bascenev1._submodule.Foo.
set_canonical_module_names(globals())
# Sanity check: we want to keep ballistica's dependencies and
# bootstrapping order clearly defined; let's check a few particular
# modules to make sure they never directly or indirectly import us
# before their own execs complete.
if __debug__:
for _mdl in 'babase', '_babase':
if not hasattr(__import__(_mdl), '_REACHED_END_OF_MODULE'):
logging.warning(
'%s was imported before %s finished importing;'
' should not happen.',
__name__,
_mdl,
)

View file

@ -4,39 +4,32 @@
from __future__ import annotations
import weakref
import logging
from typing import TYPE_CHECKING, Generic, TypeVar
import _ba
from ba._team import Team
from ba._player import Player
from ba._error import (
print_exception,
SessionTeamNotFoundError,
SessionPlayerNotFoundError,
NodeNotFoundError,
)
from ba._dependency import DependencyComponent
from ba._general import Call, verify_object_death
from ba._messages import UNHANDLED
import babase
import _bascenev1
from bascenev1._dependency import DependencyComponent
from bascenev1._team import Team
from bascenev1._messages import UNHANDLED
from bascenev1._player import Player
if TYPE_CHECKING:
from typing import Any
import ba
import bascenev1
# pylint: disable=invalid-name
PlayerType = TypeVar('PlayerType', bound=Player)
TeamType = TypeVar('TeamType', bound=Team)
# pylint: enable=invalid-name
PlayerT = TypeVar('PlayerT', bound=Player)
TeamT = TypeVar('TeamT', bound=Team)
class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
"""Units of execution wrangled by a ba.Session.
class Activity(DependencyComponent, Generic[PlayerT, TeamT]):
"""Units of execution wrangled by a bascenev1.Session.
Category: Gameplay Classes
Examples of Activities include games, score-screens, cutscenes, etc.
A ba.Session has one 'current' Activity at any time, though their existence
can overlap during transitions.
A bascenev1.Session has one 'current' Activity at any time, though
their existence can overlap during transitions.
"""
# pylint: disable=too-many-public-methods
@ -47,17 +40,17 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
activities should pull all values they need from the 'settings' arg
passed to the Activity __init__ call."""
teams: list[TeamType]
"""The list of ba.Team-s in the Activity. This gets populated just
teams: list[TeamT]
"""The list of bascenev1.Team-s in the Activity. This gets populated just
before on_begin() is called and is updated automatically as players
join or leave the game. (at least in free-for-all mode where every
player gets their own team; in teams mode there are always 2 teams
regardless of the player count)."""
players: list[PlayerType]
"""The list of ba.Player-s in the Activity. This gets populated just
before on_begin() is called and is updated automatically as players
join or leave the game."""
players: list[PlayerT]
"""The list of bascenev1.Player-s in the Activity. This gets populated
just before on_begin() is called and is updated automatically as
players join or leave the game."""
announce_player_deaths = False
"""Whether to print every time a player dies. This can be pertinent
@ -125,11 +118,11 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
the next activity?"""
def __init__(self, settings: dict):
"""Creates an Activity in the current ba.Session.
"""Creates an Activity in the current bascenev1.Session.
The activity will not be actually run until ba.Session.setactivity
is called. 'settings' should be a dict of key/value pairs specific
to the activity.
The activity will not be actually run until
bascenev1.Session.setactivity is called. 'settings' should be a
dict of key/value pairs specific to the activity.
Activities should preload as much of their media/etc as possible in
their constructor, but none of it should actually be used until they
@ -138,23 +131,23 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
super().__init__()
# Create our internal engine data.
self._activity_data = _ba.register_activity(self)
self._activity_data = _bascenev1.register_activity(self)
assert isinstance(settings, dict)
assert _ba.getactivity() is self
assert _bascenev1.getactivity() is self
self._globalsnode: ba.Node | None = None
self._globalsnode: bascenev1.Node | None = None
# Player/Team types should have been specified as type args;
# grab those.
self._playertype: type[PlayerType]
self._teamtype: type[TeamType]
self._playertype: type[PlayerT]
self._teamtype: type[TeamT]
self._setup_player_and_team_types()
# FIXME: Relocate or remove the need for this stuff.
self.paused_text: ba.Actor | None = None
self.paused_text: bascenev1.Actor | None = None
self._session = weakref.ref(_ba.getsession())
self._session = weakref.ref(_bascenev1.getsession())
# Preloaded data for actors, maps, etc; indexed by type.
self.preloads: dict[type, Any] = {}
@ -167,68 +160,71 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._has_transitioned_in = False
self._has_begun = False
self._has_ended = False
self._activity_death_check_timer: ba.Timer | None = None
self._activity_death_check_timer: bascenev1.AppTimer | None = None
self._expired = False
self._delay_delete_players: list[PlayerType] = []
self._delay_delete_teams: list[TeamType] = []
self._players_that_left: list[weakref.ref[PlayerType]] = []
self._teams_that_left: list[weakref.ref[TeamType]] = []
self._delay_delete_players: list[PlayerT] = []
self._delay_delete_teams: list[TeamT] = []
self._players_that_left: list[weakref.ref[PlayerT]] = []
self._teams_that_left: list[weakref.ref[TeamT]] = []
self._transitioning_out = False
# A handy place to put most actors; this list is pruned of dead
# actors regularly and these actors are insta-killed as the activity
# is dying.
self._actor_refs: list[ba.Actor] = []
self._actor_weak_refs: list[weakref.ref[ba.Actor]] = []
self._last_prune_dead_actors_time = _ba.time()
self._prune_dead_actors_timer: ba.Timer | None = None
self._actor_refs: list[bascenev1.Actor] = []
self._actor_weak_refs: list[weakref.ref[bascenev1.Actor]] = []
self._last_prune_dead_actors_time = babase.apptime()
self._prune_dead_actors_timer: bascenev1.Timer | None = None
self.teams = []
self.players = []
self.lobby = None
self._stats: ba.Stats | None = None
self._stats: bascenev1.Stats | None = None
self._customdata: dict | None = {}
def __del__(self) -> None:
# If the activity has been run then we should have already cleaned
# it up, but we still need to run expire calls for un-run activities.
if not self._expired:
with _ba.Context('empty'):
with babase.ContextRef.empty():
self._expire()
# Inform our owner that we officially kicked the bucket.
if self._transitioning_out:
session = self._session()
if session is not None:
_ba.pushcall(
Call(
babase.pushcall(
babase.Call(
session.transitioning_out_activity_was_freed,
self.can_show_ad_on_death,
)
)
@property
def globalsnode(self) -> ba.Node:
"""The 'globals' ba.Node for the activity. This contains various
def context(self) -> bascenev1.ContextRef:
"""A context-ref pointing at this activity."""
return self._activity_data.context()
@property
def globalsnode(self) -> bascenev1.Node:
"""The 'globals' bascenev1.Node for the activity. This contains various
global controls and values.
"""
node = self._globalsnode
if not node:
raise NodeNotFoundError()
raise babase.NodeNotFoundError()
return node
@property
def stats(self) -> ba.Stats:
def stats(self) -> bascenev1.Stats:
"""The stats instance accessible while the activity is running.
If access is attempted before or after, raises a ba.NotFoundError.
If access is attempted before or after, raises a
bascenev1.NotFoundError.
"""
if self._stats is None:
from ba._error import NotFoundError
raise NotFoundError()
raise babase.NotFoundError()
return self._stats
def on_expire(self) -> None:
@ -263,13 +259,13 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
return self._expired
@property
def playertype(self) -> type[PlayerType]:
"""The type of ba.Player this Activity is using."""
def playertype(self) -> type[PlayerT]:
"""The type of bascenev1.Player this Activity is using."""
return self._playertype
@property
def teamtype(self) -> type[TeamType]:
"""The type of ba.Team this Activity is using."""
def teamtype(self) -> type[TeamT]:
"""The type of bascenev1.Team this Activity is using."""
return self._teamtype
def set_has_ended(self, val: bool) -> None:
@ -281,19 +277,17 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
(internal)
"""
from ba._generated.enums import TimeType
# Create a real-timer that watches a weak-ref of this activity
# Create an app-timer that watches a weak-ref of this activity
# and reports any lingering references keeping it alive.
# We store the timer on the activity so as soon as the activity dies
# it gets cleaned up.
with _ba.Context('ui'):
with babase.ContextRef.empty():
ref = weakref.ref(self)
self._activity_death_check_timer = _ba.Timer(
self._activity_death_check_timer = babase.AppTimer(
5.0,
Call(self._check_activity_death, ref, [0]),
babase.Call(self._check_activity_death, ref, [0]),
repeat=True,
timetype=TimeType.REAL,
)
# Run _expire in an empty context; nothing should be happening in
@ -302,67 +296,65 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
# and we can't properly provide context in that situation anyway; might
# as well be consistent).
if not self._expired:
with _ba.Context('empty'):
with babase.ContextRef.empty():
self._expire()
else:
raise RuntimeError(
f'destroy() called when' f' already expired for {self}'
f'destroy() called when already expired for {self}.'
)
def retain_actor(self, actor: ba.Actor) -> None:
"""Add a strong-reference to a ba.Actor to this Activity.
def retain_actor(self, actor: bascenev1.Actor) -> None:
"""Add a strong-reference to a bascenev1.Actor to this Activity.
The reference will be lazily released once ba.Actor.exists()
returns False for the Actor. The ba.Actor.autoretain() method
The reference will be lazily released once bascenev1.Actor.exists()
returns False for the Actor. The bascenev1.Actor.autoretain() method
is a convenient way to access this same functionality.
"""
if __debug__:
from ba._actor import Actor
from bascenev1._actor import Actor
assert isinstance(actor, Actor)
self._actor_refs.append(actor)
def add_actor_weak_ref(self, actor: ba.Actor) -> None:
"""Add a weak-reference to a ba.Actor to the ba.Activity.
def add_actor_weak_ref(self, actor: bascenev1.Actor) -> None:
"""Add a weak-reference to a bascenev1.Actor to the bascenev1.Activity.
(called by the ba.Actor base class)
(called by the bascenev1.Actor base class)
"""
if __debug__:
from ba._actor import Actor
from bascenev1._actor import Actor
assert isinstance(actor, Actor)
self._actor_weak_refs.append(weakref.ref(actor))
@property
def session(self) -> ba.Session:
"""The ba.Session this ba.Activity belongs go.
def session(self) -> bascenev1.Session:
"""The bascenev1.Session this bascenev1.Activity belongs to.
Raises a ba.SessionNotFoundError if the Session no longer exists.
Raises a babase.SessionNotFoundError if the Session no longer exists.
"""
session = self._session()
if session is None:
from ba._error import SessionNotFoundError
raise SessionNotFoundError()
raise babase.SessionNotFoundError()
return session
def on_player_join(self, player: PlayerType) -> None:
"""Called when a new ba.Player has joined the Activity.
def on_player_join(self, player: PlayerT) -> None:
"""Called when a new bascenev1.Player has joined the Activity.
(including the initial set of Players)
"""
def on_player_leave(self, player: PlayerType) -> None:
"""Called when a ba.Player is leaving the Activity."""
def on_player_leave(self, player: PlayerT) -> None:
"""Called when a bascenev1.Player is leaving the Activity."""
def on_team_join(self, team: TeamType) -> None:
"""Called when a new ba.Team joins the Activity.
def on_team_join(self, team: TeamT) -> None:
"""Called when a new bascenev1.Team joins the Activity.
(including the initial set of Teams)
"""
def on_team_leave(self, team: TeamType) -> None:
"""Called when a ba.Team leaves the Activity."""
def on_team_leave(self, team: TeamT) -> None:
"""Called when a bascenev1.Team leaves the Activity."""
def on_transition_in(self) -> None:
"""Called when the Activity is first becoming visible.
@ -370,18 +362,18 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
Upon this call, the Activity should fade in backgrounds,
start playing music, etc. It does not yet have access to players
or teams, however. They remain owned by the previous Activity
up until ba.Activity.on_begin() is called.
up until bascenev1.Activity.on_begin() is called.
"""
def on_transition_out(self) -> None:
"""Called when your activity begins transitioning out.
Note that this may happen at any time even if ba.Activity.end() has
not been called.
Note that this may happen at any time even if bascenev1.Activity.end()
has not been called.
"""
def on_begin(self) -> None:
"""Called once the previous ba.Activity has finished transitioning out.
"""Called once the previous Activity has finished transitioning out.
At this point the activity's initial players and teams are filled in
and it should begin its actual game logic.
@ -393,12 +385,11 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
return UNHANDLED
def has_transitioned_in(self) -> bool:
"""Return whether ba.Activity.on_transition_in()
has been called."""
"""Return whether bascenev1.Activity.on_transition_in() has run."""
return self._has_transitioned_in
def has_begun(self) -> bool:
"""Return whether ba.Activity.on_begin() has been called."""
"""Return whether bascenev1.Activity.on_begin() has run."""
return self._has_begun
def has_ended(self) -> bool:
@ -406,10 +397,10 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
return self._has_ended
def is_transitioning_out(self) -> bool:
"""Return whether ba.Activity.on_transition_out() has been called."""
"""Return whether bascenev1.Activity.on_transition_out() has run."""
return self._transitioning_out
def transition_in(self, prev_globals: ba.Node | None) -> None:
def transition_in(self, prev_globals: bascenev1.Node | None) -> None:
"""Called by Session to kick off transition-in.
(internal)
@ -418,8 +409,8 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._has_transitioned_in = True
# Set up the globals node based on our settings.
with _ba.Context(self):
glb = self._globalsnode = _ba.newnode('globals')
with self.context:
glb = self._globalsnode = _bascenev1.newnode('globals')
# Now that it's going to be front and center,
# set some global values based on what the activity wants.
@ -449,11 +440,11 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
# Start pruning our various things periodically.
self._prune_dead_actors()
self._prune_dead_actors_timer = _ba.Timer(
self._prune_dead_actors_timer = _bascenev1.Timer(
5.17, self._prune_dead_actors, repeat=True
)
_ba.timer(13.3, self._prune_delay_deletes, repeat=True)
_bascenev1.timer(13.3, self._prune_delay_deletes, repeat=True)
# Also start our low-level scene running.
self._activity_data.start()
@ -461,7 +452,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
try:
self.on_transition_in()
except Exception:
print_exception(f'Error in on_transition_in for {self}.')
logging.exception('Error in on_transition_in for %s.', self)
# Tell the C++ layer that this activity is the main one, so it uses
# settings from our globals, directs various events to us, etc.
@ -471,13 +462,13 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
"""Called by the Session to start us transitioning out."""
assert not self._transitioning_out
self._transitioning_out = True
with _ba.Context(self):
with self.context:
try:
self.on_transition_out()
except Exception:
print_exception(f'Error in on_transition_out for {self}.')
logging.exception('Error in on_transition_out for %s.', self)
def begin(self, session: ba.Session) -> None:
def begin(self, session: bascenev1.Session) -> None:
"""Begin the activity.
(internal)
@ -499,7 +490,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._has_begun = True
# Let the activity do its thing.
with _ba.Context(self):
with self.context:
# Note: do we want to catch errors here?
# Currently I believe we wind up canceling the
# activity launch; just wanna be sure that is intentional.
@ -508,7 +499,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
def end(
self, results: Any = None, delay: float = 0.0, force: bool = False
) -> None:
"""Commences Activity shutdown and delivers results to the ba.Session.
"""Commences Activity shutdown and delivers results to the Session.
'delay' is the time delay before the Activity actually ends
(in seconds). Further calls to end() will be ignored up until
@ -519,20 +510,20 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
# Ask the session to end us.
self.session.end_activity(self, results, delay, force)
def create_player(self, sessionplayer: ba.SessionPlayer) -> PlayerType:
def create_player(self, sessionplayer: bascenev1.SessionPlayer) -> PlayerT:
"""Create the Player instance for this Activity.
Subclasses can override this if the activity's player class
requires a custom constructor; otherwise it will be called with
no args. Note that the player object should not be used at this
point as it is not yet fully wired up; wait for
ba.Activity.on_player_join() for that.
bascenev1.Activity.on_player_join() for that.
"""
del sessionplayer # Unused.
player = self._playertype()
return player
def create_team(self, sessionteam: ba.SessionTeam) -> TeamType:
def create_team(self, sessionteam: bascenev1.SessionTeam) -> TeamT:
"""Create the Team instance for this Activity.
Subclasses can override this if the activity's team class
@ -545,7 +536,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
team = self._teamtype()
return team
def add_player(self, sessionplayer: ba.SessionPlayer) -> None:
def add_player(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""(internal)"""
assert sessionplayer.sessionteam is not None
sessionplayer.resetinput()
@ -554,7 +545,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
team = sessionteam.activityteam
assert team is not None
sessionplayer.setactivity(self)
with _ba.Context(self):
with self.context:
sessionplayer.activityplayer = player = self.create_player(
sessionplayer
)
@ -571,9 +562,9 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
try:
self.on_player_join(player)
except Exception:
print_exception(f'Error in on_player_join for {self}.')
logging.exception('Error in on_player_join for %s.', self)
def remove_player(self, sessionplayer: ba.SessionPlayer) -> None:
def remove_player(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""Remove a player from the Activity while it is running.
(internal)
@ -593,19 +584,19 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self.players.remove(player)
assert player not in self.players
# This should allow our ba.Player instance to die.
# This should allow our bascenev1.Player instance to die.
# Complain if that doesn't happen.
# verify_object_death(player)
with _ba.Context(self):
with self.context:
try:
self.on_player_leave(player)
except Exception:
print_exception(f'Error in on_player_leave for {self}.')
logging.exception('Error in on_player_leave for %s.', self)
try:
player.leave()
except Exception:
print_exception(f'Error on leave for {player} in {self}.')
logging.exception('Error on leave for %s in %s.', player, self)
self._reset_session_player_for_no_activity(sessionplayer)
@ -616,23 +607,23 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._delay_delete_players.append(player)
self._players_that_left.append(weakref.ref(player))
def add_team(self, sessionteam: ba.SessionTeam) -> None:
def add_team(self, sessionteam: bascenev1.SessionTeam) -> None:
"""Add a team to the Activity
(internal)
"""
assert not self.expired
with _ba.Context(self):
with self.context:
sessionteam.activityteam = team = self.create_team(sessionteam)
team.postinit(sessionteam)
self.teams.append(team)
try:
self.on_team_join(team)
except Exception:
print_exception(f'Error in on_team_join for {self}.')
logging.exception('Error in on_team_join for %s.', self)
def remove_team(self, sessionteam: ba.SessionTeam) -> None:
def remove_team(self, sessionteam: bascenev1.SessionTeam) -> None:
"""Remove a team from a Running Activity
(internal)
@ -647,16 +638,16 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self.teams.remove(team)
assert team not in self.teams
with _ba.Context(self):
with self.context:
# Make a decent attempt to persevere if user code breaks.
try:
self.on_team_leave(team)
except Exception:
print_exception(f'Error in on_team_leave for {self}.')
logging.exception('Error in on_team_leave for %s.', self)
try:
team.leave()
except Exception:
print_exception(f'Error on leave for {team} in {self}.')
logging.exception('Error on leave for %s in %s.', team, self)
sessionteam.activityteam = None
@ -668,25 +659,26 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._teams_that_left.append(weakref.ref(team))
def _reset_session_player_for_no_activity(
self, sessionplayer: ba.SessionPlayer
self, sessionplayer: bascenev1.SessionPlayer
) -> None:
# Let's be extra-defensive here: killing a node/input-call/etc
# could trigger user-code resulting in errors, but we would still
# like to complete the reset if possible.
try:
sessionplayer.setnode(None)
except Exception:
print_exception(
f'Error resetting SessionPlayer node on {sessionplayer}'
f' for {self}.'
logging.exception(
'Error resetting SessionPlayer node on %s for %s.',
sessionplayer,
self,
)
try:
sessionplayer.resetinput()
except Exception:
print_exception(
f'Error resetting SessionPlayer input on {sessionplayer}'
f' for {self}.'
logging.exception(
'Error resetting SessionPlayer input on %s for %s.',
sessionplayer,
self,
)
# These should never fail I think...
@ -699,7 +691,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
# TODO: There are proper calls for pulling these in Python 3.8;
# should update this code when we adopt that.
# NOTE: If we get Any as PlayerType or TeamType (generally due
# NOTE: If we get Any as PlayerT or TeamT (generally due
# to no generic params being passed) we automatically use the
# base class types, but also warn the user since this will mean
# less type safety for that class. (its better to pass the base
@ -710,7 +702,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._playertype = Player
print(
f'ERROR: {type(self)} was not passed a Player'
f' type argument; please explicitly pass ba.Player'
f' type argument; please explicitly pass bascenev1.Player'
f' if you do not want to override it.'
)
self._teamtype = type(self).__orig_bases__[-1].__args__[1]
@ -718,7 +710,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
self._teamtype = Team
print(
f'ERROR: {type(self)} was not passed a Team'
f' type argument; please explicitly pass ba.Team'
f' type argument; please explicitly pass bascenev1.Team'
f' if you do not want to override it.'
)
assert issubclass(self._playertype, Player)
@ -730,8 +722,8 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
) -> None:
"""Sanity check to make sure an Activity was destroyed properly.
Receives a weakref to a ba.Activity which should have torn itself
down due to no longer being referenced anywhere. Will complain
Receives a weakref to a bascenev1.Activity which should have torn
itself down due to no longer being referenced anywhere. Will complain
and/or print debugging info if the Activity still exists.
"""
try:
@ -751,10 +743,10 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
counter[0] += 1
if counter[0] == 4:
print('Killing app due to stuck activity... :-(')
_ba.quit()
babase.quit()
except Exception:
print_exception('Error on _check_activity_death/')
logging.exception('Error on _check_activity_death.')
def _expire(self) -> None:
"""Put the activity in a state where it can be garbage-collected.
@ -768,12 +760,12 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
try:
self.on_expire()
except Exception:
print_exception(f'Error in Activity on_expire() for {self}.')
logging.exception('Error in Activity on_expire() for %s.', self)
try:
self._customdata = None
except Exception:
print_exception(f'Error clearing customdata for {self}.')
logging.exception('Error clearing customdata for %s.', self)
# Don't want to be holding any delay-delete refs at this point.
self._prune_delay_deletes()
@ -788,79 +780,77 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
try:
self._activity_data.expire()
except Exception:
print_exception(f'Error expiring _activity_data for {self}.')
logging.exception('Error expiring _activity_data for %s.', self)
def _expire_actors(self) -> None:
# Expire all Actors.
for actor_ref in self._actor_weak_refs:
actor = actor_ref()
if actor is not None:
verify_object_death(actor)
babase.verify_object_death(actor)
try:
actor.on_expire()
except Exception:
print_exception(
f'Error in Actor.on_expire()' f' for {actor_ref()}.'
logging.exception(
'Error in Actor.on_expire() for %s.', actor_ref()
)
def _expire_players(self) -> None:
# Issue warnings for any players that left the game but don't
# get freed soon.
for ex_player in (p() for p in self._players_that_left):
if ex_player is not None:
verify_object_death(ex_player)
babase.verify_object_death(ex_player)
for player in self.players:
# This should allow our ba.Player instance to be freed.
# This should allow our bascenev1.Player instance to be freed.
# Complain if that doesn't happen.
verify_object_death(player)
babase.verify_object_death(player)
try:
player.expire()
except Exception:
print_exception(f'Error expiring {player}')
logging.exception('Error expiring %s.', player)
# Reset the SessionPlayer to a not-in-an-activity state.
try:
sessionplayer = player.sessionplayer
self._reset_session_player_for_no_activity(sessionplayer)
except SessionPlayerNotFoundError:
except babase.SessionPlayerNotFoundError:
# Conceivably, someone could have held on to a Player object
# until now whos underlying SessionPlayer left long ago...
pass
except Exception:
print_exception(f'Error expiring {player}.')
logging.exception('Error expiring %s.', player)
def _expire_teams(self) -> None:
# Issue warnings for any teams that left the game but don't
# get freed soon.
for ex_team in (p() for p in self._teams_that_left):
if ex_team is not None:
verify_object_death(ex_team)
babase.verify_object_death(ex_team)
for team in self.teams:
# This should allow our ba.Team instance to die.
# This should allow our bascenev1.Team instance to die.
# Complain if that doesn't happen.
verify_object_death(team)
babase.verify_object_death(team)
try:
team.expire()
except Exception:
print_exception(f'Error expiring {team}')
logging.exception('Error expiring %s.', team)
try:
sessionteam = team.sessionteam
sessionteam.activityteam = None
except SessionTeamNotFoundError:
except babase.SessionTeamNotFoundError:
# It is expected that Team objects may last longer than
# the SessionTeam they came from (game objects may hold
# team references past the point at which the underlying
# player/team has left the game)
pass
except Exception:
print_exception(f'Error expiring Team {team}.')
logging.exception('Error expiring Team %s.', team)
def _prune_delay_deletes(self) -> None:
self._delay_delete_players.clear()
@ -875,7 +865,7 @@ class Activity(DependencyComponent, Generic[PlayerType, TeamType]):
]
def _prune_dead_actors(self) -> None:
self._last_prune_dead_actors_time = _ba.time()
self._last_prune_dead_actors_time = babase.apptime()
# Prune our strong refs when the Actor's exists() call gives False
self._actor_refs = [a for a in self._actor_refs if a.exists()]

View file

@ -5,22 +5,24 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._activity import Activity
from ba._music import setmusic, MusicType
from ba._generated.enums import InputType, UIScale
import babase
import _bascenev1
from bascenev1._activity import Activity
# False-positive from pylint due to our class-generics-filter.
from ba._player import EmptyPlayer # pylint: disable=W0611
from ba._team import EmptyTeam # pylint: disable=W0611
from bascenev1._player import EmptyPlayer # pylint: disable=W0611
from bascenev1._team import EmptyTeam # pylint: disable=W0611
from bascenev1._music import MusicType, setmusic
if TYPE_CHECKING:
import ba
from ba._lobby import JoinInfo
import bascenev1
from bascenev1._lobby import JoinInfo
class EndSessionActivity(Activity[EmptyPlayer, EmptyTeam]):
"""Special ba.Activity to fade out and end the current ba.Session."""
"""Special Activity to fade out and end the current Session."""
def __init__(self, settings: dict):
super().__init__(settings)
@ -34,17 +36,22 @@ class EndSessionActivity(Activity[EmptyPlayer, EmptyTeam]):
def on_transition_in(self) -> None:
super().on_transition_in()
_ba.fade_screen(False)
_ba.lock_all_input()
babase.fade_screen(False)
babase.lock_all_input()
def on_begin(self) -> None:
# pylint: disable=cyclic-import
from bastd.mainmenu import MainMenuSession
from ba._general import Call
assert babase.app.classic is not None
main_menu_session = babase.app.classic.get_main_menu_session()
super().on_begin()
_ba.unlock_all_input()
_ba.app.ads.call_after_ad(Call(_ba.new_host_session, MainMenuSession))
babase.unlock_all_input()
assert babase.app.classic is not None
babase.app.classic.ads.call_after_ad(
babase.Call(_bascenev1.new_host_session, main_menu_session)
)
class JoinActivity(Activity[EmptyPlayer, EmptyTeam]):
@ -66,14 +73,14 @@ class JoinActivity(Activity[EmptyPlayer, EmptyTeam]):
# In vr mode we don't want stuff moving around.
self.use_fixed_vr_overlay = True
self._background: ba.Actor | None = None
self._tips_text: ba.Actor | None = None
self._background: bascenev1.Actor | None = None
self._tips_text: bascenev1.Actor | None = None
self._join_info: JoinInfo | None = None
def on_transition_in(self) -> None:
# pylint: disable=cyclic-import
from bastd.actor.tipstext import TipsText
from bastd.actor.background import Background
from bascenev1lib.actor.tipstext import TipsText
from bascenev1lib.actor.background import Background
super().on_transition_in()
self._background = Background(
@ -82,7 +89,7 @@ class JoinActivity(Activity[EmptyPlayer, EmptyTeam]):
self._tips_text = TipsText()
setmusic(MusicType.CHAR_SELECT)
self._join_info = self.session.lobby.create_join_info()
_ba.set_analytics_screen('Joining Screen')
babase.set_analytics_screen('Joining Screen')
class TransitionActivity(Activity[EmptyPlayer, EmptyTeam]):
@ -101,14 +108,14 @@ class TransitionActivity(Activity[EmptyPlayer, EmptyTeam]):
def __init__(self, settings: dict):
super().__init__(settings)
self._background: ba.Actor | None = None
self._background: bascenev1.Actor | None = None
def on_transition_in(self) -> None:
# pylint: disable=cyclic-import
from bastd.actor import background # FIXME: Don't use bastd from ba.
from bascenev1lib.actor.background import Background
super().on_transition_in()
self._background = background.Background(
self._background = Background(
fade_time=0.5, start_faded=False, show_logo=False
)
@ -116,7 +123,7 @@ class TransitionActivity(Activity[EmptyPlayer, EmptyTeam]):
super().on_begin()
# Die almost immediately.
_ba.timer(0.1, self.end)
_bascenev1.timer(0.1, self.end)
class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]):
@ -134,32 +141,32 @@ class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]):
def __init__(self, settings: dict):
super().__init__(settings)
self._birth_time = _ba.time()
self._birth_time = babase.apptime()
self._min_view_time = 5.0
self._allow_server_transition = False
self._background: ba.Actor | None = None
self._tips_text: ba.Actor | None = None
self._background: bascenev1.Actor | None = None
self._tips_text: bascenev1.Actor | None = None
self._kicked_off_server_shutdown = False
self._kicked_off_server_restart = False
self._default_show_tips = True
self._custom_continue_message: ba.Lstr | None = None
self._custom_continue_message: babase.Lstr | None = None
self._server_transitioning: bool | None = None
def on_player_join(self, player: EmptyPlayer) -> None:
from ba._general import WeakCall
super().on_player_join(player)
time_till_assign = max(
0, self._birth_time + self._min_view_time - _ba.time()
0, self._birth_time + self._min_view_time - babase.apptime()
)
# If we're still kicking at the end of our assign-delay, assign this
# guy's input to trigger us.
_ba.timer(time_till_assign, WeakCall(self._safe_assign, player))
_bascenev1.timer(
time_till_assign, babase.WeakCall(self._safe_assign, player)
)
def on_transition_in(self) -> None:
from bastd.actor.tipstext import TipsText
from bastd.actor.background import Background
from bascenev1lib.actor.tipstext import TipsText
from bascenev1lib.actor.background import Background
super().on_transition_in()
self._background = Background(
@ -171,22 +178,20 @@ class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]):
def on_begin(self) -> None:
# pylint: disable=cyclic-import
from bastd.actor.text import Text
from ba import _language
from bascenev1lib.actor.text import Text
super().on_begin()
import custom_hooks
custom_hooks.score_screen_on_begin(self._stats)
# Pop up a 'press any button to continue' statement after our
# min-view-time show a 'press any button to continue..'
# thing after a bit.
if _ba.app.ui.uiscale is UIScale.LARGE:
assert babase.app.classic is not None
if babase.app.ui_v1.uiscale is babase.UIScale.LARGE:
# FIXME: Need a better way to determine whether we've probably
# got a keyboard.
sval = _language.Lstr(resource='pressAnyKeyButtonText')
sval = babase.Lstr(resource='pressAnyKeyButtonText')
else:
sval = _language.Lstr(resource='pressAnyButtonText')
sval = babase.Lstr(resource='pressAnyButtonText')
Text(
self._custom_continue_message
@ -204,15 +209,17 @@ class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]):
).autoretain()
def _player_press(self) -> None:
# If this activity is a good 'end point', ask server-mode just once if
# it wants to do anything special like switch sessions or kill the app.
if (
self._allow_server_transition
and _ba.app.server is not None
and babase.app.classic is not None
and babase.app.classic.server is not None
and self._server_transitioning is None
):
self._server_transitioning = _ba.app.server.handle_transition()
self._server_transitioning = (
babase.app.classic.server.handle_transition()
)
assert isinstance(self._server_transitioning, bool)
# If server-mode is handling this, don't do anything ourself.
@ -223,16 +230,15 @@ class ScoreScreenActivity(Activity[EmptyPlayer, EmptyTeam]):
self.end()
def _safe_assign(self, player: EmptyPlayer) -> None:
# Just to be extra careful, don't assign if we're transitioning out.
# (though theoretically that should be ok).
if not self.is_transitioning_out() and player:
player.assigninput(
(
InputType.JUMP_PRESS,
InputType.PUNCH_PRESS,
InputType.BOMB_PRESS,
InputType.PICK_UP_PRESS,
babase.InputType.JUMP_PRESS,
babase.InputType.PUNCH_PRESS,
babase.InputType.BOMB_PRESS,
babase.InputType.PICK_UP_PRESS,
),
self._player_press,
)

View file

@ -5,29 +5,37 @@
from __future__ import annotations
import weakref
import logging
from typing import TYPE_CHECKING, TypeVar, overload
import _ba
from ba._messages import DieMessage, DeathType, OutOfBoundsMessage, UNHANDLED
from ba._error import print_exception, ActivityNotFoundError
import babase
import _bascenev1
from bascenev1._messages import (
DieMessage,
DeathType,
OutOfBoundsMessage,
UNHANDLED,
)
if TYPE_CHECKING:
from typing import Any, Literal
import ba
import bascenev1
ActorT = TypeVar('ActorT', bound='Actor')
class Actor:
"""High level logical entities in a ba.Activity.
"""High level logical entities in a bascenev1.Activity.
Category: **Gameplay Classes**
Actors act as controllers, combining some number of ba.Nodes,
ba.Textures, ba.Sounds, etc. into a high-level cohesive unit.
Actors act as controllers, combining some number of Nodes, Textures,
Sounds, etc. into a high-level cohesive unit.
Some example actors include the Bomb, Flag, and Spaz classes that
live in the bastd.actor.* modules.
live in the bascenev1lib.actor.* modules.
One key feature of Actors is that they generally 'die'
(killing off or transitioning out their nodes) when the last Python
@ -35,7 +43,7 @@ class Actor:
##### Example
>>> # Create a flag Actor in our game activity:
... from bastd.actor.flag import Flag
... from bascenev1lib.actor.flag import Flag
... self.flag = Flag(position=(0, 10, 0))
...
... # Later, destroy the flag.
@ -44,34 +52,36 @@ class Actor:
... # Either way, the old flag disappears.
... self.flag = None
This is in contrast to the behavior of the more low level ba.Nodes,
which are always explicitly created and destroyed and don't care
how many Python references to them exist.
This is in contrast to the behavior of the more low level
bascenev1.Node, which is always explicitly created and destroyed
and doesn't care how many Python references to it exist.
Note, however, that you can use the ba.Actor.autoretain() method
Note, however, that you can use the bascenev1.Actor.autoretain() method
if you want an Actor to stick around until explicitly killed
regardless of references.
Another key feature of ba.Actor is its ba.Actor.handlemessage() method,
which takes a single arbitrary object as an argument. This provides a safe
way to communicate between ba.Actor, ba.Activity, ba.Session, and any other
Another key feature of bascenev1.Actor is its
bascenev1.Actor.handlemessage() method, which takes a single arbitrary
object as an argument. This provides a safe way to communicate between
bascenev1.Actor, bascenev1.Activity, bascenev1.Session, and any other
class providing a handlemessage() method. The most universally handled
message type for Actors is the ba.DieMessage.
message type for Actors is the bascenev1.DieMessage.
Another way to kill the flag from the example above:
We can safely call this on any type with a 'handlemessage' method
(though its not guaranteed to always have a meaningful effect).
In this case the Actor instance will still be around, but its
ba.Actor.exists() and ba.Actor.is_alive() methods will both return False.
>>> self.flag.handlemessage(ba.DieMessage())
bascenev1.Actor.exists() and bascenev1.Actor.is_alive() methods will
both return False.
>>> self.flag.handlemessage(bascenev1.DieMessage())
"""
def __init__(self) -> None:
"""Instantiates an Actor in the current ba.Activity."""
"""Instantiates an Actor in the current bascenev1.Activity."""
if __debug__:
self._root_actor_init_called = True
activity = _ba.getactivity()
activity = _bascenev1.getactivity()
self._activity = weakref.ref(activity)
activity.add_actor_weak_ref(self)
@ -83,7 +93,9 @@ class Actor:
if not self.expired:
self.handlemessage(DieMessage())
except Exception:
print_exception('exception in ba.Actor.__del__() for', self)
logging.exception(
'Error in bascenev1.Actor.__del__() for %s.', self
)
def handlemessage(self, msg: Any) -> Any:
"""General message handling; can be passed any message object."""
@ -98,30 +110,32 @@ class Actor:
def autoretain(self: ActorT) -> ActorT:
"""Keep this Actor alive without needing to hold a reference to it.
This keeps the ba.Actor in existence by storing a reference to it
with the ba.Activity it was created in. The reference is lazily
released once ba.Actor.exists() returns False for it or when the
Activity is set as expired. This can be a convenient alternative
to storing references explicitly just to keep a ba.Actor from dying.
For convenience, this method returns the ba.Actor it is called with,
enabling chained statements such as: myflag = ba.Flag().autoretain()
This keeps the bascenev1.Actor in existence by storing a reference
to it with the bascenev1.Activity it was created in. The reference
is lazily released once bascenev1.Actor.exists() returns False for
it or when the Activity is set as expired. This can be a convenient
alternative to storing references explicitly just to keep a
bascenev1.Actor from dying.
For convenience, this method returns the bascenev1.Actor it is called
with, enabling chained statements such as:
myflag = bascenev1.Flag().autoretain()
"""
activity = self._activity()
if activity is None:
raise ActivityNotFoundError()
raise babase.ActivityNotFoundError()
activity.retain_actor(self)
return self
def on_expire(self) -> None:
"""Called for remaining `ba.Actor`s when their ba.Activity shuts down.
"""Called for remaining `bascenev1.Actor`s when their activity dies.
Actors can use this opportunity to clear callbacks or other
references which have the potential of keeping the ba.Activity
references which have the potential of keeping the bascenev1.Activity
alive inadvertently (Activities can not exit cleanly while
any Python references to them remain.)
Once an actor is expired (see ba.Actor.is_expired()) it should no
longer perform any game-affecting operations (creating, modifying,
Once an actor is expired (see bascenev1.Actor.is_expired()) it should
no longer perform any game-affecting operations (creating, modifying,
or deleting nodes, media, timers, etc.) Attempts to do so will
likely result in errors.
"""
@ -130,7 +144,7 @@ class Actor:
def expired(self) -> bool:
"""Whether the Actor is expired.
(see ba.Actor.on_expire())
(see bascenev1.Actor.on_expire())
"""
activity = self.getactivity(doraise=False)
return True if activity is None else activity.expired
@ -140,11 +154,11 @@ class Actor:
Note that a dying character should still return True here as long as
their corpse is visible; this is about presence, not being 'alive'
(see ba.Actor.is_alive() for that).
(see bascenev1.Actor.is_alive() for that).
If this returns False, it is assumed the Actor can be completely
deleted without affecting the game; this call is often used
when pruning lists of Actors, such as with ba.Actor.autoretain()
when pruning lists of Actors, such as with bascenev1.Actor.autoretain()
The default implementation of this method always return True.
@ -170,33 +184,35 @@ class Actor:
return True
@property
def activity(self) -> ba.Activity:
def activity(self) -> bascenev1.Activity:
"""The Activity this Actor was created in.
Raises a ba.ActivityNotFoundError if the Activity no longer exists.
Raises a bascenev1.ActivityNotFoundError if the Activity no longer
exists.
"""
activity = self._activity()
if activity is None:
raise ActivityNotFoundError()
raise babase.ActivityNotFoundError()
return activity
# Overloads to convey our exact return type depending on 'doraise' value.
@overload
def getactivity(self, doraise: Literal[True] = True) -> ba.Activity:
def getactivity(self, doraise: Literal[True] = True) -> bascenev1.Activity:
...
@overload
def getactivity(self, doraise: Literal[False]) -> ba.Activity | None:
def getactivity(self, doraise: Literal[False]) -> bascenev1.Activity | None:
...
def getactivity(self, doraise: bool = True) -> ba.Activity | None:
"""Return the ba.Activity this Actor is associated with.
def getactivity(self, doraise: bool = True) -> bascenev1.Activity | None:
"""Return the bascenev1.Activity this Actor is associated with.
If the Activity no longer exists, raises a ba.ActivityNotFoundError
or returns None depending on whether 'doraise' is True.
If the Activity no longer exists, raises a
bascenev1.ActivityNotFoundError or returns None depending on whether
'doraise' is True.
"""
activity = self._activity()
if activity is None and doraise:
raise ActivityNotFoundError()
raise babase.ActivityNotFoundError()
return activity

View file

@ -0,0 +1,36 @@
# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
from babase import AppMode, AppIntentExec, AppIntentDefault
import _bascenev1
if TYPE_CHECKING:
from babase import AppIntent
class SceneV1AppMode(AppMode):
"""Our app-mode."""
@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):
_bascenev1.handle_app_intent_exec(intent.code)
return
assert isinstance(intent, AppIntentDefault)
_bascenev1.handle_app_intent_default()
def on_activate(self) -> None:
# Let the native layer do its thing.
_bascenev1.app_mode_activate()
def on_deactivate(self) -> None:
# Let the native layer do its thing.
_bascenev1.app_mode_deactivate()

View file

@ -5,25 +5,21 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
import babase
if TYPE_CHECKING:
from typing import Any
import ba
import bascenev1
def register_campaign(campaign: ba.Campaign) -> None:
def register_campaign(campaign: bascenev1.Campaign) -> None:
"""Register a new campaign."""
_ba.app.campaigns[campaign.name] = campaign
def getcampaign(name: str) -> ba.Campaign:
"""Return a campaign by name."""
return _ba.app.campaigns[name]
assert babase.app.classic is not None
babase.app.classic.campaigns[campaign.name] = campaign
class Campaign:
"""Represents a unique set or series of ba.Level-s.
"""Represents a unique set or series of baclassic.Level-s.
Category: **App Classes**
"""
@ -32,11 +28,11 @@ class Campaign:
self,
name: str,
sequential: bool = True,
levels: list[ba.Level] | None = None,
levels: list[bascenev1.Level] | None = None,
):
self._name = name
self._sequential = sequential
self._levels: list[ba.Level] = []
self._levels: list[bascenev1.Level] = []
if levels is not None:
for level in levels:
self.addlevel(level)
@ -51,8 +47,10 @@ class Campaign:
"""Whether this Campaign's levels must be played in sequence."""
return self._sequential
def addlevel(self, level: ba.Level, index: int | None = None) -> None:
"""Adds a ba.Level to the Campaign."""
def addlevel(
self, level: bascenev1.Level, index: int | None = None
) -> None:
"""Adds a baclassic.Level to the Campaign."""
if level.campaign is not None:
raise RuntimeError('Level already belongs to a campaign.')
level.set_campaign(self, len(self._levels))
@ -62,30 +60,30 @@ class Campaign:
self._levels.insert(index, level)
@property
def levels(self) -> list[ba.Level]:
"""The list of ba.Level-s in the Campaign."""
def levels(self) -> list[bascenev1.Level]:
"""The list of baclassic.Level-s in the Campaign."""
return self._levels
def getlevel(self, name: str) -> ba.Level:
"""Return a contained ba.Level by name."""
from ba import _error
def getlevel(self, name: str) -> bascenev1.Level:
"""Return a contained baclassic.Level by name."""
for level in self._levels:
if level.name == name:
return level
raise _error.NotFoundError(
raise babase.NotFoundError(
"Level '" + name + "' not found in campaign '" + self.name + "'"
)
def reset(self) -> None:
"""Reset state for the Campaign."""
_ba.app.config.setdefault('Campaigns', {})[self._name] = {}
babase.app.config.setdefault('Campaigns', {})[self._name] = {}
# FIXME should these give/take ba.Level instances instead of level names?..
# FIXME should these give/take baclassic.Level instances instead
# of level names?..
def set_selected_level(self, levelname: str) -> None:
"""Set the Level currently selected in the UI (by name)."""
self.configdict['Selection'] = levelname
_ba.app.config.commit()
babase.app.config.commit()
def get_selected_level(self) -> str:
"""Return the name of the Level currently selected in the UI."""
@ -94,7 +92,7 @@ class Campaign:
@property
def configdict(self) -> dict[str, Any]:
"""Return the live config dict for this campaign."""
val: dict[str, Any] = _ba.app.config.setdefault(
val: dict[str, Any] = babase.app.config.setdefault(
'Campaigns', {}
).setdefault(self._name, {})
assert isinstance(val, dict)
@ -104,19 +102,19 @@ class Campaign:
def init_campaigns() -> None:
"""Fill out initial default Campaigns."""
# pylint: disable=cyclic-import
from ba._level import Level
from bastd.game.onslaught import OnslaughtGame
from bastd.game.football import FootballCoopGame
from bastd.game.runaround import RunaroundGame
from bastd.game.thelaststand import TheLastStandGame
from bastd.game.race import RaceGame
from bastd.game.targetpractice import TargetPracticeGame
from bastd.game.meteorshower import MeteorShowerGame
from bastd.game.easteregghunt import EasterEggHuntGame
from bastd.game.ninjafight import NinjaFightGame
from bascenev1._level import Level
from bascenev1lib.game.onslaught import OnslaughtGame
from bascenev1lib.game.football import FootballCoopGame
from bascenev1lib.game.runaround import RunaroundGame
from bascenev1lib.game.thelaststand import TheLastStandGame
from bascenev1lib.game.race import RaceGame
from bascenev1lib.game.targetpractice import TargetPracticeGame
from bascenev1lib.game.meteorshower import MeteorShowerGame
from bascenev1lib.game.easteregghunt import EasterEggHuntGame
from bascenev1lib.game.ninjafight import NinjaFightGame
# TODO: Campaigns should be load-on-demand; not all imported at launch
# like this.
# like this.
# FIXME: Once translations catch up, we can convert these to use the
# generic display-name '${GAME} Training' type stuff.

View file

@ -6,11 +6,11 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._error import NodeNotFoundError
import babase
import _bascenev1
if TYPE_CHECKING:
import ba
import bascenev1
class Collision:
@ -20,42 +20,42 @@ class Collision:
"""
@property
def position(self) -> ba.Vec3:
def position(self) -> bascenev1.Vec3:
"""The position of the current collision."""
return _ba.Vec3(_ba.get_collision_info('position'))
return babase.Vec3(_bascenev1.get_collision_info('position'))
@property
def sourcenode(self) -> ba.Node:
def sourcenode(self) -> bascenev1.Node:
"""The node containing the material triggering the current callback.
Throws a ba.NodeNotFoundError if the node does not exist, though
the node should always exist (at least at the start of the collision
callback).
Throws a bascenev1.NodeNotFoundError if the node does not exist,
though the node should always exist (at least at the start of the
collision callback).
"""
node = _ba.get_collision_info('sourcenode')
assert isinstance(node, (_ba.Node, type(None)))
node = _bascenev1.get_collision_info('sourcenode')
assert isinstance(node, (_bascenev1.Node, type(None)))
if not node:
raise NodeNotFoundError()
raise babase.NodeNotFoundError()
return node
@property
def opposingnode(self) -> ba.Node:
def opposingnode(self) -> bascenev1.Node:
"""The node the current callback material node is hitting.
Throws a ba.NodeNotFoundError if the node does not exist.
Throws a bascenev1.NodeNotFoundError if the node does not exist.
This can be expected in some cases such as in 'disconnect'
callbacks triggered by deleting a currently-colliding node.
"""
node = _ba.get_collision_info('opposingnode')
assert isinstance(node, (_ba.Node, type(None)))
node = _bascenev1.get_collision_info('opposingnode')
assert isinstance(node, (_bascenev1.Node, type(None)))
if not node:
raise NodeNotFoundError()
raise babase.NodeNotFoundError()
return node
@property
def opposingbody(self) -> int:
"""The body index on the opposing node in the current collision."""
body = _ba.get_collision_info('opposingbody')
body = _bascenev1.get_collision_info('opposingbody')
assert isinstance(body, int)
return body

View file

@ -3,36 +3,39 @@
"""Functionality related to co-op games."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, TypeVar
import _ba
from ba import _internal
from ba._gameactivity import GameActivity
from ba._general import WeakCall
import babase
import _bascenev1
from bascenev1._gameactivity import GameActivity
if TYPE_CHECKING:
from typing import Sequence
from bastd.actor.playerspaz import PlayerSpaz
import ba
# pylint: disable=invalid-name
PlayerType = TypeVar('PlayerType', bound='ba.Player')
TeamType = TypeVar('TeamType', bound='ba.Team')
# pylint: enable=invalid-name
from bascenev1lib.actor.playerspaz import PlayerSpaz
import bascenev1
PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
TeamT = TypeVar('TeamT', bound='bascenev1.Team')
class CoopGameActivity(GameActivity[PlayerType, TeamType]):
class CoopGameActivity(GameActivity[PlayerT, TeamT]):
"""Base class for cooperative-mode games.
Category: **Gameplay Classes**
"""
# We can assume our session is a CoopSession.
session: ba.CoopSession
session: bascenev1.CoopSession
@classmethod
def supports_session_type(cls, sessiontype: type[ba.Session]) -> bool:
from ba._coopsession import CoopSession
def supports_session_type(
cls, sessiontype: type[bascenev1.Session]
) -> bool:
from bascenev1._coopsession import CoopSession
return issubclass(sessiontype, CoopSession)
@ -42,19 +45,21 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
# Cache these for efficiency.
self._achievements_awarded: set[str] = set()
self._life_warning_beep: ba.Actor | None = None
self._life_warning_beep_timer: ba.Timer | None = None
self._warn_beeps_sound = _ba.getsound('warnBeeps')
self._life_warning_beep: bascenev1.Actor | None = None
self._life_warning_beep_timer: bascenev1.Timer | None = None
self._warn_beeps_sound = _bascenev1.getsound('warnBeeps')
def on_begin(self) -> None:
super().on_begin()
# Show achievements remaining.
if not (_ba.app.demo_mode or _ba.app.arcade_mode):
_ba.timer(3.8, WeakCall(self._show_remaining_achievements))
if not (babase.app.demo_mode or babase.app.arcade_mode):
_bascenev1.timer(
3.8, babase.WeakCall(self._show_remaining_achievements)
)
# Preload achievement images in case we get some.
_ba.timer(2.0, WeakCall(self._preload_achievements))
_bascenev1.timer(2.0, babase.WeakCall(self._preload_achievements))
# FIXME: this is now redundant with activityutils.getscoreconfig();
# need to kill this.
@ -75,14 +80,15 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
a wave.
duration is given in seconds.
"""
from ba._messages import CelebrateMessage
from bascenev1._messages import CelebrateMessage
for player in self.players:
if player.actor:
player.actor.handlemessage(CelebrateMessage(duration))
def _preload_achievements(self) -> None:
achievements = _ba.app.ach.achievements_for_coop_level(
assert babase.app.classic is not None
achievements = babase.app.classic.ach.achievements_for_coop_level(
self._get_coop_level_name()
)
for ach in achievements:
@ -90,22 +96,22 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
def _show_remaining_achievements(self) -> None:
# pylint: disable=cyclic-import
from ba._language import Lstr
from bastd.actor.text import Text
from bascenev1lib.actor.text import Text
assert babase.app.classic is not None
ts_h_offs = 30
v_offs = -200
achievements = [
a
for a in _ba.app.ach.achievements_for_coop_level(
for a in babase.app.classic.ach.achievements_for_coop_level(
self._get_coop_level_name()
)
if not a.complete
]
vrmode = _ba.app.vr_mode
vrmode = babase.app.vr_mode
if achievements:
Text(
Lstr(resource='achievementsRemainingText'),
babase.Lstr(resource='achievementsRemainingText'),
host_only=True,
position=(ts_h_offs - 10 + 40, v_offs - 10),
transition=Text.Transition.FADE_IN,
@ -134,7 +140,7 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
def spawn_player_spaz(
self,
player: PlayerType,
player: PlayerT,
position: Sequence[float] = (0.0, 0.0, 0.0),
angle: float | None = None,
) -> PlayerSpaz:
@ -154,10 +160,18 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
False otherwise
"""
classic = babase.app.classic
plus = babase.app.plus
if classic is None or plus is None:
logging.warning(
'_award_achievement is a no-op without classic and plus.'
)
return
if achievement_name in self._achievements_awarded:
return
ach = _ba.app.ach.get_achievement(achievement_name)
ach = classic.ach.get_achievement(achievement_name)
# If we're in the easy campaign and this achievement is hard-mode-only,
# ignore it.
@ -167,9 +181,7 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
if ach.hard_mode_only and campaign.name == 'Easy':
return
except Exception:
from ba._error import print_exception
print_exception()
logging.exception('Error in _award_achievement.')
# If we haven't awarded this one, check to see if we've got it.
# If not, set it through the game service *and* add a transaction
@ -178,10 +190,10 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
self._achievements_awarded.add(achievement_name)
# Report new achievements to the game-service.
_internal.report_achievement(achievement_name)
plus.report_achievement(achievement_name)
# ...and to our account.
_internal.add_transaction(
plus.add_v1_account_transaction(
{'type': 'ACHIEVEMENT', 'name': achievement_name}
)
@ -190,10 +202,10 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
def fade_to_red(self) -> None:
"""Fade the screen to red; (such as when the good guys have lost)."""
from ba import _gameutils
from bascenev1 import _gameutils
c_existing = self.globalsnode.tint
cnode = _ba.newnode(
cnode = _bascenev1.newnode(
'combine',
attrs={
'input0': c_existing[0],
@ -209,8 +221,8 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
def setup_low_life_warning_sound(self) -> None:
"""Set up a beeping noise to play when any players are near death."""
self._life_warning_beep = None
self._life_warning_beep_timer = _ba.Timer(
1.0, WeakCall(self._update_life_warning), repeat=True
self._life_warning_beep_timer = _bascenev1.Timer(
1.0, babase.WeakCall(self._update_life_warning), repeat=True
)
def _update_life_warning(self) -> None:
@ -224,10 +236,10 @@ class CoopGameActivity(GameActivity[PlayerType, TeamType]):
should_beep = True
break
if should_beep and self._life_warning_beep is None:
from ba._nodeactor import NodeActor
from bascenev1._nodeactor import NodeActor
self._life_warning_beep = NodeActor(
_ba.newnode(
_bascenev1.newnode(
'sound',
attrs={
'sound': self._warn_beeps_sound,

View file

@ -5,65 +5,64 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._session import Session
import babase
import _bascenev1
from bascenev1._session import Session
if TYPE_CHECKING:
from typing import Any, Callable, Sequence
import ba
import bascenev1
TEAM_COLORS = [(0.2, 0.4, 1.6)]
TEAM_NAMES = ['Humans']
TEAM_NAMES = ['Good Guys']
class CoopSession(Session):
"""A ba.Session which runs cooperative-mode games.
"""A bascenev1.Session which runs cooperative-mode games.
Category: Gameplay Classes
Category: **Gameplay Classes**
These generally consist of 1-4 players against
the computer and include functionality such as
high score lists.
Attributes:
campaign
The ba.Campaign instance this Session represents, or None if
there is no associated Campaign.
"""
use_teams = True
use_team_colors = False
allow_mid_activity_joins = True
allow_mid_activity_joins = False
# Note: even though these are instance vars, we annotate them at the
# class level so that docs generation can access their types.
campaign: ba.Campaign | None
"""The ba.Campaign instance this Session represents, or None if
campaign: bascenev1.Campaign | None
"""The baclassic.Campaign instance this Session represents, or None if
there is no associated Campaign."""
def __init__(self) -> None:
"""Instantiate a co-op mode session."""
# pylint: disable=cyclic-import
from ba._campaign import getcampaign
from bastd.activity.coopjoin import CoopJoinActivity
from bascenev1lib.activity.coopjoin import CoopJoinActivity
_ba.increment_analytics_count('Co-op session start')
app = _ba.app
babase.increment_analytics_count('Co-op session start')
app = babase.app
classic = app.classic
assert classic is not None
# If they passed in explicit min/max, honor that.
# Otherwise defer to user overrides or defaults.
if 'min_players' in app.coop_session_args:
min_players = app.coop_session_args['min_players']
if 'min_players' in classic.coop_session_args:
min_players = classic.coop_session_args['min_players']
else:
min_players = 1
if 'max_players' in app.coop_session_args:
max_players = app.coop_session_args['max_players']
if 'max_players' in classic.coop_session_args:
max_players = classic.coop_session_args['max_players']
else:
max_players = app.config.get('Coop Game Max Players', 4)
# print('FIXME: COOP SESSION WOULD CALC DEPS.')
depsets: Sequence[ba.DependencySet] = []
depsets: Sequence[bascenev1.DependencySet] = []
super().__init__(
depsets,
@ -74,41 +73,48 @@ class CoopSession(Session):
)
# Tournament-ID if we correspond to a co-op tournament (otherwise None)
self.tournament_id: str | None = app.coop_session_args.get(
self.tournament_id: str | None = classic.coop_session_args.get(
'tournament_id'
)
self.campaign = getcampaign(app.coop_session_args['campaign'])
self.campaign_level_name: str = app.coop_session_args['level']
self.campaign = classic.getcampaign(
classic.coop_session_args['campaign']
)
self.campaign_level_name: str = classic.coop_session_args['level']
self._ran_tutorial_activity = False
self._tutorial_activity: ba.Activity | None = None
self._tutorial_activity: bascenev1.Activity | None = None
self._custom_menu_ui: list[dict[str, Any]] = []
# Start our joining screen.
self.setactivity(_ba.newactivity(CoopJoinActivity))
self.setactivity(_bascenev1.newactivity(CoopJoinActivity))
self._next_game_instance: ba.GameActivity | None = None
self._next_game_instance: bascenev1.GameActivity | None = None
self._next_game_level_name: str | None = None
self._update_on_deck_game_instances()
def get_current_game_instance(self) -> ba.GameActivity:
def get_current_game_instance(self) -> bascenev1.GameActivity:
"""Get the game instance currently being played."""
return self._current_game_instance
def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
def should_allow_mid_activity_joins(
self, activity: bascenev1.Activity
) -> bool:
# pylint: disable=cyclic-import
# from ba._gameactivity import GameActivity
from bascenev1._gameactivity import GameActivity
# Disallow any joins in the middle of the game.
# if isinstance(activity, GameActivity):
# return False
if isinstance(activity, GameActivity):
return False
return True
def _update_on_deck_game_instances(self) -> None:
# pylint: disable=cyclic-import
from ba._gameactivity import GameActivity
from bascenev1._gameactivity import GameActivity
classic = babase.app.classic
assert classic is not None
# Instantiate levels we may be running soon to let them load in the bg.
@ -124,7 +130,7 @@ class CoopSession(Session):
if setting.name not in settings:
settings[setting.name] = setting.default
newactivity = _ba.newactivity(gametype, settings)
newactivity = _bascenev1.newactivity(gametype, settings)
assert isinstance(newactivity, GameActivity)
self._current_game_instance: GameActivity = newactivity
@ -132,14 +138,11 @@ class CoopSession(Session):
levels = self.campaign.levels
level = self.campaign.getlevel(self.campaign_level_name)
nextlevel: ba.Level | None
# if level.index < len(levels) - 1:
# nextlevel = levels[level.index + 1]
# else:
# nextlevel = None
nextlevel=levels[(level.index+1)%len(levels)]
nextlevel: bascenev1.Level | None
if level.index < len(levels) - 1:
nextlevel = levels[level.index + 1]
else:
nextlevel = None
if nextlevel:
gametype = nextlevel.gametype
settings = nextlevel.get_settings()
@ -151,7 +154,7 @@ class CoopSession(Session):
settings[setting.name] = setting.default
# We wanna be in the activity's context while taking it down.
newactivity = _ba.newactivity(gametype, settings)
newactivity = _bascenev1.newactivity(gametype, settings)
assert isinstance(newactivity, GameActivity)
self._next_game_instance = newactivity
self._next_game_level_name = nextlevel.name
@ -167,24 +170,22 @@ class CoopSession(Session):
and self._tutorial_activity is None
and not self._ran_tutorial_activity
):
from bastd.tutorial import TutorialActivity
from bascenev1lib.tutorial import TutorialActivity
self._tutorial_activity = _ba.newactivity(TutorialActivity)
self._tutorial_activity = _bascenev1.newactivity(TutorialActivity)
def get_custom_menu_entries(self) -> list[dict[str, Any]]:
return self._custom_menu_ui
def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
from ba._general import WeakCall
def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None:
super().on_player_leave(sessionplayer)
_ba.timer(2.0, WeakCall(self._handle_empty_activity))
_bascenev1.timer(2.0, babase.WeakCall(self._handle_empty_activity))
def _handle_empty_activity(self) -> None:
"""Handle cases where all players have left the current activity."""
from ba._gameactivity import GameActivity
from bascenev1._gameactivity import GameActivity
activity = self.getactivity()
if activity is None:
@ -197,11 +198,9 @@ class CoopSession(Session):
# If there are *not* players in the current activity but there
# *are* in the session:
if not activity.players and self.sessionplayers:
# If we're in a game, we should restart to pull in players
# currently waiting in the session.
if isinstance(activity, GameActivity):
# Never restart tourney games however; just end the session
# if all players are gone.
if self.tournament_id is not None:
@ -212,25 +211,25 @@ class CoopSession(Session):
# Hmm; no players anywhere. Let's end the entire session if we're
# running a GUI (or just the current game if we're running headless).
else:
if not _ba.app.headless_mode:
if not babase.app.headless_mode:
self.end()
else:
if isinstance(activity, GameActivity):
with _ba.Context(activity):
with activity.context:
activity.end_game()
def _on_tournament_restart_menu_press(
self, resume_callback: Callable[[], Any]
) -> None:
# pylint: disable=cyclic-import
from bastd.ui.tournamententry import TournamentEntryWindow
from ba._gameactivity import GameActivity
from bascenev1._gameactivity import GameActivity
assert babase.app.classic is not None
activity = self.getactivity()
if activity is not None and not activity.expired:
assert self.tournament_id is not None
assert isinstance(activity, GameActivity)
TournamentEntryWindow(
babase.app.classic.tournament_entry_window(
tournament_id=self.tournament_id,
tournament_activity=activity,
on_close_call=resume_callback,
@ -253,10 +252,13 @@ class CoopSession(Session):
activity = self.getactivity()
if activity is not None and not activity.expired:
activity.can_show_ad_on_death = True
with _ba.Context(activity):
with activity.context:
activity.end(results={'outcome': 'restart'}, force=True)
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
# noinspection PyUnresolvedReferences
def on_activity_end(
self, activity: bascenev1.Activity, results: Any
) -> None:
"""Method override for co-op sessions.
Jumps between co-op games and score screens.
@ -265,17 +267,18 @@ class CoopSession(Session):
# pylint: disable=too-many-locals
# pylint: disable=too-many-statements
# pylint: disable=cyclic-import
from ba._activitytypes import JoinActivity, TransitionActivity
from ba._language import Lstr
from ba._general import WeakCall
from ba._coopgame import CoopGameActivity
from ba._gameresults import GameResults
from ba._score import ScoreType
from ba._player import PlayerInfo
from bastd.tutorial import TutorialActivity
from bastd.activity.coopscore import CoopScoreScreen
from bascenev1lib.activity.coopscore import CoopScoreScreen
from bascenev1lib.tutorial import TutorialActivity
app = _ba.app
from bascenev1._gameresults import GameResults
from bascenev1._player import PlayerInfo
from bascenev1._activitytypes import JoinActivity, TransitionActivity
from bascenev1._coopgame import CoopGameActivity
from bascenev1._score import ScoreType
app = babase.app
classic = app.classic
assert classic is not None
# If we're running a TeamGameActivity we'll have a GameResults
# as results. Otherwise its an old CoopGameActivity so its giving
@ -288,7 +291,7 @@ class CoopSession(Session):
# If we're running with a gui and at any point we have no
# in-game players, quit out of the session (this can happen if
# someone leaves in the tutorial for instance).
if not _ba.app.headless_mode:
if not babase.app.headless_mode:
active_players = [p for p in self.sessionplayers if p.in_game]
if not active_players:
self.end()
@ -296,19 +299,9 @@ class CoopSession(Session):
# If we're in a between-round activity or a restart-activity,
# hop into a round.
if outcome=="victory" or outcome=="restart" or outcome=="defeat":
outcome = 'next_level'
if (isinstance(activity,
(JoinActivity, CoopScoreScreen, TransitionActivity))) or True:
from features import team_balancer
team_balancer.checkToExitCoop()
if isinstance(
activity, (JoinActivity, CoopScoreScreen, TransitionActivity)
):
if outcome == 'next_level':
if self._next_game_instance is None:
raise RuntimeError()
@ -335,11 +328,9 @@ class CoopSession(Session):
# Normal case; launch the next round.
else:
# Reset stats for the new activity.
self.stats.reset()
for player in self.sessionplayers:
# Skip players that are still choosing a team.
if player.in_game:
self.stats.register_sessionplayer(player)
@ -352,9 +343,9 @@ class CoopSession(Session):
if self.tournament_id is not None:
self._custom_menu_ui = [
{
'label': Lstr(resource='restartText'),
'label': babase.Lstr(resource='restartText'),
'resume_on_call': False,
'call': WeakCall(
'call': babase.WeakCall(
self._on_tournament_restart_menu_press
),
}
@ -362,22 +353,17 @@ class CoopSession(Session):
else:
self._custom_menu_ui = [
{
'label': Lstr(resource='restartText'),
'call': WeakCall(self.restart),
'label': babase.Lstr(resource='restartText'),
'call': babase.WeakCall(self.restart),
}
]
# If we were in a tutorial, just pop a transition to get to the
# actual round.
elif isinstance(activity, TutorialActivity):
self.setactivity(_ba.newactivity(TransitionActivity))
self.setactivity(_bascenev1.newactivity(TransitionActivity))
else:
pass
if False:
playerinfos: list[ba.PlayerInfo]
playerinfos: list[bascenev1.PlayerInfo]
# Generic team games.
if isinstance(results, GameResults):
@ -431,17 +417,16 @@ class CoopSession(Session):
# Validate types.
if playerinfos is not None:
assert isinstance(playerinfos, list)
assert (isinstance(i, PlayerInfo) for i in playerinfos)
assert all(isinstance(i, PlayerInfo) for i in playerinfos)
# Looks like we were in a round - check the outcome and
# go from there.
if outcome == 'restart':
# This will pop up back in the same round.
self.setactivity(_ba.newactivity(TransitionActivity))
self.setactivity(_bascenev1.newactivity(TransitionActivity))
else:
self.setactivity(
_ba.newactivity(
_bascenev1.newactivity(
CoopScoreScreen,
{
'playerinfos': playerinfos,

68
dist/ba_data/python/bascenev1/_debug.py vendored Normal file
View file

@ -0,0 +1,68 @@
# Released under the MIT License. See LICENSE for details.
#
"""Debugging functionality."""
from __future__ import annotations
from typing import TYPE_CHECKING
import babase
if TYPE_CHECKING:
from typing import Any
import bascenev1
def print_live_object_warnings(
when: Any,
ignore_session: bascenev1.Session | None = None,
ignore_activity: bascenev1.Activity | None = None,
) -> None:
"""Print warnings for remaining objects in the current context.
IMPORTANT - don't call this in production; usage of gc.get_objects()
can bork Python. See notes at top of efro.debug module.
"""
# pylint: disable=cyclic-import
import gc
from bascenev1._session import Session
from bascenev1._actor import Actor
from bascenev1._activity import Activity
assert babase.app.classic is not None
sessions: list[bascenev1.Session] = []
activities: list[bascenev1.Activity] = []
actors: list[bascenev1.Actor] = []
# Once we come across leaked stuff, printing again is probably
# redundant.
if babase.app.classic.printed_live_object_warning:
return
for obj in gc.get_objects():
if isinstance(obj, Actor):
actors.append(obj)
elif isinstance(obj, Session):
sessions.append(obj)
elif isinstance(obj, Activity):
activities.append(obj)
# Complain about any remaining sessions.
for session in sessions:
if session is ignore_session:
continue
babase.app.classic.printed_live_object_warning = True
print(f'ERROR: Session found {when}: {session}')
# Complain about any remaining activities.
for activity in activities:
if activity is ignore_activity:
continue
babase.app.classic.printed_live_object_warning = True
print(f'ERROR: Activity found {when}: {activity}')
# Complain about any remaining actors.
for actor in actors:
babase.app.classic.printed_live_object_warning = True
print(f'ERROR: Actor found {when}: {actor}')

View file

@ -7,11 +7,14 @@ from __future__ import annotations
import weakref
from typing import Generic, TypeVar, TYPE_CHECKING
import _ba
import babase
import _bascenev1
if TYPE_CHECKING:
from typing import Any
import ba
import bascenev1
T = TypeVar('T', bound='DependencyComponent')
@ -26,13 +29,13 @@ class Dependency(Generic[T]):
The class functions as a descriptor, allowing dependencies to
be added at a class level much the same as properties or methods
and then used with class instances to access those dependencies.
For instance, if you do 'floofcls = ba.Dependency(FloofClass)' you
would then be able to instantiate a FloofClass in your class's
For instance, if you do 'floofcls = bascenev1.Dependency(FloofClass)'
you would then be able to instantiate a FloofClass in your class's
methods via self.floofcls().
"""
def __init__(self, cls: type[T], config: Any = None):
"""Instantiate a Dependency given a ba.DependencyComponent type.
"""Instantiate a Dependency given a bascenev1.DependencyComponent type.
Optionally, an arbitrary object can be passed as 'config' to
influence dependency calculation for the target class.
@ -57,7 +60,7 @@ class Dependency(Generic[T]):
)
raise TypeError(
f'Dependency cannot be added to class of type {type(obj)}'
' (class must inherit from ba.DependencyComponent).'
' (class must inherit from bascenev1.DependencyComponent).'
)
# We expect to be instantiated from an already living
@ -125,7 +128,7 @@ class DependencyComponent:
class DependencyEntry:
"""Data associated with a dependency/config pair in a ba.DependencySet."""
"""Data associated with a dep/config pair in bascenev1.DependencySet."""
# def __del__(self) -> None:
# print('~DepEntry()', self.cls)
@ -154,7 +157,6 @@ class DependencyEntry:
instance._dep_entry = weakref.ref(self)
instance.__init__() # type: ignore
assert self.depset
depset = self.depset()
assert depset is not None
self.component = instance
@ -193,12 +195,12 @@ class DependencySet(Generic[T]):
def resolve(self) -> None:
"""Resolve the complete set of required dependencies for this set.
Raises a ba.DependencyError if dependencies are missing (or other
Exception types on other errors).
Raises a bascenev1.DependencyError if dependencies are missing (or
other Exception types on other errors).
"""
if self._resolved:
raise Exception('DependencySet has already been resolved.')
raise RuntimeError('DependencySet has already been resolved.')
# print('RESOLVING DEP SET')
@ -214,8 +216,6 @@ class DependencySet(Generic[T]):
if not entry.cls.dep_is_present(entry.config)
]
if missing:
from ba._error import DependencyError
raise DependencyError(missing)
self._resolved = True
@ -233,7 +233,7 @@ class DependencySet(Generic[T]):
"""
ids: set[str] = set()
if not self._resolved:
raise Exception('Must be called on a resolved dep-set.')
raise RuntimeError('Must be called on a resolved dep-set.')
for entry in self.entries.values():
if issubclass(entry.cls, AssetPackage):
assert isinstance(entry.config, str)
@ -268,7 +268,6 @@ class DependencySet(Generic[T]):
return rootdata
def _resolve(self, dep: Dependency[T], recursion: int) -> None:
# Watch for wacky infinite dep loops.
if recursion > 10:
raise RecursionError('Max recursion reached')
@ -297,7 +296,7 @@ class DependencySet(Generic[T]):
class AssetPackage(DependencyComponent):
"""ba.DependencyComponent representing a bundled package of game assets.
"""bascenev1.DependencyComponent representing a bundled package of assets.
Category: **Asset Classes**
"""
@ -306,7 +305,7 @@ class AssetPackage(DependencyComponent):
super().__init__()
# This is used internally by the get_package_xxx calls.
self.context = _ba.Context('current')
self.context = babase.ContextRef()
entry = self._dep_entry()
assert entry is not None
@ -323,40 +322,40 @@ class AssetPackage(DependencyComponent):
return True
return False
def gettexture(self, name: str) -> ba.Texture:
"""Load a named ba.Texture from the AssetPackage.
def gettexture(self, name: str) -> bascenev1.Texture:
"""Load a named bascenev1.Texture from the AssetPackage.
Behavior is similar to ba.gettexture()
Behavior is similar to bascenev1.gettexture()
"""
return _ba.get_package_texture(self, name)
return _bascenev1.get_package_texture(self, name)
def getmodel(self, name: str) -> ba.Model:
"""Load a named ba.Model from the AssetPackage.
def getmesh(self, name: str) -> bascenev1.Mesh:
"""Load a named bascenev1.Mesh from the AssetPackage.
Behavior is similar to ba.getmodel()
Behavior is similar to bascenev1.getmesh()
"""
return _ba.get_package_model(self, name)
return _bascenev1.get_package_mesh(self, name)
def getcollidemodel(self, name: str) -> ba.CollideModel:
"""Load a named ba.CollideModel from the AssetPackage.
def getcollisionmesh(self, name: str) -> bascenev1.CollisionMesh:
"""Load a named bascenev1.CollisionMesh from the AssetPackage.
Behavior is similar to ba.getcollideModel()
Behavior is similar to bascenev1.getcollisionmesh()
"""
return _ba.get_package_collide_model(self, name)
return _bascenev1.get_package_collision_mesh(self, name)
def getsound(self, name: str) -> ba.Sound:
"""Load a named ba.Sound from the AssetPackage.
def getsound(self, name: str) -> bascenev1.Sound:
"""Load a named bascenev1.Sound from the AssetPackage.
Behavior is similar to ba.getsound()
Behavior is similar to bascenev1.getsound()
"""
return _ba.get_package_sound(self, name)
return _bascenev1.get_package_sound(self, name)
def getdata(self, name: str) -> ba.Data:
"""Load a named ba.Data from the AssetPackage.
def getdata(self, name: str) -> bascenev1.Data:
"""Load a named bascenev1.Data from the AssetPackage.
Behavior is similar to ba.getdata()
Behavior is similar to bascenev1.getdata()
"""
return _ba.get_package_data(self, name)
return _bascenev1.get_package_data(self, name)
class TestClassFactory(DependencyComponent):
@ -368,7 +367,7 @@ class TestClassFactory(DependencyComponent):
super().__init__()
print('Instantiating TestClassFactory')
self.tex = self._assets.gettexture('black')
self.model = self._assets.getmodel('landMine')
self.mesh = self._assets.getmesh('landMine')
self.sound = self._assets.getsound('error')
self.data = self._assets.getdata('langdata')
@ -402,8 +401,6 @@ def test_depset() -> None:
print('running test_depset()...')
def doit() -> None:
from ba._error import DependencyError
depset = DependencySet(Dependency(TestClass))
try:
depset.resolve()
@ -428,4 +425,22 @@ def test_depset() -> None:
# To test this, add prints on __del__ for stuff used above;
# everything should be dead at this point if we have no cycles.
print('everything should be cleaned up...')
_ba.quit()
babase.quit()
class DependencyError(Exception):
"""Exception raised when one or more bascenev1.Dependency items are missing.
Category: **Exception Classes**
(this will generally be missing assets).
"""
def __init__(self, deps: list[bascenev1.Dependency]):
super().__init__()
self._deps = deps
@property
def deps(self) -> list[bascenev1.Dependency]:
"""The list of missing dependencies causing this error."""
return self._deps

View file

@ -5,15 +5,17 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._multiteamsession import MultiTeamSession
import babase
import _bascenev1
from bascenev1._multiteamsession import MultiTeamSession
if TYPE_CHECKING:
import ba
import bascenev1
class DualTeamSession(MultiTeamSession):
"""ba.Session type for teams mode games.
"""bascenev1.Session type for teams mode games.
Category: **Gameplay Classes**
"""
@ -27,22 +29,24 @@ class DualTeamSession(MultiTeamSession):
_playlists_var = 'Team Tournament Playlists'
def __init__(self) -> None:
_ba.increment_analytics_count('Teams session start')
babase.increment_analytics_count('Teams session start')
super().__init__()
def _switch_to_score_screen(self, results: ba.GameResults) -> None:
def _switch_to_score_screen(self, results: bascenev1.GameResults) -> None:
# pylint: disable=cyclic-import
from bastd.activity.drawscore import DrawScoreScreenActivity
from bastd.activity.dualteamscore import TeamVictoryScoreScreenActivity
from bastd.activity.multiteamvictory import (
from bascenev1lib.activity.multiteamvictory import (
TeamSeriesVictoryScoreScreenActivity,
)
from bascenev1lib.activity.dualteamscore import (
TeamVictoryScoreScreenActivity,
)
from bascenev1lib.activity.drawscore import DrawScoreScreenActivity
winnergroups = results.winnergroups
# If everyone has the same score, call it a draw.
if len(winnergroups) < 2:
self.setactivity(_ba.newactivity(DrawScoreScreenActivity))
self.setactivity(_bascenev1.newactivity(DrawScoreScreenActivity))
else:
winner = winnergroups[0].teams[0]
winner.customdata['score'] += 1
@ -50,13 +54,14 @@ class DualTeamSession(MultiTeamSession):
# If a team has won, show final victory screen.
if winner.customdata['score'] >= (self._series_length - 1) / 2 + 1:
self.setactivity(
_ba.newactivity(
TeamSeriesVictoryScoreScreenActivity, {'winner': winner}
_bascenev1.newactivity(
TeamSeriesVictoryScoreScreenActivity,
{'winner': winner},
)
)
else:
self.setactivity(
_ba.newactivity(
_bascenev1.newactivity(
TeamVictoryScoreScreenActivity, {'winner': winner}
)
)

View file

@ -0,0 +1,8 @@
# Released under the MIT License. See LICENSE for details.
#
"""Gather our feature-set functionality from the _babase binary module."""
# This module only exists to imports/rename the parts of our feature-set
# contained in _babase. This allows our internal modules to access this
# functionality through us instead of requiring long-winded
# feature-set-specific names in _babase.

View file

@ -6,15 +6,17 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import _ba
from ba._multiteamsession import MultiTeamSession
import babase
import _bascenev1
from bascenev1._multiteamsession import MultiTeamSession
if TYPE_CHECKING:
import ba
import bascenev1
class FreeForAllSession(MultiTeamSession):
"""ba.Session type for free-for-all mode games.
"""bascenev1.Session type for free-for-all mode games.
Category: **Gameplay Classes**
"""
@ -48,19 +50,19 @@ class FreeForAllSession(MultiTeamSession):
return point_awards
def __init__(self) -> None:
_ba.increment_analytics_count('Free-for-all session start')
babase.increment_analytics_count('Free-for-all session start')
super().__init__()
def _switch_to_score_screen(self, results: ba.GameResults) -> None:
def _switch_to_score_screen(self, results: bascenev1.GameResults) -> None:
# pylint: disable=cyclic-import
from efro.util import asserttype
from bastd.activity.drawscore import DrawScoreScreenActivity
from bastd.activity.multiteamvictory import (
from bascenev1lib.activity.multiteamvictory import (
TeamSeriesVictoryScoreScreenActivity,
)
from bastd.activity.freeforallvictory import (
from bascenev1lib.activity.freeforallvictory import (
FreeForAllVictoryScoreScreenActivity,
)
from bascenev1lib.activity.drawscore import DrawScoreScreenActivity
winners = results.winnergroups
@ -68,7 +70,9 @@ class FreeForAllSession(MultiTeamSession):
# call it a draw.
if len(self.sessionplayers) > 1 and len(winners) < 2:
self.setactivity(
_ba.newactivity(DrawScoreScreenActivity, {'results': results})
_bascenev1.newactivity(
DrawScoreScreenActivity, {'results': results}
)
)
else:
# Award different point amounts based on number of players.
@ -95,14 +99,14 @@ class FreeForAllSession(MultiTeamSession):
!= series_winners[1].customdata['score']
):
self.setactivity(
_ba.newactivity(
_bascenev1.newactivity(
TeamSeriesVictoryScoreScreenActivity,
{'winner': series_winners[0]},
)
)
else:
self.setactivity(
_ba.newactivity(
_bascenev1.newactivity(
FreeForAllVictoryScoreScreenActivity,
{'results': results},
)

View file

@ -9,11 +9,14 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
from efro.util import asserttype
from ba._team import Team, SessionTeam
import babase
from bascenev1._team import Team, SessionTeam
if TYPE_CHECKING:
from typing import Sequence
import ba
import bascenev1
@dataclass
@ -21,7 +24,7 @@ class WinnerGroup:
"""Entry for a winning team or teams calculated by game-results."""
score: int | None
teams: Sequence[ba.SessionTeam]
teams: Sequence[bascenev1.SessionTeam]
class GameResults:
@ -31,22 +34,24 @@ class GameResults:
Category: **Gameplay Classes**
Upon completion, a game should fill one of these out and pass it to its
ba.Activity.end call.
bascenev1.Activity.end call.
"""
def __init__(self) -> None:
self._game_set = False
self._scores: dict[
int, tuple[weakref.ref[ba.SessionTeam], int | None]
int, tuple[weakref.ref[bascenev1.SessionTeam], int | None]
] = {}
self._sessionteams: list[weakref.ref[ba.SessionTeam]] | None = None
self._playerinfos: list[ba.PlayerInfo] | None = None
self._sessionteams: list[
weakref.ref[bascenev1.SessionTeam]
] | None = None
self._playerinfos: list[bascenev1.PlayerInfo] | None = None
self._lower_is_better: bool | None = None
self._score_label: str | None = None
self._none_is_winner: bool | None = None
self._scoretype: ba.ScoreType | None = None
self._scoretype: bascenev1.ScoreType | None = None
def set_game(self, game: ba.GameActivity) -> None:
def set_game(self, game: bascenev1.GameActivity) -> None:
"""Set the game instance these results are applying to."""
if self._game_set:
raise RuntimeError('Game set twice for GameResults.')
@ -61,7 +66,7 @@ class GameResults:
self._none_is_winner = scoreconfig.none_is_winner
self._scoretype = scoreconfig.scoretype
def set_team_score(self, team: ba.Team, score: int | None) -> None:
def set_team_score(self, team: bascenev1.Team, score: int | None) -> None:
"""Set the score for a given team.
This can be a number or None.
@ -71,8 +76,10 @@ class GameResults:
sessionteam = team.sessionteam
self._scores[sessionteam.id] = (weakref.ref(sessionteam), score)
def get_sessionteam_score(self, sessionteam: ba.SessionTeam) -> int | None:
"""Return the score for a given ba.SessionTeam."""
def get_sessionteam_score(
self, sessionteam: bascenev1.SessionTeam
) -> int | None:
"""Return the score for a given bascenev1.SessionTeam."""
assert isinstance(sessionteam, SessionTeam)
for score in list(self._scores.values()):
if score[0]() is sessionteam:
@ -82,8 +89,8 @@ class GameResults:
return None
@property
def sessionteams(self) -> list[ba.SessionTeam]:
"""Return all ba.SessionTeams in the results."""
def sessionteams(self) -> list[bascenev1.SessionTeam]:
"""Return all bascenev1.SessionTeams in the results."""
if not self._game_set:
raise RuntimeError("Can't get teams until game is set.")
teams = []
@ -94,41 +101,36 @@ class GameResults:
teams.append(team)
return teams
def has_score_for_sessionteam(self, sessionteam: ba.SessionTeam) -> bool:
def has_score_for_sessionteam(
self, sessionteam: bascenev1.SessionTeam
) -> bool:
"""Return whether there is a score for a given session-team."""
return any(s[0]() is sessionteam for s in self._scores.values())
def get_sessionteam_score_str(self, sessionteam: ba.SessionTeam) -> ba.Lstr:
def get_sessionteam_score_str(
self, sessionteam: bascenev1.SessionTeam
) -> babase.Lstr:
"""Return the score for the given session-team as an Lstr.
(properly formatted for the score type.)
"""
from ba._gameutils import timestring
from ba._language import Lstr
from ba._generated.enums import TimeFormat
from ba._score import ScoreType
from bascenev1._score import ScoreType
if not self._game_set:
raise RuntimeError("Can't get team-score-str until game is set.")
for score in list(self._scores.values()):
if score[0]() is sessionteam:
if score[1] is None:
return Lstr(value='-')
return babase.Lstr(value='-')
if self._scoretype is ScoreType.SECONDS:
return timestring(
score[1] * 1000,
centi=False,
timeformat=TimeFormat.MILLISECONDS,
)
return babase.timestring(score[1], centi=False)
if self._scoretype is ScoreType.MILLISECONDS:
return timestring(
score[1], centi=True, timeformat=TimeFormat.MILLISECONDS
)
return Lstr(value=str(score[1]))
return Lstr(value='-')
return babase.timestring(score[1] / 1000.0, centi=True)
return babase.Lstr(value=str(score[1]))
return babase.Lstr(value='-')
@property
def playerinfos(self) -> list[ba.PlayerInfo]:
def playerinfos(self) -> list[bascenev1.PlayerInfo]:
"""Get info about the players represented by the results."""
if not self._game_set:
raise RuntimeError("Can't get player-info until game is set.")
@ -136,7 +138,7 @@ class GameResults:
return self._playerinfos
@property
def scoretype(self) -> ba.ScoreType:
def scoretype(self) -> bascenev1.ScoreType:
"""The type of score."""
if not self._game_set:
raise RuntimeError("Can't get score-type until game is set.")
@ -160,8 +162,8 @@ class GameResults:
return self._lower_is_better
@property
def winning_sessionteam(self) -> ba.SessionTeam | None:
"""The winning ba.SessionTeam if there is exactly one, or else None."""
def winning_sessionteam(self) -> bascenev1.SessionTeam | None:
"""The winning SessionTeam if there is exactly one, or else None."""
if not self._game_set:
raise RuntimeError("Can't get winners until game is set.")
winners = self.winnergroups
@ -176,7 +178,7 @@ class GameResults:
raise RuntimeError("Can't get winners until game is set.")
# Group by best scoring teams.
winners: dict[int, list[ba.SessionTeam]] = {}
winners: dict[int, list[bascenev1.SessionTeam]] = {}
scores = [
score
for score in self._scores.values()
@ -188,7 +190,7 @@ class GameResults:
team = score[0]()
assert team is not None
sval.append(team)
results: list[tuple[int | None, list[ba.SessionTeam]]] = list(
results: list[tuple[int | None, list[bascenev1.SessionTeam]]] = list(
winners.items()
)
results.sort(
@ -197,7 +199,7 @@ class GameResults:
)
# Also group the 'None' scores.
none_sessionteams: list[ba.SessionTeam] = []
none_sessionteams: list[bascenev1.SessionTeam] = []
for score in self._scores.values():
scoreteam = score[0]()
if scoreteam is not None and score[1] is None:
@ -206,7 +208,7 @@ class GameResults:
# Add the Nones to the list (either as winners or losers
# depending on the rules).
if none_sessionteams:
nones: list[tuple[int | None, list[ba.SessionTeam]]] = [
nones: list[tuple[int | None, list[bascenev1.SessionTeam]]] = [
(None, none_sessionteams)
]
if self._none_is_winner:

View file

@ -5,23 +5,26 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, NewType
import _ba
from ba._generated.enums import TimeType, TimeFormat, SpecialChar, UIScale
from ba._error import ActivityNotFoundError
import babase
import _bascenev1
if TYPE_CHECKING:
from typing import Sequence
import ba
import bascenev1
Time = NewType('Time', float)
BaseTime = NewType('BaseTime', float)
TROPHY_CHARS = {
'1': SpecialChar.TROPHY1,
'2': SpecialChar.TROPHY2,
'3': SpecialChar.TROPHY3,
'0a': SpecialChar.TROPHY0A,
'0b': SpecialChar.TROPHY0B,
'4': SpecialChar.TROPHY4,
'1': babase.SpecialChar.TROPHY1,
'2': babase.SpecialChar.TROPHY2,
'3': babase.SpecialChar.TROPHY3,
'0a': babase.SpecialChar.TROPHY0A,
'0b': babase.SpecialChar.TROPHY0B,
'4': babase.SpecialChar.TROPHY4,
}
@ -33,28 +36,25 @@ class GameTip:
"""
text: str
icon: ba.Texture | None = None
sound: ba.Sound | None = None
icon: bascenev1.Texture | None = None
sound: bascenev1.Sound | None = None
def get_trophy_string(trophy_id: str) -> str:
"""Given a trophy id, returns a string to visualize it."""
if trophy_id in TROPHY_CHARS:
return _ba.charstr(TROPHY_CHARS[trophy_id])
return babase.charstr(TROPHY_CHARS[trophy_id])
return '?'
def animate(
node: ba.Node,
node: bascenev1.Node,
attr: str,
keys: dict[float, float],
loop: bool = False,
offset: float = 0,
timetype: ba.TimeType = TimeType.SIM,
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
suppress_format_warning: bool = False,
) -> ba.Node:
"""Animate values on a target ba.Node.
) -> bascenev1.Node:
"""Animate values on a target bascenev1.Node.
Category: **Gameplay Functions**
@ -65,37 +65,20 @@ def animate(
but timeformat can also be set to MILLISECONDS to recreate the old behavior
(prior to ba 1.5) of taking milliseconds. Returns the animcurve node.
"""
if timetype is TimeType.SIM:
driver = 'time'
else:
raise Exception('FIXME; only SIM timetype is supported currently.')
items = list(keys.items())
items.sort()
# Temp sanity check while we transition from milliseconds to seconds
# based time values.
if __debug__:
if not suppress_format_warning:
for item in items:
_ba.time_format_check(timeformat, item[0])
curve = _ba.newnode(
curve = _bascenev1.newnode(
'animcurve',
owner=node,
name='Driving ' + str(node) + ' \'' + attr + '\'',
)
if timeformat is TimeFormat.SECONDS:
mult = 1000
elif timeformat is TimeFormat.MILLISECONDS:
mult = 1
else:
raise ValueError(f'invalid timeformat value: {timeformat}')
# We take seconds but operate on milliseconds internally.
mult = 1000
curve.times = [int(mult * time) for time, val in items]
curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
mult * offset
)
curve.offset = int(_bascenev1.time() * 1000.0) + int(mult * offset)
curve.values = [val for time, val in items]
curve.loop = loop
@ -105,10 +88,8 @@ def animate(
# get disconnected.
if not loop:
# noinspection PyUnresolvedReferences
_ba.timer(
int(mult * items[-1][0]) + 1000,
curve.delete,
timeformat=TimeFormat.MILLISECONDS,
_bascenev1.timer(
(int(mult * items[-1][0]) + 1000) / 1000.0, curve.delete
)
# Do the connects last so all our attrs are in place when we push initial
@ -116,76 +97,54 @@ def animate(
# We operate in either activities or sessions..
try:
globalsnode = _ba.getactivity().globalsnode
except ActivityNotFoundError:
globalsnode = _ba.getsession().sessionglobalsnode
globalsnode = _bascenev1.getactivity().globalsnode
except babase.ActivityNotFoundError:
globalsnode = _bascenev1.getsession().sessionglobalsnode
globalsnode.connectattr(driver, curve, 'in')
globalsnode.connectattr('time', curve, 'in')
curve.connectattr('out', node, attr)
return curve
def animate_array(
node: ba.Node,
node: bascenev1.Node,
attr: str,
size: int,
keys: dict[float, Sequence[float]],
loop: bool = False,
offset: float = 0,
timetype: ba.TimeType = TimeType.SIM,
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
suppress_format_warning: bool = False,
) -> None:
"""Animate an array of values on a target ba.Node.
"""Animate an array of values on a target bascenev1.Node.
Category: **Gameplay Functions**
Like ba.animate, but operates on array attributes.
Like bs.animate, but operates on array attributes.
"""
# pylint: disable=too-many-locals
combine = _ba.newnode('combine', owner=node, attrs={'size': size})
if timetype is TimeType.SIM:
driver = 'time'
else:
raise Exception('FIXME: Only SIM timetype is supported currently.')
combine = _bascenev1.newnode('combine', owner=node, attrs={'size': size})
items = list(keys.items())
items.sort()
# Temp sanity check while we transition from milliseconds to seconds
# based time values.
if __debug__:
if not suppress_format_warning:
for item in items:
# (PyCharm seems to think item is a float, not a tuple)
_ba.time_format_check(timeformat, item[0])
if timeformat is TimeFormat.SECONDS:
mult = 1000
elif timeformat is TimeFormat.MILLISECONDS:
mult = 1
else:
raise ValueError('invalid timeformat value: "' + str(timeformat) + '"')
# We take seconds but operate on milliseconds internally.
mult = 1000
# We operate in either activities or sessions..
try:
globalsnode = _ba.getactivity().globalsnode
except ActivityNotFoundError:
globalsnode = _ba.getsession().sessionglobalsnode
globalsnode = _bascenev1.getactivity().globalsnode
except babase.ActivityNotFoundError:
globalsnode = _bascenev1.getsession().sessionglobalsnode
for i in range(size):
curve = _ba.newnode(
curve = _bascenev1.newnode(
'animcurve',
owner=node,
name=(
'Driving ' + str(node) + ' \'' + attr + '\' member ' + str(i)
),
)
globalsnode.connectattr(driver, curve, 'in')
globalsnode.connectattr('time', curve, 'in')
curve.times = [int(mult * time) for time, val in items]
curve.values = [val[i] for time, val in items]
curve.offset = _ba.time(timeformat=TimeFormat.MILLISECONDS) + int(
mult * offset
)
curve.offset = int(_bascenev1.time() * 1000.0) + int(mult * offset)
curve.loop = loop
curve.connectattr('out', combine, 'input' + str(i))
@ -194,10 +153,9 @@ def animate_array(
if not loop:
# (PyCharm seems to think item is a float, not a tuple)
# noinspection PyUnresolvedReferences
_ba.timer(
int(mult * items[-1][0]) + 1000,
_bascenev1.timer(
(int(mult * items[-1][0]) + 1000) / 1000.0,
curve.delete,
timeformat=TimeFormat.MILLISECONDS,
)
combine.connectattr('output', node, attr)
@ -208,10 +166,8 @@ def animate_array(
if not loop:
# (PyCharm seems to think item is a float, not a tuple)
# noinspection PyUnresolvedReferences
_ba.timer(
int(mult * items[-1][0]) + 1000,
combine.delete,
timeformat=TimeFormat.MILLISECONDS,
_bascenev1.timer(
(int(mult * items[-1][0]) + 1000) / 1000.0, combine.delete
)
@ -223,13 +179,14 @@ def show_damage_count(
Category: **Gameplay Functions**
"""
lifespan = 1.0
app = _ba.app
app = babase.app
# FIXME: Should never vary game elements based on local config.
# (connected clients may have differing configs so they won't
# get the intended results).
do_big = app.ui.uiscale is UIScale.SMALL or app.vr_mode
txtnode = _ba.newnode(
assert app.classic is not None
do_big = app.ui_v1.uiscale is babase.UIScale.SMALL or app.vr_mode
txtnode = _bascenev1.newnode(
'text',
attrs={
'text': damage,
@ -242,7 +199,7 @@ def show_damage_count(
},
)
# Translate upward.
tcombine = _ba.newnode('combine', owner=txtnode, attrs={'size': 3})
tcombine = _bascenev1.newnode('combine', owner=txtnode, attrs={'size': 3})
tcombine.connectattr('output', txtnode, 'position')
v_vals = []
pval = 0.0
@ -274,104 +231,7 @@ def show_damage_count(
{i[0] * lifespan: p_start + p_dir * i[1] for i in v_vals},
)
animate(txtnode, 'opacity', {0.7 * lifespan: 1.0, lifespan: 0.0})
_ba.timer(lifespan, txtnode.delete)
def timestring(
timeval: float | int,
centi: bool = True,
timeformat: ba.TimeFormat = TimeFormat.SECONDS,
suppress_format_warning: bool = False,
) -> ba.Lstr:
"""Generate a ba.Lstr for displaying a time value.
Category: **General Utility Functions**
Given a time value, returns a ba.Lstr with:
(hours if > 0 ) : minutes : seconds : (centiseconds if centi=True).
Time 'timeval' is specified in seconds by default, or 'timeformat' can
be set to ba.TimeFormat.MILLISECONDS to accept milliseconds instead.
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 ba._language import Lstr
# Temp sanity check while we transition from milliseconds to seconds
# based time values.
if __debug__:
if not suppress_format_warning:
_ba.time_format_check(timeformat, timeval)
# We operate on milliseconds internally.
if timeformat is TimeFormat.SECONDS:
timeval = int(1000 * timeval)
elif timeformat is TimeFormat.MILLISECONDS:
pass
else:
raise ValueError(f'invalid timeformat: {timeformat}')
if not isinstance(timeval, int):
timeval = int(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)
_bascenev1.timer(lifespan, txtnode.delete)
def cameraflash(duration: float = 999.0) -> None:
@ -384,7 +244,7 @@ def cameraflash(duration: float = 999.0) -> None:
"""
# pylint: disable=too-many-locals
import random
from ba._nodeactor import NodeActor
from bascenev1._nodeactor import NodeActor
x_spread = 10
y_spread = 5
@ -400,11 +260,11 @@ def cameraflash(duration: float = 999.0) -> None:
# Store this on the current activity so we only have one at a time.
# FIXME: Need a type safe way to do this.
activity = _ba.getactivity()
activity = _bascenev1.getactivity()
activity.camera_flash_data = [] # type: ignore
for i in range(6):
light = NodeActor(
_ba.newnode(
_bascenev1.newnode(
'light',
attrs={
'position': (positions[i][0], 0, positions[i][1]),
@ -417,7 +277,7 @@ def cameraflash(duration: float = 999.0) -> None:
)
sval = 1.87
iscale = 1.3
tcombine = _ba.newnode(
tcombine = _bascenev1.newnode(
'combine',
owner=light.node,
attrs={
@ -468,9 +328,8 @@ def cameraflash(duration: float = 999.0) -> None:
loop=True,
offset=times[i],
)
_ba.timer(
(times[i] + random.randint(1, int(duration)) * 40 * sval),
_bascenev1.timer(
(times[i] + random.randint(1, int(duration)) * 40 * sval) / 1000.0,
light.node.delete,
timeformat=TimeFormat.MILLISECONDS,
)
activity.camera_flash_data.append(light) # type: ignore

56
dist/ba_data/python/bascenev1/_hooks.py vendored Normal file
View file

@ -0,0 +1,56 @@
# Released under the MIT License. See LICENSE for details.
#
"""Snippets of code for use by the c++ layer."""
# (most of these are self-explanatory)
# pylint: disable=missing-function-docstring
from __future__ import annotations
from typing import TYPE_CHECKING
import babase
import _bascenev1
if TYPE_CHECKING:
from typing import Any
import bascenev1
def launch_main_menu_session() -> None:
assert babase.app.classic is not None
_bascenev1.new_host_session(babase.app.classic.get_main_menu_session())
def get_player_icon(sessionplayer: bascenev1.SessionPlayer) -> dict[str, Any]:
info = sessionplayer.get_icon_info()
return {
'texture': _bascenev1.gettexture(info['texture']),
'tint_texture': _bascenev1.gettexture(info['tint_texture']),
'tint_color': info['tint_color'],
'tint2_color': info['tint2_color'],
}
def filter_chat_message(msg: str, client_id: int) -> str | None:
"""Intercept/filter chat messages.
Called for all chat messages while hosting.
Messages originating from the host will have clientID -1.
Should filter and return the string to be displayed, or return None
to ignore the message.
"""
del client_id # Unused by default.
return msg
def local_chat_message(msg: str) -> None:
classic = babase.app.classic
assert classic is not None
party_window = (
None if classic.party_window is None else classic.party_window()
)
if party_window is not None:
party_window.on_chat_message(msg)

View file

@ -7,15 +7,16 @@ import copy
import weakref
from typing import TYPE_CHECKING
import _ba
import babase
if TYPE_CHECKING:
from typing import Any
import ba
import bascenev1
class Level:
"""An entry in a ba.Campaign consisting of a name, game type, and settings.
"""An entry in a bascenev1.Campaign.
Category: **Gameplay Classes**
"""
@ -23,7 +24,7 @@ class Level:
def __init__(
self,
name: str,
gametype: type[ba.GameActivity],
gametype: type[bascenev1.GameActivity],
settings: dict,
preview_texture_name: str,
displayname: str | None = None,
@ -33,7 +34,7 @@ class Level:
self._settings = settings
self._preview_texture_name = preview_texture_name
self._displayname = displayname
self._campaign: weakref.ref[ba.Campaign] | None = None
self._campaign: weakref.ref[bascenev1.Campaign] | None = None
self._index: int | None = None
self._score_version_string: str | None = None
@ -60,16 +61,14 @@ class Level:
"""The preview texture name for this Level."""
return self._preview_texture_name
def get_preview_texture(self) -> ba.Texture:
"""Load/return the preview Texture for this Level."""
return _ba.gettexture(self._preview_texture_name)
# def get_preview_texture(self) -> bauiv1.Texture:
# """Load/return the preview Texture for this Level."""
# return _bauiv1.gettexture(self._preview_texture_name)
@property
def displayname(self) -> ba.Lstr:
def displayname(self) -> bascenev1.Lstr:
"""The localized name for this Level."""
from ba import _language
return _language.Lstr(
return babase.Lstr(
translate=(
'coopLevelNames',
self._displayname
@ -82,18 +81,18 @@ class Level:
)
@property
def gametype(self) -> type[ba.GameActivity]:
def gametype(self) -> type[bascenev1.GameActivity]:
"""The type of game used for this Level."""
return self._gametype
@property
def campaign(self) -> ba.Campaign | None:
"""The ba.Campaign this Level is associated with, or None."""
def campaign(self) -> bascenev1.Campaign | None:
"""The baclassic.Campaign this Level is associated with, or None."""
return None if self._campaign is None else self._campaign()
@property
def index(self) -> int:
"""The zero-based index of this Level in its ba.Campaign.
"""The zero-based index of this Level in its baclassic.Campaign.
Access results in a RuntimeError if the Level is not assigned to a
Campaign.
@ -171,8 +170,8 @@ class Level:
assert isinstance(val, dict)
return val
def set_campaign(self, campaign: ba.Campaign, index: int) -> None:
"""For use by ba.Campaign when adding levels to itself.
def set_campaign(self, campaign: bascenev1.Campaign, index: int) -> None:
"""For use by baclassic.Campaign when adding levels to itself.
(internal)"""
self._campaign = weakref.ref(campaign)

View file

@ -5,20 +5,20 @@
from __future__ import annotations
import logging
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING
import _ba
from ba._error import print_exception, print_error, NotFoundError
from ba._gameutils import animate, animate_array
from ba._language import Lstr
from ba._generated.enums import SpecialChar, InputType
from ba._profile import get_player_profile_colors
import babase
import _bascenev1
from bascenev1._profile import get_player_profile_colors
from bascenev1._gameutils import animate, animate_array
if TYPE_CHECKING:
from typing import Any, Sequence
import ba
import bascenev1
MAX_QUICK_CHANGE_COUNT = 30
QUICK_CHANGE_INTERVAL = 0.05
@ -29,34 +29,33 @@ QUICK_CHANGE_RESET_INTERVAL = 1.0
class JoinInfo:
"""Display useful info for joiners."""
def __init__(self, lobby: ba.Lobby):
from ba._nodeactor import NodeActor
from ba._general import WeakCall
def __init__(self, lobby: bascenev1.Lobby):
from bascenev1._nodeactor import NodeActor
self._state = 0
self._press_to_punch: str | ba.Lstr = (
self._press_to_punch: str | bascenev1.Lstr = (
'C'
if _ba.app.iircade_mode
else _ba.charstr(SpecialChar.LEFT_BUTTON)
if babase.app.iircade_mode
else babase.charstr(babase.SpecialChar.LEFT_BUTTON)
)
self._press_to_bomb: str | ba.Lstr = (
self._press_to_bomb: str | bascenev1.Lstr = (
'B'
if _ba.app.iircade_mode
else _ba.charstr(SpecialChar.RIGHT_BUTTON)
if babase.app.iircade_mode
else babase.charstr(babase.SpecialChar.RIGHT_BUTTON)
)
self._joinmsg = Lstr(resource='pressAnyButtonToJoinText')
self._joinmsg = babase.Lstr(resource='pressAnyButtonToJoinText')
can_switch_teams = len(lobby.sessionteams) > 1
# If we have a keyboard, grab keys for punch and pickup.
# FIXME: This of course is only correct on the local device;
# Should change this for net games.
keyboard = _ba.getinputdevice('Keyboard', '#1', doraise=False)
keyboard = _bascenev1.getinputdevice('Keyboard', '#1', doraise=False)
if keyboard is not None:
self._update_for_keyboard(keyboard)
flatness = 1.0 if _ba.app.vr_mode else 0.0
flatness = 1.0 if babase.app.vr_mode else 0.0
self._text = NodeActor(
_ba.newnode(
_bascenev1.newnode(
'text',
attrs={
'position': (0, -40),
@ -70,39 +69,43 @@ class JoinInfo:
)
)
if _ba.app.demo_mode or _ba.app.arcade_mode:
if babase.app.demo_mode or babase.app.arcade_mode:
self._messages = [self._joinmsg]
else:
msg1 = Lstr(
msg1 = babase.Lstr(
resource='pressToSelectProfileText',
subs=[
(
'${BUTTONS}',
_ba.charstr(SpecialChar.UP_ARROW)
babase.charstr(babase.SpecialChar.UP_ARROW)
+ ' '
+ _ba.charstr(SpecialChar.DOWN_ARROW),
+ babase.charstr(babase.SpecialChar.DOWN_ARROW),
)
],
)
msg2 = Lstr(
msg2 = babase.Lstr(
resource='pressToOverrideCharacterText',
subs=[('${BUTTONS}', Lstr(resource='bombBoldText'))],
subs=[('${BUTTONS}', babase.Lstr(resource='bombBoldText'))],
)
msg3 = Lstr(
msg3 = babase.Lstr(
value='${A} < ${B} >',
subs=[('${A}', msg2), ('${B}', self._press_to_bomb)],
)
self._messages = (
(
[
Lstr(
babase.Lstr(
resource='pressToSelectTeamText',
subs=[
(
'${BUTTONS}',
_ba.charstr(SpecialChar.LEFT_ARROW)
babase.charstr(
babase.SpecialChar.LEFT_ARROW
)
+ ' '
+ _ba.charstr(SpecialChar.RIGHT_ARROW),
+ babase.charstr(
babase.SpecialChar.RIGHT_ARROW
),
)
],
)
@ -115,35 +118,44 @@ class JoinInfo:
+ [self._joinmsg]
)
self._timer = _ba.Timer(4.0, WeakCall(self._update), repeat=True)
self._timer = _bascenev1.Timer(
4.0, babase.WeakCall(self._update), repeat=True
)
def _update_for_keyboard(self, keyboard: ba.InputDevice) -> None:
from ba import _input
def _update_for_keyboard(self, keyboard: bascenev1.InputDevice) -> None:
classic = babase.app.classic
assert classic is not None
punch_key = keyboard.get_button_name(
_input.get_device_value(keyboard, 'buttonPunch')
classic.get_input_device_mapped_value(keyboard, 'buttonPunch')
)
self._press_to_punch = Lstr(
self._press_to_punch = babase.Lstr(
resource='orText',
subs=[
('${A}', Lstr(value='\'${K}\'', subs=[('${K}', punch_key)])),
(
'${A}',
babase.Lstr(value='\'${K}\'', subs=[('${K}', punch_key)]),
),
('${B}', self._press_to_punch),
],
)
bomb_key = keyboard.get_button_name(
_input.get_device_value(keyboard, 'buttonBomb')
classic.get_input_device_mapped_value(keyboard, 'buttonBomb')
)
self._press_to_bomb = Lstr(
self._press_to_bomb = babase.Lstr(
resource='orText',
subs=[
('${A}', Lstr(value='\'${K}\'', subs=[('${K}', bomb_key)])),
(
'${A}',
babase.Lstr(value='\'${K}\'', subs=[('${K}', bomb_key)]),
),
('${B}', self._press_to_bomb),
],
)
self._joinmsg = Lstr(
self._joinmsg = babase.Lstr(
value='${A} < ${B} >',
subs=[
('${A}', Lstr(resource='pressPunchToJoinText')),
('${A}', babase.Lstr(resource='pressPunchToJoinText')),
('${B}', self._press_to_punch),
],
)
@ -158,7 +170,7 @@ class JoinInfo:
class PlayerReadyMessage:
"""Tells an object a player has been selected from the given chooser."""
chooser: ba.Chooser
chooser: bascenev1.Chooser
@dataclass
@ -170,32 +182,34 @@ class ChangeMessage:
class Chooser:
"""A character/team selector for a ba.Player.
"""A character/team selector for a bascenev1.Player.
Category: Gameplay Classes
"""
def __del__(self) -> None:
# Just kill off our base node; the rest should go down with it.
if self._text_node:
self._text_node.delete()
def __init__(
self, vpos: float, sessionplayer: _ba.SessionPlayer, lobby: 'Lobby'
self,
vpos: float,
sessionplayer: bascenev1.SessionPlayer,
lobby: 'Lobby',
) -> None:
self._deek_sound = _ba.getsound('deek')
self._click_sound = _ba.getsound('click01')
self._punchsound = _ba.getsound('punch01')
self._swish_sound = _ba.getsound('punchSwish')
self._errorsound = _ba.getsound('error')
self._mask_texture = _ba.gettexture('characterIconMask')
self._deek_sound = _bascenev1.getsound('deek')
self._click_sound = _bascenev1.getsound('click01')
self._punchsound = _bascenev1.getsound('punch01')
self._swish_sound = _bascenev1.getsound('punchSwish')
self._errorsound = _bascenev1.getsound('error')
self._mask_texture = _bascenev1.gettexture('characterIconMask')
self._vpos = vpos
self._lobby = weakref.ref(lobby)
self._sessionplayer = sessionplayer
self._inited = False
self._dead = False
self._text_node: ba.Node | None = None
self._text_node: bascenev1.Node | None = None
self._profilename = ''
self._profilenames: list[str] = []
self._ready: bool = False
@ -203,7 +217,8 @@ class Chooser:
self._last_change: Sequence[float | int] = (0, 0)
self._profiles: dict[str, dict[str, Any]] = {}
app = _ba.app
app = babase.app
assert app.classic is not None
# Load available player profiles either from the local config or
# from the remote device.
@ -224,7 +239,7 @@ class Chooser:
# To calc our random character we pick a random one out of our
# unlocked list and then locate that character's index in the full
# list.
char_index_offset = app.lobby_random_char_index_offset
char_index_offset: int = app.classic.lobby_random_char_index_offset
self._random_character_index = (
sessionplayer.inputdevice.id + char_index_offset
) % len(self._character_names)
@ -234,7 +249,7 @@ class Chooser:
self._profileindex = self._select_initial_profile()
self._profilename = self._profilenames[self._profileindex]
self._text_node = _ba.newnode(
self._text_node = _bascenev1.newnode(
'text',
delegate=self,
attrs={
@ -248,7 +263,7 @@ class Chooser:
},
)
animate(self._text_node, 'scale', {0: 0, 0.1: 1.0})
self.icon = _ba.newnode(
self.icon = _bascenev1.newnode(
'image',
owner=self._text_node,
attrs={
@ -263,7 +278,7 @@ class Chooser:
# Set our initial name to '<choosing player>' in case anyone asks.
self._sessionplayer.setname(
Lstr(resource='choosingPlayerText').evaluate(), real=False
babase.Lstr(resource='choosingPlayerText').evaluate(), real=False
)
# Init these to our rando but they should get switched to the
@ -279,7 +294,8 @@ class Chooser:
self._set_ready(False)
def _select_initial_profile(self) -> int:
app = _ba.app
app = babase.app
assert app.classic is not None
profilenames = self._profilenames
inputdevice = self._sessionplayer.inputdevice
@ -296,9 +312,9 @@ class Chooser:
if (
dprofilename == '__account__'
and not inputdevice.is_remote_client
and app.lobby_account_profile_device_id is None
and app.classic.lobby_account_profile_device_id is None
):
app.lobby_account_profile_device_id = inputdevice.id
app.classic.lobby_account_profile_device_id = inputdevice.id
return profilenames.index(dprofilename)
# We want to mark the first local input-device in the game
@ -308,15 +324,15 @@ class Chooser:
and not inputdevice.is_controller_app
):
if (
app.lobby_account_profile_device_id is None
app.classic.lobby_account_profile_device_id is None
and '__account__' in profilenames
):
app.lobby_account_profile_device_id = inputdevice.id
app.classic.lobby_account_profile_device_id = inputdevice.id
# If this is the designated account-profile-device, try to default
# to the account profile.
if (
inputdevice.id == app.lobby_account_profile_device_id
inputdevice.id == app.classic.lobby_account_profile_device_id
and '__account__' in profilenames
):
return profilenames.index('__account__')
@ -335,24 +351,24 @@ class Chooser:
# Cycle through our non-random profiles once; after
# that, everyone gets random.
while app.lobby_random_profile_index < len(
while app.classic.lobby_random_profile_index < len(
profilenames
) and profilenames[app.lobby_random_profile_index] in (
) and profilenames[app.classic.lobby_random_profile_index] in (
'_random',
'__account__',
'_edit',
):
app.lobby_random_profile_index += 1
if app.lobby_random_profile_index < len(profilenames):
profileindex = app.lobby_random_profile_index
app.lobby_random_profile_index += 1
app.classic.lobby_random_profile_index += 1
if app.classic.lobby_random_profile_index < len(profilenames):
profileindex: int = app.classic.lobby_random_profile_index
app.classic.lobby_random_profile_index += 1
return profileindex
assert '_random' in profilenames
return profilenames.index('_random')
@property
def sessionplayer(self) -> ba.SessionPlayer:
"""The ba.SessionPlayer associated with this chooser."""
def sessionplayer(self) -> bascenev1.SessionPlayer:
"""The bascenev1.SessionPlayer associated with this chooser."""
return self._sessionplayer
@property
@ -369,24 +385,25 @@ class Chooser:
self._dead = val
@property
def sessionteam(self) -> ba.SessionTeam:
"""Return this chooser's currently selected ba.SessionTeam."""
def sessionteam(self) -> bascenev1.SessionTeam:
"""Return this chooser's currently selected bascenev1.SessionTeam."""
return self.lobby.sessionteams[self._selected_team_index]
@property
def lobby(self) -> ba.Lobby:
"""The chooser's ba.Lobby."""
def lobby(self) -> bascenev1.Lobby:
"""The chooser's baclassic.Lobby."""
lobby = self._lobby()
if lobby is None:
raise NotFoundError('Lobby does not exist.')
raise babase.NotFoundError('Lobby does not exist.')
return lobby
def get_lobby(self) -> ba.Lobby | None:
def get_lobby(self) -> bascenev1.Lobby | None:
"""Return this chooser's lobby if it still exists; otherwise None."""
return self._lobby()
def update_from_profile(self) -> None:
"""Set character/colors based on the current profile."""
assert babase.app.classic is not None
self._profilename = self._profilenames[self._profileindex]
if self._profilename == '_edit':
pass
@ -407,7 +424,7 @@ class Chooser:
# so no exploit opportunities)
if (
character not in self._character_names
and character in _ba.app.spaz_appearances
and character in babase.app.classic.spaz_appearances
):
self._character_names.append(character)
self._character_index = self._character_names.index(character)
@ -419,9 +436,9 @@ class Chooser:
def reload_profiles(self) -> None:
"""Reload all player profiles."""
from ba._general import json_prep
app = _ba.app
app = babase.app
assert app.classic is not None
# Re-construct our profile index and other stuff since the profile
# list might have changed.
@ -448,11 +465,14 @@ class Chooser:
# (non-unicode/non-json) version.
# Make sure they conform to our standards
# (unicode strings, no tuples, etc)
self._profiles = json_prep(self._profiles)
self._profiles = babase.json_prep(self._profiles)
# Filter out any characters we're unaware of.
for profile in list(self._profiles.items()):
if profile[1].get('character', '') not in app.spaz_appearances:
if (
profile[1].get('character', '')
not in app.classic.spaz_appearances
):
profile[1]['character'] = 'Spaz'
# Add in a random one so we're ok even if there's no user profiles.
@ -523,20 +543,20 @@ class Chooser:
try:
name = self._sessionplayer.inputdevice.get_default_player_name()
except Exception:
print_exception('Error getting _random chooser name.')
logging.exception('Error getting _random chooser name.')
name = 'Invalid'
clamp = not full
elif name == '__account__':
try:
name = self._sessionplayer.inputdevice.get_v1_account_name(full)
except Exception:
print_exception('Error getting account name for chooser.')
logging.exception('Error getting account name for chooser.')
name = 'Invalid'
clamp = not full
elif name == '_edit':
# Explicitly flattening this to a str; it's only relevant on
# the host so that's ok.
name = Lstr(
name = babase.Lstr(
resource='createEditPlayerText',
fallback_resource='editProfileWindow.titleNewText',
).evaluate()
@ -549,11 +569,11 @@ class Chooser:
icon = (
self._profiles[name_raw]['icon']
if 'icon' in self._profiles[name_raw]
else _ba.charstr(SpecialChar.LOGO)
else babase.charstr(babase.SpecialChar.LOGO)
)
name = icon + name
except Exception:
print_exception('Error applying global icon.')
logging.exception('Error applying global icon.')
else:
# We now clamp non-full versions of names so there's at
# least some hope of reading them in-game.
@ -566,49 +586,54 @@ class Chooser:
def _set_ready(self, ready: bool) -> None:
# pylint: disable=cyclic-import
from bastd.ui.profile import browser as pbrowser
from ba._general import Call
classic = babase.app.classic
assert classic is not None
profilename = self._profilenames[self._profileindex]
# Handle '_edit' as a special case.
if profilename == '_edit' and ready:
with _ba.Context('ui'):
pbrowser.ProfileBrowserWindow(in_main_menu=False)
with babase.ContextRef.empty():
classic.profile_browser_window(in_main_menu=False)
# Give their input-device UI ownership too
# (prevent someone else from snatching it in crowded games)
_ba.set_ui_input_device(self._sessionplayer.inputdevice)
babase.set_ui_input_device(self._sessionplayer.inputdevice.id)
return
if not ready:
self._sessionplayer.assigninput(
InputType.LEFT_PRESS,
Call(self.handlemessage, ChangeMessage('team', -1)),
babase.InputType.LEFT_PRESS,
babase.Call(self.handlemessage, ChangeMessage('team', -1)),
)
self._sessionplayer.assigninput(
InputType.RIGHT_PRESS,
Call(self.handlemessage, ChangeMessage('team', 1)),
babase.InputType.RIGHT_PRESS,
babase.Call(self.handlemessage, ChangeMessage('team', 1)),
)
self._sessionplayer.assigninput(
InputType.BOMB_PRESS,
Call(self.handlemessage, ChangeMessage('character', 1)),
babase.InputType.BOMB_PRESS,
babase.Call(self.handlemessage, ChangeMessage('character', 1)),
)
self._sessionplayer.assigninput(
InputType.UP_PRESS,
Call(self.handlemessage, ChangeMessage('profileindex', -1)),
babase.InputType.UP_PRESS,
babase.Call(
self.handlemessage, ChangeMessage('profileindex', -1)
),
)
self._sessionplayer.assigninput(
InputType.DOWN_PRESS,
Call(self.handlemessage, ChangeMessage('profileindex', 1)),
babase.InputType.DOWN_PRESS,
babase.Call(
self.handlemessage, ChangeMessage('profileindex', 1)
),
)
self._sessionplayer.assigninput(
(
InputType.JUMP_PRESS,
InputType.PICK_UP_PRESS,
InputType.PUNCH_PRESS,
babase.InputType.JUMP_PRESS,
babase.InputType.PICK_UP_PRESS,
babase.InputType.PUNCH_PRESS,
),
Call(self.handlemessage, ChangeMessage('ready', 1)),
babase.Call(self.handlemessage, ChangeMessage('ready', 1)),
)
self._ready = False
self._update_text()
@ -616,31 +641,31 @@ class Chooser:
else:
self._sessionplayer.assigninput(
(
InputType.LEFT_PRESS,
InputType.RIGHT_PRESS,
InputType.UP_PRESS,
InputType.DOWN_PRESS,
InputType.JUMP_PRESS,
InputType.BOMB_PRESS,
InputType.PICK_UP_PRESS,
babase.InputType.LEFT_PRESS,
babase.InputType.RIGHT_PRESS,
babase.InputType.UP_PRESS,
babase.InputType.DOWN_PRESS,
babase.InputType.JUMP_PRESS,
babase.InputType.BOMB_PRESS,
babase.InputType.PICK_UP_PRESS,
),
self._do_nothing,
)
self._sessionplayer.assigninput(
(
InputType.JUMP_PRESS,
InputType.BOMB_PRESS,
InputType.PICK_UP_PRESS,
InputType.PUNCH_PRESS,
babase.InputType.JUMP_PRESS,
babase.InputType.BOMB_PRESS,
babase.InputType.PICK_UP_PRESS,
babase.InputType.PUNCH_PRESS,
),
Call(self.handlemessage, ChangeMessage('ready', 0)),
babase.Call(self.handlemessage, ChangeMessage('ready', 0)),
)
# Store the last profile picked by this input for reuse.
input_device = self._sessionplayer.inputdevice
name = input_device.name
unique_id = input_device.unique_identifier
device_profiles = _ba.app.config.setdefault(
device_profiles = babase.app.config.setdefault(
'Default Player Profiles', {}
)
@ -656,7 +681,7 @@ class Chooser:
del device_profiles[profilekey]
else:
device_profiles[profilekey] = profilename
_ba.app.config.commit()
babase.app.config.commit()
# Set this player's short and full name.
self._sessionplayer.setname(
@ -666,7 +691,7 @@ class Chooser:
self._update_text()
# Inform the session that this player is ready.
_ba.getsession().handlemessage(PlayerReadyMessage(self))
_bascenev1.getsession().handlemessage(PlayerReadyMessage(self))
def _handle_ready_msg(self, ready: bool) -> None:
force_team_switch = False
@ -674,11 +699,10 @@ class Chooser:
# Team auto-balance kicks us to another team if we try to
# join the team with the most players.
if not self._ready:
if _ba.app.config.get('Auto Balance Teams', False):
if babase.app.config.get('Auto Balance Teams', False):
lobby = self.lobby
sessionteams = lobby.sessionteams
if len(sessionteams) > 1:
# First, calc how many players are on each team
# ..we need to count both active players and
# choosers that have been marked as ready.
@ -704,20 +728,22 @@ class Chooser:
# Either force switch teams, or actually for realsies do the set-ready.
if force_team_switch:
_ba.playsound(self._errorsound)
self._errorsound.play()
self.handlemessage(ChangeMessage('team', 1))
else:
_ba.playsound(self._punchsound)
self._punchsound.play()
self._set_ready(ready)
# TODO: should handle this at the engine layer so this is unnecessary.
def _handle_repeat_message_attack(self) -> None:
now = _ba.time()
now = babase.apptime()
count = self._last_change[1]
if now - self._last_change[0] < QUICK_CHANGE_INTERVAL:
count += 1
if count > MAX_QUICK_CHANGE_COUNT:
_ba.disconnect_client(self._sessionplayer.inputdevice.client_id)
_bascenev1.disconnect_client(
self._sessionplayer.inputdevice.client_id
)
elif now - self._last_change[0] > QUICK_CHANGE_RESET_INTERVAL:
count = 0
self._last_change = (now, count)
@ -730,17 +756,17 @@ class Chooser:
# If we've been removed from the lobby, ignore this stuff.
if self._dead:
print_error('chooser got ChangeMessage after dying')
logging.error('chooser got ChangeMessage after dying')
return
if not self._text_node:
print_error('got ChangeMessage after nodes died')
logging.error('got ChangeMessage after nodes died')
return
if msg.what == 'team':
sessionteams = self.lobby.sessionteams
if len(sessionteams) > 1:
_ba.playsound(self._swish_sound)
self._swish_sound.play()
self._selected_team_index = (
self._selected_team_index + msg.value
) % len(sessionteams)
@ -750,22 +776,20 @@ class Chooser:
elif msg.what == 'profileindex':
if len(self._profilenames) == 1:
# This should be pretty hard to hit now with
# automatic local accounts.
_ba.playsound(_ba.getsound('error'))
_bascenev1.getsound('error').play()
else:
# Pick the next player profile and assign our name
# and character based on that.
_ba.playsound(self._deek_sound)
self._deek_sound.play()
self._profileindex = (self._profileindex + msg.value) % len(
self._profilenames
)
self.update_from_profile()
elif msg.what == 'character':
_ba.playsound(self._click_sound)
self._click_sound.play()
# update our index in our local list of characters
self._character_index = (
self._character_index + msg.value
@ -779,21 +803,23 @@ class Chooser:
def _update_text(self) -> None:
assert self._text_node is not None
if self._ready:
# Once we're ready, we've saved the name, so lets ask the system
# for it so we get appended numbers and stuff.
text = Lstr(value=self._sessionplayer.getname(full=True))
text = Lstr(
text = babase.Lstr(value=self._sessionplayer.getname(full=True))
text = babase.Lstr(
value='${A} (${B})',
subs=[('${A}', text), ('${B}', Lstr(resource='readyText'))],
subs=[
('${A}', text),
('${B}', babase.Lstr(resource='readyText')),
],
)
else:
text = Lstr(value=self._getname(full=True))
text = babase.Lstr(value=self._getname(full=True))
can_switch_teams = len(self.lobby.sessionteams) > 1
# Flash as we're coming in.
fin_color = _ba.safecolor(self.get_color()) + (1,)
fin_color = babase.safecolor(self.get_color()) + (1,)
if not self._inited:
animate_array(
self._text_node,
@ -802,7 +828,6 @@ class Chooser:
{0.15: fin_color, 0.25: (2, 2, 2, 1), 0.35: fin_color},
)
else:
# Blend if we're in teams mode; switch instantly otherwise.
if can_switch_teams:
animate_array(
@ -839,7 +864,6 @@ class Chooser:
if self.lobby.use_team_colors:
for i, sessionteam in enumerate(self.lobby.sessionteams):
if i != self._selected_team_index:
# Find the dominant component of this sessionteam's color
# and adjust ours so that the component is
# not super-dominant.
@ -861,14 +885,15 @@ class Chooser:
highlight[(max_index + 2) % 3] += diff * 0.2
return highlight
def getplayer(self) -> ba.SessionPlayer:
def getplayer(self) -> bascenev1.SessionPlayer:
"""Return the player associated with this chooser."""
return self._sessionplayer
def _update_icon(self) -> None:
assert babase.app.classic is not None
if self._profilenames[self._profileindex] == '_edit':
tex = _ba.gettexture('black')
tint_tex = _ba.gettexture('black')
tex = _bascenev1.gettexture('black')
tint_tex = _bascenev1.gettexture('black')
self.icon.color = (1, 1, 1)
self.icon.texture = tex
self.icon.tint_texture = tint_tex
@ -876,19 +901,19 @@ class Chooser:
return
try:
tex_name = _ba.app.spaz_appearances[
tex_name = babase.app.classic.spaz_appearances[
self._character_names[self._character_index]
].icon_texture
tint_tex_name = _ba.app.spaz_appearances[
tint_tex_name = babase.app.classic.spaz_appearances[
self._character_names[self._character_index]
].icon_mask_texture
except Exception:
print_exception('Error updating char icon list')
logging.exception('Error updating char icon list')
tex_name = 'neoSpazIcon'
tint_tex_name = 'neoSpazIconColorMask'
tex = _ba.gettexture(tex_name)
tint_tex = _ba.gettexture(tint_tex_name)
tex = _bascenev1.gettexture(tex_name)
tint_tex = _bascenev1.gettexture(tint_tex_name)
self.icon.color = (1, 1, 1)
self.icon.texture = tex
@ -921,13 +946,12 @@ class Chooser:
class Lobby:
"""Container for ba.Choosers.
"""Container for baclassic.Choosers.
Category: Gameplay Classes
"""
def __del__(self) -> None:
# Reset any players that still have a chooser in us.
# (should allow the choosers to die).
sessionplayers = [
@ -937,10 +961,10 @@ class Lobby:
sessionplayer.resetinput()
def __init__(self) -> None:
from ba._team import SessionTeam
from ba._coopsession import CoopSession
from bascenev1._team import SessionTeam
from bascenev1._coopsession import CoopSession
session = _ba.getsession()
session = _bascenev1.getsession()
self._use_team_colors = session.use_team_colors
if session.use_teams:
self._sessionteams = [
@ -976,8 +1000,8 @@ class Lobby:
return self._use_team_colors
@property
def sessionteams(self) -> list[ba.SessionTeam]:
"""ba.SessionTeams available in this lobby."""
def sessionteams(self) -> list[bascenev1.SessionTeam]:
"""bascenev1.SessionTeams available in this lobby."""
allteams = []
for tref in self._sessionteams:
team = tref()
@ -1000,7 +1024,9 @@ class Lobby:
def reload_profiles(self) -> None:
"""Reload available player profiles."""
# pylint: disable=cyclic-import
from bastd.actor.spazappearance import get_appearances
from bascenev1lib.actor.spazappearance import get_appearances
assert babase.app.classic is not None
# We may have gained or lost character names if the user
# bought something; reload these too.
@ -1008,13 +1034,13 @@ class Lobby:
self.character_names_local_unlocked.sort(key=lambda x: x.lower())
# Do any overall prep we need to such as creating account profile.
_ba.app.accounts_v1.ensure_have_account_player_profile()
babase.app.classic.accounts.ensure_have_account_player_profile()
for chooser in self.choosers:
try:
chooser.reload_profiles()
chooser.update_from_profile()
except Exception:
print_exception('Error reloading profiles.')
logging.exception('Error reloading profiles.')
def update_positions(self) -> None:
"""Update positions for all choosers."""
@ -1028,7 +1054,7 @@ class Lobby:
"""Return whether all choosers are marked ready."""
return all(chooser.ready for chooser in self.choosers)
def add_chooser(self, sessionplayer: ba.SessionPlayer) -> None:
def add_chooser(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""Add a chooser to the lobby for the provided player."""
self.choosers.append(
Chooser(vpos=self._vpos, sessionplayer=sessionplayer, lobby=self)
@ -1038,7 +1064,7 @@ class Lobby:
)
self._vpos -= 48
def remove_chooser(self, player: ba.SessionPlayer) -> None:
def remove_chooser(self, player: bascenev1.SessionPlayer) -> None:
"""Remove a single player's chooser; does not kick them.
This is used when a player enters the game and no longer
@ -1056,9 +1082,9 @@ class Lobby:
self.choosers.remove(chooser)
break
if not found:
print_error(f'remove_chooser did not find player {player}')
logging.exception('remove_chooser did not find player %s.', player)
elif chooser in self.choosers:
print_error(f'chooser remains after removal for {player}')
logging.exception('chooser remains after removal for %s.', player)
self.update_positions()
def remove_all_choosers(self) -> None:

View file

@ -6,26 +6,15 @@ from __future__ import annotations
import random
from typing import TYPE_CHECKING
import _ba
from ba import _math
from ba._actor import Actor
import babase
import _bascenev1
from bascenev1._actor import Actor
if TYPE_CHECKING:
from typing import Sequence, Any
import ba
def preload_map_preview_media() -> None:
"""Preload media needed for map preview UIs.
Category: **Asset Functions**
"""
_ba.getmodel('level_select_button_opaque')
_ba.getmodel('level_select_button_transparent')
for maptype in list(_ba.app.maps.values()):
map_tex_name = maptype.get_preview_texture_name()
if map_tex_name is not None:
_ba.gettexture(map_tex_name)
import bascenev1
def get_filtered_map_name(name: str) -> str:
@ -43,80 +32,26 @@ def get_filtered_map_name(name: str) -> str:
return name
def get_map_display_string(name: str) -> ba.Lstr:
"""Return a ba.Lstr for displaying a given map\'s name.
def get_map_display_string(name: str) -> babase.Lstr:
"""Return a babase.Lstr for displaying a given map\'s name.
Category: **Asset Functions**
"""
from ba import _language
return _language.Lstr(translate=('mapsNames', name))
return babase.Lstr(translate=('mapsNames', name))
def getmaps(playtype: str) -> list[str]:
"""Return a list of ba.Map types supporting a playtype str.
Category: **Asset Functions**
Maps supporting a given playtype must provide a particular set of
features and lend themselves to a certain style of play.
Play Types:
'melee'
General fighting map.
Has one or more 'spawn' locations.
'team_flag'
For games such as Capture The Flag where each team spawns by a flag.
Has two or more 'spawn' locations, each with a corresponding 'flag'
location (based on index).
'single_flag'
For games such as King of the Hill or Keep Away where multiple teams
are fighting over a single flag.
Has two or more 'spawn' locations and 1 'flag_default' location.
'conquest'
For games such as Conquest where flags are spread throughout the map
- has 2+ 'flag' locations, 2+ 'spawn_by_flag' locations.
'king_of_the_hill' - has 2+ 'spawn' locations, 1+ 'flag_default' locations,
and 1+ 'powerup_spawn' locations
'hockey'
For hockey games.
Has two 'goal' locations, corresponding 'spawn' locations, and one
'flag_default' location (for where puck spawns)
'football'
For football games.
Has two 'goal' locations, corresponding 'spawn' locations, and one
'flag_default' location (for where flag/ball/etc. spawns)
'race'
For racing games where players much touch each region in order.
Has two or more 'race_point' locations.
"""
return sorted(
key
for key, val in _ba.app.maps.items()
if playtype in val.get_play_types()
)
def get_map_class(name: str) -> type[ba.Map]:
def get_map_class(name: str) -> type[Map]:
"""Return a map type given a name.
Category: **Asset Functions**
"""
assert babase.app.classic is not None
name = get_filtered_map_name(name)
try:
return _ba.app.maps[name]
mapclass: type[Map] = babase.app.classic.maps[name]
return mapclass
except KeyError:
from ba import _error
raise _error.NotFoundError(f"Map not found: '{name}'") from None
raise babase.NotFoundError(f"Map not found: '{name}'") from None
class Map(Actor):
@ -137,11 +72,12 @@ class Map(Actor):
"""Preload map media.
This runs the class's on_preload() method as needed to prep it to run.
Preloading should generally be done in a ba.Activity's __init__ method.
Note that this is a classmethod since it is not operate on map
instances but rather on the class itself before instances are made
Preloading should generally be done in a bascenev1.Activity's
__init__ method. Note that this is a classmethod since it is not
operate on map instances but rather on the class itself before
instances are made
"""
activity = _ba.getactivity()
activity = _bascenev1.getactivity()
if cls not in activity.preloads:
activity.preloads[cls] = cls.on_preload()
@ -169,7 +105,7 @@ class Map(Actor):
return cls.name
@classmethod
def get_music_type(cls) -> ba.MusicType | None:
def get_music_type(cls) -> bascenev1.MusicType | None:
"""Return a music-type string that should be played on this map.
If None is returned, default music will be used.
@ -182,18 +118,17 @@ class Map(Actor):
"""Instantiate a map."""
super().__init__()
# This is expected to always be a ba.Node object (whether valid or not)
# should be set to something meaningful by child classes.
self.node: _ba.Node | None = None
# This is expected to always be a bascenev1.Node object
# (whether valid or not) should be set to something meaningful
# by child classes.
self.node: _bascenev1.Node | None = None
# Make our class' preload-data available to us
# (and instruct the user if we weren't preloaded properly).
try:
self.preloaddata = _ba.getactivity().preloads[type(self)]
self.preloaddata = _bascenev1.getactivity().preloads[type(self)]
except Exception as exc:
from ba import _error
raise _error.NotFoundError(
raise babase.NotFoundError(
'Preload data not found for '
+ str(type(self))
+ '; make sure to call the type\'s preload()'
@ -201,11 +136,7 @@ class Map(Actor):
) from exc
# Set various globals.
gnode = _ba.getactivity().globalsnode
import ba
import custom_hooks
custom_hooks.on_map_init()
gnode = _bascenev1.getactivity().globalsnode
# Set area-of-interest bounds.
aoi_bounds = self.get_def_bound_box('area_of_interest_bounds')
@ -219,7 +150,7 @@ class Map(Actor):
if map_bounds is None:
print('WARNING: no "map_bounds" found for map:', self.getname())
map_bounds = (-30, -10, -30, 30, 100, 30)
_ba.set_map_bounds(map_bounds)
_bascenev1.set_map_bounds(map_bounds)
# Set shadow ranges.
try:
@ -288,7 +219,9 @@ class Map(Actor):
len(self.ffa_spawn_points)
)
def is_point_near_edge(self, point: ba.Vec3, running: bool = False) -> bool:
def is_point_near_edge(
self, point: babase.Vec3, running: bool = False
) -> bool:
"""Return whether the provided point is near an edge of the map.
Simple bot logic uses this call to determine if they
@ -322,7 +255,7 @@ class Map(Actor):
return (
None
if val is None
else _math.vec3validate(val)
else babase.vec3validate(val)
if __debug__
else val
)
@ -360,12 +293,12 @@ class Map(Actor):
return pnt
def get_ffa_start_position(
self, players: Sequence[ba.Player]
self, players: Sequence[bascenev1.Player]
) -> Sequence[float]:
"""Return a random starting position in one of the FFA spawn areas.
If a list of ba.Player-s is provided; the returned points will be
as far from these players as possible.
If a list of bascenev1.Player-s is provided; the returned points
will be as far from these players as possible.
"""
# Get positions for existing players.
@ -396,7 +329,7 @@ class Map(Actor):
farthestpt_dist = -1.0
farthestpt = None
for _i in range(10):
testpt = _ba.Vec3(_getpt())
testpt = babase.Vec3(_getpt())
closest_player_dist = 9999.0
for ppt in player_pts:
dist = (ppt - testpt).length()
@ -424,7 +357,7 @@ class Map(Actor):
return bool(self.node)
def handlemessage(self, msg: Any) -> Any:
from ba import _messages
from bascenev1 import _messages
if isinstance(msg, _messages.DieMessage):
if self.node:
@ -436,6 +369,7 @@ class Map(Actor):
def register_map(maptype: type[Map]) -> None:
"""Register a map class with the game."""
if maptype.name in _ba.app.maps:
assert babase.app.classic is not None
if maptype.name in babase.app.classic.maps:
raise RuntimeError('map "' + maptype.name + '" already registered')
_ba.app.maps[maptype.name] = maptype
babase.app.classic.maps[maptype.name] = maptype

View file

@ -8,11 +8,12 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeVar
from enum import Enum
import _ba
import babase
if TYPE_CHECKING:
from typing import Sequence, Any
import ba
import bascenev1
class _UnhandledType:
@ -53,7 +54,7 @@ class DieMessage:
Category: **Message Classes**
Most ba.Actor-s respond to this.
Most bascenev1.Actor-s respond to this.
"""
immediate: bool = False
@ -66,13 +67,11 @@ class DieMessage:
"""The particular reason for death."""
# pylint: disable=invalid-name
PlayerType = TypeVar('PlayerType', bound='ba.Player')
# pylint: enable=invalid-name
PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
class PlayerDiedMessage:
"""A message saying a ba.Player has died.
"""A message saying a bascenev1.Player has died.
Category: **Message Classes**
"""
@ -81,15 +80,15 @@ class PlayerDiedMessage:
"""If True, the player was killed;
If False, they left the game or the round ended."""
how: ba.DeathType
how: DeathType
"""The particular type of death."""
def __init__(
self,
player: ba.Player,
player: bascenev1.Player,
was_killed: bool,
killerplayer: ba.Player | None,
how: ba.DeathType,
killerplayer: bascenev1.Player | None,
how: DeathType,
):
"""Instantiate a message with the given values."""
@ -103,18 +102,16 @@ class PlayerDiedMessage:
self.killed = was_killed
self.how = how
def getkillerplayer(
self, playertype: type[PlayerType]
) -> PlayerType | None:
"""Return the ba.Player responsible for the killing, if any.
def getkillerplayer(self, playertype: type[PlayerT]) -> PlayerT | None:
"""Return the bascenev1.Player responsible for the killing, if any.
Pass the Player type being used by the current game.
"""
assert isinstance(self._killerplayer, (playertype, type(None)))
return self._killerplayer
def getplayer(self, playertype: type[PlayerType]) -> PlayerType:
"""Return the ba.Player that died.
def getplayer(self, playertype: type[PlayerT]) -> PlayerT:
"""Return the bascenev1.Player that died.
The type of player for the current activity should be passed so that
the type-checker properly identifies the returned value as one.
@ -151,8 +148,8 @@ class PickUpMessage:
Category: **Message Classes**
"""
node: ba.Node
"""The ba.Node that is getting picked up."""
node: bascenev1.Node
"""The bascenev1.Node that is getting picked up."""
@dataclass
@ -170,8 +167,8 @@ class PickedUpMessage:
Category: **Message Classes**
"""
node: ba.Node
"""The ba.Node doing the picking up."""
node: bascenev1.Node
"""The bascenev1.Node doing the picking up."""
@dataclass
@ -181,8 +178,8 @@ class DroppedMessage:
Category: **Message Classes**
"""
node: ba.Node
"""The ba.Node doing the dropping."""
node: bascenev1.Node
"""The bascenev1.Node doing the dropping."""
@dataclass
@ -210,7 +207,7 @@ class FreezeMessage:
Category: **Message Classes**
As seen in the effects of an ice ba.Bomb.
As seen in the effects of an ice bascenev1.Bomb.
"""
@ -244,13 +241,13 @@ class HitMessage:
def __init__(
self,
srcnode: ba.Node | None = None,
srcnode: bascenev1.Node | None = None,
pos: Sequence[float] | None = None,
velocity: Sequence[float] | None = None,
magnitude: float = 1.0,
velocity_magnitude: float = 0.0,
radius: float = 1.0,
source_player: ba.Player | None = None,
source_player: bascenev1.Player | None = None,
kick_back: float = 1.0,
flat_damage: float | None = None,
hit_type: str = 'generic',
@ -260,8 +257,8 @@ class HitMessage:
"""Instantiate a message with given values."""
self.srcnode = srcnode
self.pos = pos if pos is not None else _ba.Vec3()
self.velocity = velocity if velocity is not None else _ba.Vec3()
self.pos = pos if pos is not None else babase.Vec3()
self.velocity = velocity if velocity is not None else babase.Vec3()
self.magnitude = magnitude
self.velocity_magnitude = velocity_magnitude
self.radius = radius
@ -277,9 +274,7 @@ class HitMessage:
force_direction if force_direction is not None else velocity
)
def get_source_player(
self, playertype: type[PlayerType]
) -> PlayerType | None:
def get_source_player(self, playertype: type[PlayerT]) -> PlayerT | None:
"""Return the source-player if one exists and is the provided type."""
player: Any = self._source_player

View file

@ -5,27 +5,30 @@ from __future__ import annotations
import copy
import random
import logging
from typing import TYPE_CHECKING
import _ba
from ba._session import Session
from ba._error import NotFoundError, print_error
import babase
import _bascenev1
from bascenev1._session import Session
if TYPE_CHECKING:
from typing import Any, Sequence
import ba
import bascenev1
DEFAULT_TEAM_COLORS = ((0.1, 0.25, 1.0), (1.0, 0.25, 0.2))
DEFAULT_TEAM_NAMES = ('Blue', 'Red')
class MultiTeamSession(Session):
"""Common base class for ba.DualTeamSession and ba.FreeForAllSession.
"""Common base for DualTeamSession and FreeForAllSession.
Category: **Gameplay Classes**
Free-for-all-mode is essentially just teams-mode with each ba.Player having
their own ba.Team, so there is much overlap in functionality.
Free-for-all-mode is essentially just teams-mode with each
bascenev1.Player having their own bascenev1.Team, so there is much
overlap in functionality.
"""
# These should be overridden.
@ -34,12 +37,14 @@ class MultiTeamSession(Session):
_playlists_var = 'UNSET Playlists'
def __init__(self) -> None:
"""Set up playlists and launches a ba.Activity to accept joiners."""
"""Set up playlists & launch a bascenev1.Activity to accept joiners."""
# pylint: disable=cyclic-import
from ba import _playlist
from bastd.activity.multiteamjoin import MultiTeamJoinActivity
from bascenev1 import _playlist
from bascenev1lib.activity.multiteamjoin import MultiTeamJoinActivity
app = _ba.app
app = babase.app
classic = app.classic
assert classic is not None
cfg = app.config
if self.use_teams:
@ -50,7 +55,7 @@ class MultiTeamSession(Session):
team_colors = None
# print('FIXME: TEAM BASE SESSION WOULD CALC DEPS.')
depsets: Sequence[ba.DependencySet] = []
depsets: Sequence[bascenev1.DependencySet] = []
super().__init__(
depsets,
@ -60,17 +65,21 @@ class MultiTeamSession(Session):
max_players=self.get_max_players(),
)
self._series_length = app.teams_series_length
self._ffa_series_length = app.ffa_series_length
self._series_length: int = classic.teams_series_length
self._ffa_series_length: int = classic.ffa_series_length
show_tutorial = cfg.get('Show Tutorial', True)
self._tutorial_activity_instance: ba.Activity | None
self._tutorial_activity_instance: bascenev1.Activity | None
if show_tutorial:
from bastd.tutorial import TutorialActivity
from bascenev1lib.tutorial import TutorialActivity
tutorial_activity = TutorialActivity
# Get this loading.
self._tutorial_activity_instance = _ba.newactivity(TutorialActivity)
self._tutorial_activity_instance = _bascenev1.newactivity(
tutorial_activity
)
else:
self._tutorial_activity_instance = None
@ -88,7 +97,6 @@ class MultiTeamSession(Session):
self._playlist_name != '__default__'
and self._playlist_name in playlists
):
# Make sure to copy this, as we muck with it in place once we've
# got it and we don't want that to affect our config.
playlist = copy.deepcopy(playlists[self._playlist_name])
@ -105,16 +113,9 @@ class MultiTeamSession(Session):
add_resolved_type=True,
name='default teams' if self.use_teams else 'default ffa',
)
default_playlist_resolved = _playlist.filter_playlist(
_playlist.get_default_teams_playlist(),
sessiontype=type(self),
add_resolved_type=True,
name='default teams' if self.use_teams else 'default ffa',
)
if not playlist_resolved:
print("PLAYLIST CONTAINS NO VALID GAMES , FALLING BACK TO DEFAULT TEAM PLAYLIST")
playlist_resolved = default_playlist_resolved
# raise RuntimeError('Playlist contains no valid games.')
raise RuntimeError('Playlist contains no valid games.')
self._playlist = ShuffleList(
playlist_resolved, shuffle=self._playlist_randomize
@ -123,7 +124,7 @@ class MultiTeamSession(Session):
# Get a game on deck ready to go.
self._current_game_spec: dict[str, Any] | None = None
self._next_game_spec: dict[str, Any] = self._playlist.pull_next()
self._next_game: type[ba.GameActivity] = self._next_game_spec[
self._next_game: type[bascenev1.GameActivity] = self._next_game_spec[
'resolved_type'
]
@ -132,7 +133,7 @@ class MultiTeamSession(Session):
self._instantiate_next_game()
# Start in our custom join screen.
self.setactivity(_ba.newactivity(MultiTeamJoinActivity))
self.setactivity(_bascenev1.newactivity(MultiTeamJoinActivity))
def get_ffa_series_length(self) -> int:
"""Return free-for-all series length."""
@ -142,10 +143,10 @@ class MultiTeamSession(Session):
"""Return teams series length."""
return self._series_length
def get_next_game_description(self) -> ba.Lstr:
def get_next_game_description(self) -> babase.Lstr:
"""Returns a description of the next game on deck."""
# pylint: disable=cyclic-import
from ba._gameactivity import GameActivity
from bascenev1._gameactivity import GameActivity
gametype: type[GameActivity] = self._next_game_spec['resolved_type']
assert issubclass(gametype, GameActivity)
@ -155,28 +156,30 @@ class MultiTeamSession(Session):
"""Returns which game in the series is currently being played."""
return self._game_number
def on_team_join(self, team: ba.SessionTeam) -> None:
def on_team_join(self, team: bascenev1.SessionTeam) -> None:
team.customdata['previous_score'] = team.customdata['score'] = 0
def get_max_players(self) -> int:
"""Return max number of ba.Player-s allowed to join the game at once"""
"""Return max number of Players allowed to join the game at once."""
if self.use_teams:
return _ba.app.config.get('Team Game Max Players', 8)
return _ba.app.config.get('Free-for-All Max Players', 8)
return babase.app.config.get('Team Game Max Players', 8)
return babase.app.config.get('Free-for-All Max Players', 8)
def _instantiate_next_game(self) -> None:
self._next_game_instance = _ba.newactivity(
self._next_game_instance = _bascenev1.newactivity(
self._next_game_spec['resolved_type'],
self._next_game_spec['settings'],
)
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
def on_activity_end(
self, activity: bascenev1.Activity, results: Any
) -> None:
# pylint: disable=cyclic-import
from bastd.tutorial import TutorialActivity
from bastd.activity.multiteamvictory import (
from bascenev1lib.tutorial import TutorialActivity
from bascenev1lib.activity.multiteamvictory import (
TeamSeriesVictoryScoreScreenActivity,
)
from ba._activitytypes import (
from bascenev1._activitytypes import (
TransitionActivity,
JoinActivity,
ScoreScreenActivity,
@ -192,14 +195,13 @@ class MultiTeamSession(Session):
# to transition us into a round gracefully (otherwise we'd snap from
# one terrain to another instantly).
elif isinstance(activity, TutorialActivity):
self.setactivity(_ba.newactivity(TransitionActivity))
self.setactivity(_bascenev1.newactivity(TransitionActivity))
# If we're in a between-round activity or a restart-activity, hop
# into a round.
elif isinstance(
activity, (JoinActivity, TransitionActivity, ScoreScreenActivity)
):
# If we're coming from a series-end activity, reset scores.
if isinstance(activity, TeamSeriesVictoryScoreScreenActivity):
self.stats.reset()
@ -226,7 +228,7 @@ class MultiTeamSession(Session):
# (ie: no longer sitting in the lobby).
try:
has_team = player.sessionteam is not None
except NotFoundError:
except babase.NotFoundError:
has_team = False
if has_team:
self.stats.register_sessionplayer(player)
@ -242,12 +244,12 @@ class MultiTeamSession(Session):
def _switch_to_score_screen(self, results: Any) -> None:
"""Switch to a score screen after leaving a round."""
del results # Unused arg.
print_error('this should be overridden')
logging.error('This should be overridden.', stack_info=True)
def announce_game_results(
self,
activity: ba.GameActivity,
results: ba.GameResults,
activity: bascenev1.GameActivity,
results: bascenev1.GameResults,
delay: float,
announce_winning_team: bool = True,
) -> None:
@ -259,15 +261,11 @@ class MultiTeamSession(Session):
announcement of the same.
"""
# pylint: disable=cyclic-import
# pylint: disable=too-many-locals
from ba._math import normalized_color
from ba._general import Call
from ba._gameutils import cameraflash
from ba._language import Lstr
from ba._freeforallsession import FreeForAllSession
from ba._messages import CelebrateMessage
from bascenev1._gameutils import cameraflash
from bascenev1._freeforallsession import FreeForAllSession
from bascenev1._messages import CelebrateMessage
_ba.timer(delay, Call(_ba.playsound, _ba.getsound('boxingBell')))
_bascenev1.timer(delay, _bascenev1.getsound('boxingBell').play)
if announce_winning_team:
winning_sessionteam = results.winning_sessionteam
@ -285,14 +283,14 @@ class MultiTeamSession(Session):
wins_resource = 'winsPlayerText'
else:
wins_resource = 'winsTeamText'
wins_text = Lstr(
wins_text = babase.Lstr(
resource=wins_resource,
subs=[('${NAME}', winning_sessionteam.name)],
)
activity.show_zoom_message(
wins_text,
scale=0.85,
color=normalized_color(winning_sessionteam.color),
color=babase.normalized_color(winning_sessionteam.color),
)

73
dist/ba_data/python/bascenev1/_music.py vendored Normal file
View file

@ -0,0 +1,73 @@
# Released under the MIT License. See LICENSE for details.
#
"""Music related bits."""
from __future__ import annotations
from enum import Enum
from typing import TYPE_CHECKING
import _bascenev1
if TYPE_CHECKING:
pass
class MusicType(Enum):
"""Types of music available to play in-game.
Category: **Enums**
These do not correspond to specific pieces of music, but rather to
'situations'. The actual music played for each type can be overridden
by the game or by the user.
"""
MENU = 'Menu'
VICTORY = 'Victory'
CHAR_SELECT = 'CharSelect'
RUN_AWAY = 'RunAway'
ONSLAUGHT = 'Onslaught'
KEEP_AWAY = 'Keep Away'
RACE = 'Race'
EPIC_RACE = 'Epic Race'
SCORES = 'Scores'
GRAND_ROMP = 'GrandRomp'
TO_THE_DEATH = 'ToTheDeath'
CHOSEN_ONE = 'Chosen One'
FORWARD_MARCH = 'ForwardMarch'
FLAG_CATCHER = 'FlagCatcher'
SURVIVAL = 'Survival'
EPIC = 'Epic'
SPORTS = 'Sports'
HOCKEY = 'Hockey'
FOOTBALL = 'Football'
FLYING = 'Flying'
SCARY = 'Scary'
MARCHING = 'Marching'
def setmusic(musictype: MusicType | None, continuous: bool = False) -> None:
"""Set the app to play (or stop playing) a certain type of music.
category: **Gameplay Functions**
This function will handle loading and playing sound assets as necessary,
and also supports custom user soundtracks on specific platforms so the
user can override particular game music with their own.
Pass None to stop music.
if 'continuous' is True and musictype is the same as what is already
playing, the playing track will not be restarted.
"""
# All we do here now is set a few music attrs on the current globals
# node. The foreground globals' current playing music then gets fed to
# the do_play_music call in our music controller. This way we can
# seamlessly support custom soundtracks in replays/etc since we're being
# driven purely by node data.
gnode = _bascenev1.getactivity().globalsnode
gnode.music_continuous = continuous
gnode.music = '' if musictype is None else musictype.value
gnode.music_count += 1

View file

@ -6,16 +6,17 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from ba._messages import DieMessage
from ba._actor import Actor
from bascenev1._messages import DieMessage
from bascenev1._actor import Actor
if TYPE_CHECKING:
import ba
from typing import Any
import bascenev1
class NodeActor(Actor):
"""A simple ba.Actor type that wraps a single ba.Node.
"""A simple bascenev1.Actor type that wraps a single bascenev1.Node.
Category: **Gameplay Classes**
@ -23,7 +24,7 @@ class NodeActor(Actor):
exists() call will return whether the Node still exists or not.
"""
def __init__(self, node: ba.Node):
def __init__(self, node: bascenev1.Node):
super().__init__()
self.node = node

View file

@ -4,25 +4,22 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, TypeVar, Generic, cast
import _ba
from ba._error import (
SessionPlayerNotFoundError,
print_exception,
ActorNotFoundError,
)
from ba._messages import DeathType, DieMessage
import babase
import _bascenev1
from bascenev1._messages import DeathType, DieMessage
if TYPE_CHECKING:
from typing import Sequence, Any, Callable
import ba
# pylint: disable=invalid-name
PlayerType = TypeVar('PlayerType', bound='ba.Player')
TeamType = TypeVar('TeamType', bound='ba.Team')
# pylint: enable=invalid-name
import bascenev1
PlayerT = TypeVar('PlayerT', bound='bascenev1.Player')
TeamT = TypeVar('TeamT', bound='bascenev1.Team')
@dataclass
@ -43,33 +40,33 @@ class StandLocation:
Category: Gameplay Classes
"""
position: ba.Vec3
position: babase.Vec3
angle: float | None = None
class Player(Generic[TeamType]):
"""A player in a specific ba.Activity.
class Player(Generic[TeamT]):
"""A player in a specific bascenev1.Activity.
Category: Gameplay Classes
These correspond to ba.SessionPlayer objects, but are associated with a
single ba.Activity instance. This allows activities to specify their
own custom ba.Player types.
These correspond to bascenev1.SessionPlayer objects, but are associated
with a single bascenev1.Activity instance. This allows activities to
specify their own custom bascenev1.Player types.
"""
# These are instance attrs but we define them at the type level so
# their type annotations are introspectable (for docs generation).
character: str
actor: ba.Actor | None
"""The ba.Actor associated with the player."""
actor: bascenev1.Actor | None
"""The bascenev1.Actor associated with the player."""
color: Sequence[float]
highlight: Sequence[float]
_team: TeamType
_sessionplayer: ba.SessionPlayer
_nodeactor: ba.NodeActor | None
_team: TeamT
_sessionplayer: bascenev1.SessionPlayer
_nodeactor: bascenev1.NodeActor | None
_expired: bool
_postinited: bool
_customdata: dict
@ -79,12 +76,12 @@ class Player(Generic[TeamType]):
# This also lets us keep trivial player classes cleaner by skipping
# the super().__init__() line.
def postinit(self, sessionplayer: ba.SessionPlayer) -> None:
def postinit(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""Wire up a newly created player.
(internal)
"""
from ba._nodeactor import NodeActor
from bascenev1._nodeactor import NodeActor
# Sanity check; if a dataclass is created that inherits from us,
# it will define an equality operator by default which will break
@ -100,17 +97,19 @@ class Player(Generic[TeamType]):
self.actor = None
self.character = ''
self._nodeactor: ba.NodeActor | None = None
self._nodeactor: bascenev1.NodeActor | None = None
self._sessionplayer = sessionplayer
self.character = sessionplayer.character
self.color = sessionplayer.color
self.highlight = sessionplayer.highlight
self._team = cast(TeamType, sessionplayer.sessionteam.activityteam)
self._team = cast(TeamT, sessionplayer.sessionteam.activityteam)
assert self._team is not None
self._customdata = {}
self._expired = False
self._postinited = True
node = _ba.newnode('player', attrs={'playerID': sessionplayer.id})
node = _bascenev1.newnode(
'player', attrs={'playerID': sessionplayer.id}
)
self._nodeactor = NodeActor(node)
sessionplayer.setnode(node)
@ -127,7 +126,7 @@ class Player(Generic[TeamType]):
self.actor.handlemessage(DieMessage(how=DeathType.LEFT_GAME))
self.actor = None
except Exception:
print_exception(f'Error killing actor on leave for {self}')
logging.exception('Error killing actor on leave for %s.', self)
self._nodeactor = None
del self._team
del self._customdata
@ -144,7 +143,7 @@ class Player(Generic[TeamType]):
try:
self.on_expire()
except Exception:
print_exception(f'Error in on_expire for {self}.')
logging.exception('Error in on_expire for %s.', self)
self._nodeactor = None
self.actor = None
@ -161,8 +160,8 @@ class Player(Generic[TeamType]):
"""
@property
def team(self) -> TeamType:
"""The ba.Team for this player."""
def team(self) -> TeamT:
"""The bascenev1.Team for this player."""
assert self._postinited
assert not self._expired
return self._team
@ -171,7 +170,7 @@ class Player(Generic[TeamType]):
def customdata(self) -> dict:
"""Arbitrary values associated with the player.
Though it is encouraged that most player values be properly defined
on the ba.Player subclass, it may be useful for player-agnostic
on the bascenev1.Player subclass, it may be useful for player-agnostic
objects to store values here. This dict is cleared when the player
leaves or expires so objects stored here will be disposed of at
the expected time, unlike the Player instance itself which may
@ -182,19 +181,19 @@ class Player(Generic[TeamType]):
return self._customdata
@property
def sessionplayer(self) -> ba.SessionPlayer:
"""Return the ba.SessionPlayer corresponding to this Player.
def sessionplayer(self) -> bascenev1.SessionPlayer:
"""Return the bascenev1.SessionPlayer corresponding to this Player.
Throws a ba.SessionPlayerNotFoundError if it does not exist.
Throws a bascenev1.SessionPlayerNotFoundError if it does not exist.
"""
assert self._postinited
if bool(self._sessionplayer):
return self._sessionplayer
raise SessionPlayerNotFoundError()
raise babase.SessionPlayerNotFoundError()
@property
def node(self) -> ba.Node:
"""A ba.Node of type 'player' associated with this Player.
def node(self) -> bascenev1.Node:
"""A bascenev1.Node of type 'player' associated with this Player.
This node can be used to get a generic player position/etc.
"""
@ -204,23 +203,24 @@ class Player(Generic[TeamType]):
return self._nodeactor.node
@property
def position(self) -> ba.Vec3:
"""The position of the player, as defined by its current ba.Actor.
def position(self) -> babase.Vec3:
"""The position of the player, as defined by its bascenev1.Actor.
If the player currently has no actor, raises a ba.ActorNotFoundError.
If the player currently has no actor, raises a
babase.ActorNotFoundError.
"""
assert self._postinited
assert not self._expired
if self.actor is None:
raise ActorNotFoundError
return _ba.Vec3(self.node.position)
raise babase.ActorNotFoundError
return babase.Vec3(self.node.position)
def exists(self) -> bool:
"""Whether the underlying player still exists.
This will return False if the underlying ba.SessionPlayer has
left the game or if the ba.Activity this player was associated
with has ended.
This will return False if the underlying bascenev1.SessionPlayer has
left the game or if the bascenev1.Activity this player was
associated with has ended.
Most functionality will fail on a nonexistent player.
Note that you can also use the boolean operator for this same
functionality, so a statement such as "if player" will do
@ -240,7 +240,7 @@ class Player(Generic[TeamType]):
def is_alive(self) -> bool:
"""
Returns True if the player has a ba.Actor assigned and its
Returns True if the player has a bascenev1.Actor assigned and its
is_alive() method return True. False is returned otherwise.
"""
assert self._postinited
@ -256,7 +256,9 @@ class Player(Generic[TeamType]):
return self._sessionplayer.get_icon()
def assigninput(
self, inputtype: ba.InputType | tuple[ba.InputType, ...], call: Callable
self,
inputtype: babase.InputType | tuple[babase.InputType, ...],
call: Callable,
) -> None:
"""
Set the python callable to be run for one or more types of input.
@ -277,16 +279,17 @@ class Player(Generic[TeamType]):
return self.exists()
class EmptyPlayer(Player['ba.EmptyTeam']):
class EmptyPlayer(Player['bascenev1.EmptyTeam']):
"""An empty player for use by Activities that don't need to define one.
Category: Gameplay Classes
ba.Player and ba.Team are 'Generic' types, and so passing those top level
classes as type arguments when defining a ba.Activity reduces type safety.
For example, activity.teams[0].player will have type 'Any' in that case.
For that reason, it is better to pass EmptyPlayer and EmptyTeam when
defining a ba.Activity that does not need custom types of its own.
bascenev1.Player and bascenev1.Team are 'Generic' types, and so passing
those top level classes as type arguments when defining a
bascenev1.Activity reduces type safety. For example,
activity.teams[0].player will have type 'Any' in that case. For that
reason, it is better to pass EmptyPlayer and EmptyTeam when defining
a bascenev1.Activity that does not need custom types of its own.
Note that EmptyPlayer defines its team type as EmptyTeam and vice versa,
so if you want to define your own class for one of them you should do so
@ -300,13 +303,13 @@ class EmptyPlayer(Player['ba.EmptyTeam']):
# instead of requiring extra work by them.
def playercast(totype: type[PlayerType], player: ba.Player) -> PlayerType:
"""Cast a ba.Player to a specific ba.Player subclass.
def playercast(totype: type[PlayerT], player: bascenev1.Player) -> PlayerT:
"""Cast a bascenev1.Player to a specific bascenev1.Player subclass.
Category: Gameplay Functions
When writing type-checked code, sometimes code will deal with raw
ba.Player objects which need to be cast back to the game's actual
bascenev1.Player objects which need to be cast back to a game's actual
player type so that access can be properly type-checked. This function
is a safe way to do so. It ensures that Optional values are not cast
into Non-Optional, etc.
@ -319,9 +322,9 @@ def playercast(totype: type[PlayerType], player: ba.Player) -> PlayerType:
# for the optional variety, but that currently seems to not be working.
# See: https://github.com/python/mypy/issues/8800
def playercast_o(
totype: type[PlayerType], player: ba.Player | None
) -> PlayerType | None:
"""A variant of ba.playercast() for use with optional ba.Player values.
totype: type[PlayerT], player: bascenev1.Player | None
) -> PlayerT | None:
"""A variant of bascenev1.playercast() for use with optional Player values.
Category: Gameplay Functions
"""

View file

@ -4,23 +4,23 @@
from __future__ import annotations
import _ba
import copy
import logging
from typing import Any, TYPE_CHECKING
import _ba
import babase
if TYPE_CHECKING:
from typing import Sequence
from ba import _session
from bascenev1._session import Session
PlaylistType = list[dict[str, Any]]
def filter_playlist(
playlist: PlaylistType,
sessiontype: type[_session.Session],
sessiontype: type[Session],
add_resolved_type: bool = False,
remove_unowned: bool = True,
mark_unowned: bool = False,
@ -35,18 +35,17 @@ def filter_playlist(
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
from ba._map import get_filtered_map_name
from ba._error import MapNotFoundError
from ba._store import get_unowned_maps, get_unowned_game_types
from ba._general import getclass
from ba._gameactivity import GameActivity
from bascenev1._map import get_filtered_map_name
from bascenev1._gameactivity import GameActivity
assert babase.app.classic is not None
goodlist: list[dict] = []
unowned_maps: Sequence[str]
available_maps: list[str] = list(_ba.app.maps.keys())
if remove_unowned or mark_unowned:
unowned_maps = get_unowned_maps()
unowned_game_types = get_unowned_game_types()
available_maps: list[str] = list(babase.app.classic.maps.keys())
if (remove_unowned or mark_unowned) and babase.app.classic is not None:
unowned_maps = babase.app.classic.store.get_unowned_maps()
unowned_game_types = babase.app.classic.store.get_unowned_game_types()
else:
unowned_maps = []
unowned_game_types = set()
@ -81,89 +80,113 @@ def filter_playlist(
'Happy_Thoughts.HappyThoughtsGame',
'bsAssault.AssaultGame',
'bs_assault.AssaultGame',
'bastd.game.assault.AssaultGame',
):
entry['type'] = 'bastd.game.assault.AssaultGame'
entry['type'] = 'bascenev1lib.game.assault.AssaultGame'
if entry['type'] in (
'King_of_the_Hill.KingOfTheHillGame',
'bsKingOfTheHill.KingOfTheHillGame',
'bs_king_of_the_hill.KingOfTheHillGame',
'bastd.game.kingofthehill.KingOfTheHillGame',
):
entry['type'] = 'bastd.game.kingofthehill.KingOfTheHillGame'
entry[
'type'
] = 'bascenev1lib.game.kingofthehill.KingOfTheHillGame'
if entry['type'] in (
'Capture_the_Flag.CTFGame',
'bsCaptureTheFlag.CTFGame',
'bs_capture_the_flag.CTFGame',
'bastd.game.capturetheflag.CaptureTheFlagGame',
):
entry['type'] = 'bastd.game.capturetheflag.CaptureTheFlagGame'
entry[
'type'
] = 'bascenev1lib.game.capturetheflag.CaptureTheFlagGame'
if entry['type'] in (
'Death_Match.DeathMatchGame',
'bsDeathMatch.DeathMatchGame',
'bs_death_match.DeathMatchGame',
'bastd.game.deathmatch.DeathMatchGame',
):
entry['type'] = 'bastd.game.deathmatch.DeathMatchGame'
entry['type'] = 'bascenev1lib.game.deathmatch.DeathMatchGame'
if entry['type'] in (
'ChosenOne.ChosenOneGame',
'bsChosenOne.ChosenOneGame',
'bs_chosen_one.ChosenOneGame',
'bastd.game.chosenone.ChosenOneGame',
):
entry['type'] = 'bastd.game.chosenone.ChosenOneGame'
entry['type'] = 'bascenev1lib.game.chosenone.ChosenOneGame'
if entry['type'] in (
'Conquest.Conquest',
'Conquest.ConquestGame',
'bsConquest.ConquestGame',
'bs_conquest.ConquestGame',
'bastd.game.conquest.ConquestGame',
):
entry['type'] = 'bastd.game.conquest.ConquestGame'
entry['type'] = 'bascenev1lib.game.conquest.ConquestGame'
if entry['type'] in (
'Elimination.EliminationGame',
'bsElimination.EliminationGame',
'bs_elimination.EliminationGame',
'bastd.game.elimination.EliminationGame',
):
entry['type'] = 'bastd.game.elimination.EliminationGame'
entry['type'] = 'bascenev1lib.game.elimination.EliminationGame'
if entry['type'] in (
'Football.FootballGame',
'bsFootball.FootballTeamGame',
'bs_football.FootballTeamGame',
'bastd.game.football.FootballTeamGame',
):
entry['type'] = 'bastd.game.football.FootballTeamGame'
entry['type'] = 'bascenev1lib.game.football.FootballTeamGame'
if entry['type'] in (
'Hockey.HockeyGame',
'bsHockey.HockeyGame',
'bs_hockey.HockeyGame',
'bastd.game.hockey.HockeyGame',
):
entry['type'] = 'bastd.game.hockey.HockeyGame'
entry['type'] = 'bascenev1lib.game.hockey.HockeyGame'
if entry['type'] in (
'Keep_Away.KeepAwayGame',
'bsKeepAway.KeepAwayGame',
'bs_keep_away.KeepAwayGame',
'bastd.game.keepaway.KeepAwayGame',
):
entry['type'] = 'bastd.game.keepaway.KeepAwayGame'
entry['type'] = 'bascenev1lib.game.keepaway.KeepAwayGame'
if entry['type'] in (
'Race.RaceGame',
'bsRace.RaceGame',
'bs_race.RaceGame',
'bastd.game.race.RaceGame',
):
entry['type'] = 'bastd.game.race.RaceGame'
entry['type'] = 'bascenev1lib.game.race.RaceGame'
if entry['type'] in (
'bsEasterEggHunt.EasterEggHuntGame',
'bs_easter_egg_hunt.EasterEggHuntGame',
'bastd.game.easteregghunt.EasterEggHuntGame',
):
entry['type'] = 'bastd.game.easteregghunt.EasterEggHuntGame'
entry[
'type'
] = 'bascenev1lib.game.easteregghunt.EasterEggHuntGame'
if entry['type'] in (
'bsMeteorShower.MeteorShowerGame',
'bs_meteor_shower.MeteorShowerGame',
'bastd.game.meteorshower.MeteorShowerGame',
):
entry['type'] = 'bastd.game.meteorshower.MeteorShowerGame'
entry[
'type'
] = 'bascenev1lib.game.meteorshower.MeteorShowerGame'
if entry['type'] in (
'bsTargetPractice.TargetPracticeGame',
'bs_target_practice.TargetPracticeGame',
'bastd.game.targetpractice.TargetPracticeGame',
):
entry['type'] = 'bastd.game.targetpractice.TargetPracticeGame'
entry[
'type'
] = 'bascenev1lib.game.targetpractice.TargetPracticeGame'
gameclass = getclass(entry['type'], GameActivity)
gameclass = babase.getclass(entry['type'], GameActivity)
if entry['settings']['map'] not in available_maps:
raise MapNotFoundError()
raise babase.MapNotFoundError()
if remove_unowned and gameclass in unowned_game_types:
continue
@ -182,11 +205,11 @@ def filter_playlist(
goodlist.append(entry)
except MapNotFoundError:
except babase.MapNotFoundError:
logging.warning(
'Map \'%s\' not found while scanning playlist \'%s\'.',
name,
entry['settings']['map'],
name,
)
except ImportError as exc:
logging.warning(

View file

@ -9,7 +9,8 @@ from dataclasses import dataclass
if TYPE_CHECKING:
from typing import Sequence
import ba
import bascenev1
@dataclass
@ -18,27 +19,28 @@ class PowerupMessage:
Category: **Message Classes**
This message is normally received by touching a ba.PowerupBox.
This message is normally received by touching a bascenev1.PowerupBox.
"""
poweruptype: str
"""The type of powerup to be granted (a string).
See ba.Powerup.poweruptype for available type values."""
See bascenev1.Powerup.poweruptype for available type values."""
sourcenode: ba.Node | None = None
sourcenode: bascenev1.Node | None = None
"""The node the powerup game from, or None otherwise.
If a powerup is accepted, a ba.PowerupAcceptMessage should be sent
back to the sourcenode to inform it of the fact. This will generally
cause the powerup box to make a sound and disappear or whatnot."""
If a powerup is accepted, a bascenev1.PowerupAcceptMessage should be
sent back to the sourcenode to inform it of the fact. This will
generally cause the powerup box to make a sound and disappear or
whatnot."""
@dataclass
class PowerupAcceptMessage:
"""A message informing a ba.Powerup that it was accepted.
"""A message informing a bascenev1.Powerup that it was accepted.
Category: **Message Classes**
This is generally sent in response to a ba.PowerupMessage
This is generally sent in response to a bascenev1.PowerupMessage
to inform the box (or whoever granted it) that it can go away.
"""

View file

@ -6,7 +6,7 @@ from __future__ import annotations
import random
from typing import TYPE_CHECKING
import _ba
import babase
if TYPE_CHECKING:
from typing import Any
@ -43,9 +43,7 @@ def get_player_profile_icon(profilename: str) -> str:
(non-account profiles only)
"""
from ba._generated.enums import SpecialChar
appconfig = _ba.app.config
appconfig = babase.app.config
icon: str
try:
is_global = appconfig['Player Profiles'][profilename]['global']
@ -55,7 +53,7 @@ def get_player_profile_icon(profilename: str) -> str:
try:
icon = appconfig['Player Profiles'][profilename]['icon']
except KeyError:
icon = _ba.charstr(SpecialChar.LOGO)
icon = babase.charstr(babase.SpecialChar.LOGO)
else:
icon = ''
return icon
@ -65,13 +63,13 @@ def get_player_profile_colors(
profilename: str | None, profiles: dict[str, dict[str, Any]] | None = None
) -> tuple[tuple[float, float, float], tuple[float, float, float]]:
"""Given a profile, return colors for them."""
appconfig = _ba.app.config
appconfig = babase.app.config
if profiles is None:
profiles = appconfig['Player Profiles']
# Special case: when being asked for a random color in kiosk mode,
# always return default purple.
if (_ba.app.demo_mode or _ba.app.arcade_mode) and profilename is None:
if (babase.app.demo_mode or babase.app.arcade_mode) and profilename is None:
color = (0.5, 0.4, 1.0)
highlight = (0.4, 0.4, 0.5)
else:

View file

@ -9,7 +9,7 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import ba
import bascenev1
@unique
@ -34,7 +34,7 @@ class ScoreConfig:
label: str = 'Score'
"""A label show to the user for scores; 'Score', 'Time Survived', etc."""
scoretype: ba.ScoreType = ScoreType.POINTS
scoretype: bascenev1.ScoreType = ScoreType.POINTS
"""How the score value should be displayed."""
lower_is_better: bool = False

View file

@ -4,28 +4,30 @@
from __future__ import annotations
import weakref
import logging
from typing import TYPE_CHECKING
import _ba
from ba._error import print_error, print_exception, NodeNotFoundError
from ba._language import Lstr
from ba._player import Player
import babase
import _bascenev1
from bascenev1._player import Player
if TYPE_CHECKING:
from typing import Sequence, Any
import ba
import bascenev1
class Session:
"""Defines a high level series of ba.Activity-es with a common purpose.
"""Defines a high level series of bascenev1.Activity-es.
Category: **Gameplay Classes**
Examples of sessions are ba.FreeForAllSession, ba.DualTeamSession, and
ba.CoopSession.
Examples of sessions are bascenev1.FreeForAllSession,
bascenev1.DualTeamSession, and bascenev1.CoopSession.
A Session is responsible for wrangling and transitioning between various
ba.Activity instances such as mini-games and score-screens, and for
bascenev1.Activity instances such as mini-games and score-screens, and for
maintaining state between them (players, teams, score tallies, etc).
"""
@ -43,9 +45,9 @@ class Session:
# at the class level so that looks better and nobody get lost while
# reading large __init__
lobby: ba.Lobby
"""The ba.Lobby instance where new ba.Player-s go to select a
Profile/Team/etc. before being added to games.
lobby: bascenev1.Lobby
"""The baclassic.Lobby instance where new bascenev1.Player-s go to select
a Profile/Team/etc. before being added to games.
Be aware this value may be None if a Session does not allow
any such selection."""
@ -56,23 +58,23 @@ class Session:
"""The minimum number of players who must be present for the Session
to proceed past the initial joining screen"""
sessionplayers: list[ba.SessionPlayer]
"""All ba.SessionPlayers in the Session. Most things should use the
list of ba.Player-s in ba.Activity; not this. Some players, such as
those who have not yet selected a character, will only be
found on this list."""
sessionplayers: list[bascenev1.SessionPlayer]
"""All bascenev1.SessionPlayers in the Session. Most things should use
the list of bascenev1.Player-s in bascenev1.Activity; not this. Some
players, such as those who have not yet selected a character, will
only be found on this list."""
customdata: dict
"""A shared dictionary for objects to use as storage on this session.
Ensure that keys here are unique to avoid collisions."""
sessionteams: list[ba.SessionTeam]
"""All the ba.SessionTeams in the Session. Most things should use the
list of ba.Team-s in ba.Activity; not this."""
sessionteams: list[bascenev1.SessionTeam]
"""All the bascenev1.SessionTeams in the Session. Most things should
use the list of bascenev1.Team-s in bascenev1.Activity; not this."""
def __init__(
self,
depsets: Sequence[ba.DependencySet],
depsets: Sequence[bascenev1.DependencySet],
team_names: Sequence[str] | None = None,
team_colors: Sequence[Sequence[float]] | None = None,
min_players: int = 1,
@ -80,21 +82,25 @@ class Session:
):
"""Instantiate a session.
depsets should be a sequence of successfully resolved ba.DependencySet
instances; one for each ba.Activity the session may potentially run.
depsets should be a sequence of successfully resolved
bascenev1.DependencySet instances; one for each bascenev1.Activity
the session may potentially run.
"""
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
# pylint: disable=cyclic-import
# pylint: disable=too-many-branches
from ba._lobby import Lobby
from ba._stats import Stats
from ba._gameactivity import GameActivity
from ba._activity import Activity
from ba._team import SessionTeam
from ba._error import DependencyError
from ba._dependency import Dependency, AssetPackage
from efro.util import empty_weakref
from bascenev1._dependency import (
Dependency,
AssetPackage,
DependencyError,
)
from bascenev1._lobby import Lobby
from bascenev1._stats import Stats
from bascenev1._gameactivity import GameActivity
from bascenev1._activity import Activity
from bascenev1._team import SessionTeam
# First off, resolve all dependency-sets we were passed.
# If things are missing, we'll try to gather them into a single
@ -135,7 +141,7 @@ class Session:
# required_asset_packages)
# Init our C++ layer data.
self._sessiondata = _ba.register_session(self)
self._sessiondata = _bascenev1.register_session(self)
# Should remove this if possible.
self.tournament_id: str | None = None
@ -148,16 +154,16 @@ class Session:
self.customdata = {}
self._in_set_activity = False
self._next_team_id = 0
self._activity_retained: ba.Activity | None = None
self._activity_retained: bascenev1.Activity | None = None
self._launch_end_session_activity_time: float | None = None
self._activity_end_timer: ba.Timer | None = None
self._activity_end_timer: bascenev1.BaseTimer | None = None
self._activity_weak = empty_weakref(Activity)
self._next_activity: ba.Activity | None = None
self._next_activity: bascenev1.Activity | None = None
self._wants_to_end = False
self._ending = False
self._activity_should_end_immediately = False
self._activity_should_end_immediately_results: (
ba.GameResults | None
bascenev1.GameResults | None
) = None
self._activity_should_end_immediately_delay = 0.0
@ -186,26 +192,33 @@ class Session:
self.sessionteams.append(team)
self._next_team_id += 1
try:
with _ba.Context(self):
with self.context:
self.on_team_join(team)
except Exception:
print_exception(f'Error in on_team_join for {self}.')
logging.exception('Error in on_team_join for %s.', self)
self.lobby = Lobby()
self.stats = Stats()
# Instantiate our session globals node which will apply its settings.
self._sessionglobalsnode = _ba.newnode('sessionglobals')
self._sessionglobalsnode = _bascenev1.newnode('sessionglobals')
@property
def sessionglobalsnode(self) -> ba.Node:
"""The sessionglobals ba.Node for the session."""
def context(self) -> bascenev1.ContextRef:
"""A context-ref pointing at this activity."""
return self._sessiondata.context()
@property
def sessionglobalsnode(self) -> bascenev1.Node:
"""The sessionglobals bascenev1.Node for the session."""
node = self._sessionglobalsnode
if not node:
raise NodeNotFoundError()
raise babase.NodeNotFoundError()
return node
def should_allow_mid_activity_joins(self, activity: ba.Activity) -> bool:
def should_allow_mid_activity_joins(
self, activity: bascenev1.Activity
) -> bool:
"""Ask ourself if we should allow joins during an Activity.
Note that for a join to be allowed, both the Session and Activity
@ -215,21 +228,22 @@ class Session:
del activity # Unused.
return True
def on_player_request(self, player: ba.SessionPlayer) -> bool:
"""Called when a new ba.Player wants to join the Session.
def on_player_request(self, player: bascenev1.SessionPlayer) -> bool:
"""Called when a new bascenev1.Player wants to join the Session.
This should return True or False to accept/reject.
"""
# Limit player counts *unless* we're in a stress test.
if _ba.app.stress_test_reset_timer is None:
if (
babase.app.classic is not None
and babase.app.classic.stress_test_reset_timer is None
):
if len(self.sessionplayers) >= self.max_players:
# Print a rejection message *only* to the client trying to
# join (prevents spamming everyone else in the game).
_ba.playsound(_ba.getsound('error'))
_ba.screenmessage(
Lstr(
_bascenev1.getsound('error').play()
_bascenev1.broadcastmessage(
babase.Lstr(
resource='playerLimitReachedText',
subs=[('${COUNT}', str(self.max_players))],
),
@ -239,11 +253,11 @@ class Session:
)
return False
_ba.playsound(_ba.getsound('dripity'))
_bascenev1.getsound('dripity').play()
return True
def on_player_leave(self, sessionplayer: ba.SessionPlayer) -> None:
"""Called when a previously-accepted ba.SessionPlayer leaves."""
def on_player_leave(self, sessionplayer: bascenev1.SessionPlayer) -> None:
"""Called when a previously-accepted bascenev1.SessionPlayer leaves."""
if sessionplayer not in self.sessionplayers:
print(
@ -252,26 +266,25 @@ class Session:
)
return
_ba.playsound(_ba.getsound('playerLeft'))
_bascenev1.getsound('playerLeft').play()
activity = self._activity_weak()
if not sessionplayer.in_game:
# Ok, the player is still in the lobby; simply remove them.
with _ba.Context(self):
with self.context:
try:
self.lobby.remove_chooser(sessionplayer)
except Exception:
print_exception('Error in Lobby.remove_chooser().')
logging.exception('Error in Lobby.remove_chooser().')
else:
# Ok, they've already entered the game. Remove them from
# teams/activities/etc.
sessionteam = sessionplayer.sessionteam
assert sessionteam is not None
_ba.screenmessage(
Lstr(
_bascenev1.broadcastmessage(
babase.Lstr(
resource='playerLeftText',
subs=[('${PLAYER}', sessionplayer.getname(full=True))],
)
@ -305,7 +318,9 @@ class Session:
self.sessionplayers.remove(sessionplayer)
def _remove_player_team(
self, sessionteam: ba.SessionTeam, activity: ba.Activity | None
self,
sessionteam: bascenev1.SessionTeam,
activity: bascenev1.Activity | None,
) -> None:
"""Remove the player-specific team in non-teams mode."""
@ -320,23 +335,24 @@ class Session:
print('Team not found in Activity in on_player_leave.')
# And then from the Session.
with _ba.Context(self):
with self.context:
if sessionteam in self.sessionteams:
try:
self.sessionteams.remove(sessionteam)
self.on_team_leave(sessionteam)
except Exception:
print_exception(
f'Error in on_team_leave for Session {self}.'
logging.exception(
'Error in on_team_leave for Session %s.', self
)
else:
print('Team no in Session teams in on_player_leave.')
try:
sessionteam.leave()
except Exception:
print_exception(
f'Error clearing sessiondata'
f' for team {sessionteam} in session {self}.'
logging.exception(
'Error clearing sessiondata for team %s in session %s.',
sessionteam,
self,
)
def end(self) -> None:
@ -351,46 +367,45 @@ class Session:
def _launch_end_session_activity(self) -> None:
"""(internal)"""
from ba._activitytypes import EndSessionActivity
from ba._generated.enums import TimeType
from bascenev1._activitytypes import EndSessionActivity
with _ba.Context(self):
curtime = _ba.time(TimeType.REAL)
with self.context:
curtime = babase.apptime()
if self._ending:
# Ignore repeats unless its been a while.
assert self._launch_end_session_activity_time is not None
since_last = curtime - self._launch_end_session_activity_time
if since_last < 30.0:
return
print_error(
'_launch_end_session_activity called twice (since_last='
+ str(since_last)
+ ')'
logging.error(
'_launch_end_session_activity called twice (since_last=%s)',
since_last,
)
self._launch_end_session_activity_time = curtime
self.setactivity(_ba.newactivity(EndSessionActivity))
self.setactivity(_bascenev1.newactivity(EndSessionActivity))
self._wants_to_end = False
self._ending = True # Prevent further actions.
def on_team_join(self, team: ba.SessionTeam) -> None:
"""Called when a new ba.Team joins the session."""
def on_team_join(self, team: bascenev1.SessionTeam) -> None:
"""Called when a new bascenev1.Team joins the session."""
def on_team_leave(self, team: ba.SessionTeam) -> None:
"""Called when a ba.Team is leaving the session."""
def on_team_leave(self, team: bascenev1.SessionTeam) -> None:
"""Called when a bascenev1.Team is leaving the session."""
def end_activity(
self, activity: ba.Activity, results: Any, delay: float, force: bool
self,
activity: bascenev1.Activity,
results: Any,
delay: float,
force: bool,
) -> None:
"""Commence shutdown of a ba.Activity (if not already occurring).
"""Commence shutdown of a bascenev1.Activity (if not already occurring).
'delay' is the time delay before the Activity actually ends
(in seconds). Further calls to end() will be ignored up until
this time, unless 'force' is True, in which case the new results
will replace the old.
"""
from ba._general import Call
from ba._generated.enums import TimeType
# Only pay attention if this is coming from our current activity.
if activity is not self._activity_retained:
return
@ -410,16 +425,15 @@ class Session:
activity.set_has_ended(True)
# Set a timer to set in motion this activity's demise.
self._activity_end_timer = _ba.Timer(
self._activity_end_timer = _bascenev1.BaseTimer(
delay,
Call(self._complete_end_activity, activity, results),
timetype=TimeType.BASE,
babase.Call(self._complete_end_activity, activity, results),
)
def handlemessage(self, msg: Any) -> Any:
"""General message handling; can be passed any message object."""
from ba._lobby import PlayerReadyMessage
from ba._messages import PlayerProfilesChangedMessage, UNHANDLED
from bascenev1._lobby import PlayerReadyMessage
from bascenev1._messages import PlayerProfilesChangedMessage, UNHANDLED
if isinstance(msg, PlayerReadyMessage):
self._on_player_ready(msg.chooser)
@ -427,7 +441,7 @@ class Session:
elif isinstance(msg, PlayerProfilesChangedMessage):
# If we have a current activity with a lobby, ask it to reload
# profiles.
with _ba.Context(self):
with self.context:
self.lobby.reload_profiles()
return None
@ -436,7 +450,7 @@ class Session:
return None
class _SetActivityScopedLock:
def __init__(self, session: ba.Session) -> None:
def __init__(self, session: Session) -> None:
self._session = session
if session._in_set_activity:
raise RuntimeError('Session.setactivity() called recursively.')
@ -445,20 +459,20 @@ class Session:
def __del__(self) -> None:
self._session._in_set_activity = False
def setactivity(self, activity: ba.Activity) -> None:
"""Assign a new current ba.Activity for the session.
def setactivity(self, activity: bascenev1.Activity) -> None:
"""Assign a new current bascenev1.Activity for the session.
Note that this will not change the current context to the new
Activity's. Code must be run in the new activity's methods
(on_transition_in, etc) to get it. (so you can't do
session.setactivity(foo) and then ba.newnode() to add a node to foo)
session.setactivity(foo) and then bascenev1.newnode() to add a node
to foo)
"""
from ba._generated.enums import TimeType
# Make sure we don't get called recursively.
_rlock = self._SetActivityScopedLock(self)
if activity.session is not _ba.getsession():
if activity.session is not _bascenev1.getsession():
raise RuntimeError("Provided Activity's Session is not current.")
# Quietly ignore this if the whole session is going down.
@ -466,7 +480,7 @@ class Session:
return
if activity is self._activity_retained:
print_error('Activity set to already-current activity.')
logging.error('Activity set to already-current activity.')
return
if self._next_activity is not None:
@ -507,15 +521,13 @@ class Session:
# the activity should have no refs left to it and should die (which
# will trigger the next activity to run).
if prev_activity is not None:
with _ba.Context('ui'):
_ba.timer(
max(0.0, activity.transition_time),
prev_activity.expire,
timetype=TimeType.REAL,
with babase.ContextRef.empty():
babase.apptimer(
max(0.0, activity.transition_time), prev_activity.expire
)
self._in_set_activity = False
def getactivity(self) -> ba.Activity | None:
def getactivity(self) -> bascenev1.Activity | None:
"""Return the current foreground activity for this session."""
return self._activity_weak()
@ -530,49 +542,54 @@ class Session:
return []
def _complete_end_activity(
self, activity: ba.Activity, results: Any
self, activity: bascenev1.Activity, results: Any
) -> None:
# Run the subclass callback in the session context.
try:
with _ba.Context(self):
with self.context:
self.on_activity_end(activity, results)
except Exception:
print_exception(
f'Error in on_activity_end() for session {self}'
f' activity {activity} with results {results}'
logging.error(
'Error in on_activity_end() for session %s'
' activity %s with results %s',
self,
activity,
results,
)
def _request_player(self, sessionplayer: ba.SessionPlayer) -> bool:
def _request_player(self, sessionplayer: bascenev1.SessionPlayer) -> bool:
"""Called by the native layer when a player wants to join."""
# If we're ending, allow no new players.
if self._ending:
return False
# Ask the ba.Session subclass to approve/deny this request.
# Ask the bascenev1.Session subclass to approve/deny this request.
try:
with _ba.Context(self):
with self.context:
result = self.on_player_request(sessionplayer)
except Exception:
print_exception(f'Error in on_player_request for {self}')
logging.exception('Error in on_player_request for %s.', self)
result = False
# If they said yes, add the player to the lobby.
if result:
self.sessionplayers.append(sessionplayer)
with _ba.Context(self):
with self.context:
try:
self.lobby.add_chooser(sessionplayer)
except Exception:
print_exception('Error in lobby.add_chooser().')
logging.exception('Error in lobby.add_chooser().')
return result
def on_activity_end(self, activity: ba.Activity, results: Any) -> None:
"""Called when the current ba.Activity has ended.
def on_activity_end(
self, activity: bascenev1.Activity, results: Any
) -> None:
"""Called when the current bascenev1.Activity has ended.
The ba.Session should look at the results and start
another ba.Activity.
The bascenev1.Session should look at the results and start
another bascenev1.Activity.
"""
def begin_next_activity(self) -> None:
@ -582,7 +599,7 @@ class Session:
"""
if self._next_activity is None:
# Should this ever happen?
print_error('begin_next_activity() called with no _next_activity')
logging.error('begin_next_activity() called with no _next_activity')
return
# We store both a weak and a strong ref to the new activity;
@ -611,8 +628,8 @@ class Session:
self._activity_should_end_immediately_delay,
)
def _on_player_ready(self, chooser: ba.Chooser) -> None:
"""Called when a ba.Player has checked themself ready."""
def _on_player_ready(self, chooser: bascenev1.Chooser) -> None:
"""Called when a bascenev1.Player has checked themself ready."""
lobby = chooser.lobby
activity = self._activity_weak()
@ -638,14 +655,14 @@ class Session:
# Get our next activity going.
self._complete_end_activity(activity, {})
else:
_ba.screenmessage(
Lstr(
_bascenev1.broadcastmessage(
babase.Lstr(
resource='notEnoughPlayersText',
subs=[('${COUNT}', str(min_players))],
),
color=(1, 1, 0),
)
_ba.playsound(_ba.getsound('error'))
_bascenev1.getsound('error').play()
# Otherwise just add players on the fly.
else:
@ -657,22 +674,24 @@ class Session:
) -> None:
"""(internal)"""
# pylint: disable=cyclic-import
from ba._apputils import garbage_collect
# Since things should be generally still right now, it's a good time
# to run garbage collection to clear out any circular dependency
# loops. We keep this disabled normally to avoid non-deterministic
# hitches.
garbage_collect()
babase.garbage_collect()
with _ba.Context(self):
assert babase.app.classic is not None
with self.context:
if can_show_ad_on_death:
_ba.app.ads.call_after_ad(self.begin_next_activity)
babase.app.classic.ads.call_after_ad(self.begin_next_activity)
else:
_ba.pushcall(self.begin_next_activity)
babase.pushcall(self.begin_next_activity)
def _add_chosen_player(self, chooser: ba.Chooser) -> ba.SessionPlayer:
from ba._team import SessionTeam
def _add_chosen_player(
self, chooser: bascenev1.Chooser
) -> bascenev1.SessionPlayer:
from bascenev1._team import SessionTeam
sessionplayer = chooser.getplayer()
assert sessionplayer in self.sessionplayers, (
@ -701,9 +720,9 @@ class Session:
and self.should_allow_mid_activity_joins(activity)
):
pass_to_activity = False
with _ba.Context(self):
_ba.screenmessage(
Lstr(
with self.context:
_bascenev1.broadcastmessage(
babase.Lstr(
resource='playerDelayedJoinText',
subs=[
('${PLAYER}', sessionplayer.getname(full=True))
@ -728,11 +747,11 @@ class Session:
# Add player's team to the Session.
self.sessionteams.append(sessionteam)
with _ba.Context(self):
with self.context:
try:
self.on_team_join(sessionteam)
except Exception:
print_exception(f'Error in on_team_join for {self}.')
logging.exception('Error in on_team_join for %s.', self)
# Add player's team to the Activity.
if pass_to_activity:

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