1.7.5 master sync

This commit is contained in:
Ayush Saini 2022-07-16 17:59:14 +05:30
parent 421c488c6a
commit 62103ea678
61 changed files with 2001 additions and 1364 deletions

View file

@ -29,6 +29,7 @@ if TYPE_CHECKING:
from ba._cloud import CloudSubsystem
from bastd.actor import spazappearance
from ba._accountv2 import AccountV2Subsystem
from ba._level import Level
class App:
@ -274,6 +275,7 @@ class App:
# Co-op Campaigns.
self.campaigns: dict[str, ba.Campaign] = {}
self.custom_coop_practice_games: list[str] = []
# Server Mode.
self.server: ba.ServerController | None = None
@ -342,6 +344,7 @@ class App:
from bastd.actor import spazappearance
from ba._generated.enums import TimeType
self._aioloop = _asyncio.setup_asyncio()
cfg = self.config
@ -432,8 +435,6 @@ class App:
# from ba._dependency import test_depset
# test_depset()
if bool(False):
self._test_https()
def _update_state(self) -> None:
if self._app_paused:
@ -527,6 +528,15 @@ class App:
# 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."""
@ -643,19 +653,3 @@ class App:
"""
self._initial_login_completed = True
self._update_state()
def _test_https(self) -> None:
"""Testing https support.
(would be nice to get this working on our custom Python builds; need
to wrangle certificates somehow).
"""
import urllib.request
try:
with urllib.request.urlopen('https://example.com') as url:
val = url.read()
_ba.screenmessage('HTTPS SUCCESS!')
print('HTTPS TEST SUCCESS', len(val))
except Exception as exc:
_ba.screenmessage('HTTPS FAIL.')
print('HTTPS TEST FAIL:', exc)

View file

@ -17,6 +17,7 @@ import sys
from efro.dataclassio import (ioprepped, IOAttrs, dataclass_from_json,
dataclass_to_json)
import _ba
if TYPE_CHECKING:
from bacommon.assets import AssetPackageFlavor
@ -165,7 +166,6 @@ def fetch_url(url: str, filename: Path, asset_gather: AssetGather) -> None:
"""
# pylint: disable=consider-using-with
import socket
# We don't want to keep the provided AssetGather alive, but we want
@ -175,7 +175,9 @@ 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, timeout=1)
req = urllib.request.urlopen(url,
context=_ba.app.net.sslcontext,
timeout=1)
file_size = int(req.headers['Content-Length'])
print(f'\nDownloading: {filename} Bytes: {file_size:,}')

View file

@ -11,6 +11,9 @@ from __future__ import annotations
from typing import TYPE_CHECKING
import asyncio
import logging
import time
import os
if TYPE_CHECKING:
import ba
@ -19,6 +22,8 @@ if TYPE_CHECKING:
_asyncio_timer: ba.Timer | None = None
_asyncio_event_loop: asyncio.AbstractEventLoop | None = None
DEBUG_TIMING = os.environ.get('BA_DEBUG_TIMING') == '1'
def setup_asyncio() -> asyncio.AbstractEventLoop:
"""Setup asyncio functionality for the logic thread."""
@ -53,7 +58,18 @@ def setup_asyncio() -> asyncio.AbstractEventLoop:
def run_cycle() -> None:
assert _asyncio_event_loop is not None
_asyncio_event_loop.call_soon(_asyncio_event_loop.stop)
starttime = time.monotonic() if DEBUG_TIMING else 0
_asyncio_event_loop.run_forever()
endtime = time.monotonic() if DEBUG_TIMING else 0
# Let's aim to have nothing take longer than 1/120 of a second.
if DEBUG_TIMING:
warn_time = 1.0 / 120
duration = endtime - starttime
if duration > warn_time:
logging.warning(
'Asyncio loop step took %.4fs; ideal max is %.4f',
duration, warn_time)
global _asyncio_timer # pylint: disable=invalid-name
_asyncio_timer = _ba.Timer(1.0 / 30.0,

View file

@ -28,10 +28,16 @@ class Campaign:
Category: **App Classes**
"""
def __init__(self, name: str, sequential: bool = True):
def __init__(self,
name: str,
sequential: bool = True,
levels: list[ba.Level] | None = None):
self._name = name
self._levels: list[ba.Level] = []
self._sequential = sequential
self._levels: list[ba.Level] = []
if levels is not None:
for level in levels:
self.addlevel(level)
@property
def name(self) -> str:
@ -43,12 +49,15 @@ class Campaign:
"""Whether this Campaign's levels must be played in sequence."""
return self._sequential
def addlevel(self, level: ba.Level) -> None:
def addlevel(self, level: ba.Level, index: int | None = None) -> None:
"""Adds a ba.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))
self._levels.append(level)
if index is None:
self._levels.append(level)
else:
self._levels.insert(index, level)
@property
def levels(self) -> list[ba.Level]:
@ -91,9 +100,8 @@ class Campaign:
def init_campaigns() -> None:
"""Fill out initial default Campaigns."""
# pylint: disable=too-many-statements
# pylint: disable=cyclic-import
from ba import _level
from ba._level import Level
from bastd.game.onslaught import OnslaughtGame
from bastd.game.football import FootballCoopGame
from bastd.game.runaround import RunaroundGame
@ -109,244 +117,218 @@ def init_campaigns() -> None:
# FIXME: Once translations catch up, we can convert these to use the
# generic display-name '${GAME} Training' type stuff.
campaign = Campaign('Easy')
campaign.addlevel(
_level.Level('Onslaught Training',
gametype=OnslaughtGame,
settings={'preset': 'training_easy'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Rookie Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'rookie_easy'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Rookie Football',
gametype=FootballCoopGame,
settings={'preset': 'rookie_easy'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'pro_easy'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Pro Football',
gametype=FootballCoopGame,
settings={'preset': 'pro_easy'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Runaround',
gametype=RunaroundGame,
settings={'preset': 'pro_easy'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Uber Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'uber_easy'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Uber Football',
gametype=FootballCoopGame,
settings={'preset': 'uber_easy'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Uber Runaround',
gametype=RunaroundGame,
settings={'preset': 'uber_easy'},
preview_texture_name='towerDPreview'))
register_campaign(campaign)
register_campaign(
Campaign(
'Easy',
levels=[
Level('Onslaught Training',
gametype=OnslaughtGame,
settings={'preset': 'training_easy'},
preview_texture_name='doomShroomPreview'),
Level('Rookie Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'rookie_easy'},
preview_texture_name='courtyardPreview'),
Level('Rookie Football',
gametype=FootballCoopGame,
settings={'preset': 'rookie_easy'},
preview_texture_name='footballStadiumPreview'),
Level('Pro Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'pro_easy'},
preview_texture_name='doomShroomPreview'),
Level('Pro Football',
gametype=FootballCoopGame,
settings={'preset': 'pro_easy'},
preview_texture_name='footballStadiumPreview'),
Level('Pro Runaround',
gametype=RunaroundGame,
settings={'preset': 'pro_easy'},
preview_texture_name='towerDPreview'),
Level('Uber Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'uber_easy'},
preview_texture_name='courtyardPreview'),
Level('Uber Football',
gametype=FootballCoopGame,
settings={'preset': 'uber_easy'},
preview_texture_name='footballStadiumPreview'),
Level('Uber Runaround',
gametype=RunaroundGame,
settings={'preset': 'uber_easy'},
preview_texture_name='towerDPreview')
],
))
# "hard" mode
campaign = Campaign('Default')
campaign.addlevel(
_level.Level('Onslaught Training',
gametype=OnslaughtGame,
settings={'preset': 'training'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Rookie Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'rookie'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Rookie Football',
gametype=FootballCoopGame,
settings={'preset': 'rookie'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'pro'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Pro Football',
gametype=FootballCoopGame,
settings={'preset': 'pro'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Runaround',
gametype=RunaroundGame,
settings={'preset': 'pro'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Uber Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'uber'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level('Uber Football',
gametype=FootballCoopGame,
settings={'preset': 'uber'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Uber Runaround',
gametype=RunaroundGame,
settings={'preset': 'uber'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('The Last Stand',
gametype=TheLastStandGame,
settings={},
preview_texture_name='rampagePreview'))
register_campaign(campaign)
register_campaign(
Campaign(
'Default',
levels=[
Level('Onslaught Training',
gametype=OnslaughtGame,
settings={'preset': 'training'},
preview_texture_name='doomShroomPreview'),
Level('Rookie Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'rookie'},
preview_texture_name='courtyardPreview'),
Level('Rookie Football',
gametype=FootballCoopGame,
settings={'preset': 'rookie'},
preview_texture_name='footballStadiumPreview'),
Level('Pro Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'pro'},
preview_texture_name='doomShroomPreview'),
Level('Pro Football',
gametype=FootballCoopGame,
settings={'preset': 'pro'},
preview_texture_name='footballStadiumPreview'),
Level('Pro Runaround',
gametype=RunaroundGame,
settings={'preset': 'pro'},
preview_texture_name='towerDPreview'),
Level('Uber Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'uber'},
preview_texture_name='courtyardPreview'),
Level('Uber Football',
gametype=FootballCoopGame,
settings={'preset': 'uber'},
preview_texture_name='footballStadiumPreview'),
Level('Uber Runaround',
gametype=RunaroundGame,
settings={'preset': 'uber'},
preview_texture_name='towerDPreview'),
Level('The Last Stand',
gametype=TheLastStandGame,
settings={},
preview_texture_name='rampagePreview')
],
))
# challenges: our 'official' random extra co-op levels
campaign = Campaign('Challenges', sequential=False)
campaign.addlevel(
_level.Level('Infinite Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'endless'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Infinite Runaround',
gametype=RunaroundGame,
settings={'preset': 'endless'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Race',
displayname='${GAME}',
gametype=RaceGame,
settings={
'map': 'Big G',
'Laps': 3,
'Bomb Spawning': 0
},
preview_texture_name='bigGPreview'))
campaign.addlevel(
_level.Level('Pro Race',
displayname='Pro ${GAME}',
gametype=RaceGame,
settings={
'map': 'Big G',
'Laps': 3,
'Bomb Spawning': 1000
},
preview_texture_name='bigGPreview'))
campaign.addlevel(
_level.Level('Lake Frigid Race',
displayname='${GAME}',
gametype=RaceGame,
settings={
'map': 'Lake Frigid',
'Laps': 6,
'Mine Spawning': 2000,
'Bomb Spawning': 0
},
preview_texture_name='lakeFrigidPreview'))
campaign.addlevel(
_level.Level('Football',
displayname='${GAME}',
gametype=FootballCoopGame,
settings={'preset': 'tournament'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Pro Football',
displayname='Pro ${GAME}',
gametype=FootballCoopGame,
settings={'preset': 'tournament_pro'},
preview_texture_name='footballStadiumPreview'))
campaign.addlevel(
_level.Level('Runaround',
displayname='${GAME}',
gametype=RunaroundGame,
settings={'preset': 'tournament'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Uber Runaround',
displayname='Uber ${GAME}',
gametype=RunaroundGame,
settings={'preset': 'tournament_uber'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('The Last Stand',
displayname='${GAME}',
gametype=TheLastStandGame,
settings={'preset': 'tournament'},
preview_texture_name='rampagePreview'))
campaign.addlevel(
_level.Level('Tournament Infinite Onslaught',
displayname='Infinite Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'endless_tournament'},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Tournament Infinite Runaround',
displayname='Infinite Runaround',
gametype=RunaroundGame,
settings={'preset': 'endless_tournament'},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Target Practice',
displayname='Pro ${GAME}',
gametype=TargetPracticeGame,
settings={},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Target Practice B',
displayname='${GAME}',
gametype=TargetPracticeGame,
settings={
'Target Count': 2,
'Enable Impact Bombs': False,
'Enable Triple Bombs': False
},
preview_texture_name='doomShroomPreview'))
campaign.addlevel(
_level.Level('Meteor Shower',
displayname='${GAME}',
gametype=MeteorShowerGame,
settings={},
preview_texture_name='rampagePreview'))
campaign.addlevel(
_level.Level('Epic Meteor Shower',
displayname='${GAME}',
gametype=MeteorShowerGame,
settings={'Epic Mode': True},
preview_texture_name='rampagePreview'))
campaign.addlevel(
_level.Level('Easter Egg Hunt',
displayname='${GAME}',
gametype=EasterEggHuntGame,
settings={},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level('Pro Easter Egg Hunt',
displayname='Pro ${GAME}',
gametype=EasterEggHuntGame,
settings={'Pro Mode': True},
preview_texture_name='towerDPreview'))
campaign.addlevel(
_level.Level(
name='Ninja Fight', # (unique id not seen by player)
displayname='${GAME}', # (readable name seen by player)
gametype=NinjaFightGame,
settings={'preset': 'regular'},
preview_texture_name='courtyardPreview'))
campaign.addlevel(
_level.Level(name='Pro Ninja Fight',
displayname='Pro ${GAME}',
gametype=NinjaFightGame,
settings={'preset': 'pro'},
preview_texture_name='courtyardPreview'))
register_campaign(campaign)
register_campaign(
Campaign(
'Challenges',
sequential=False,
levels=[
Level('Infinite Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'endless'},
preview_texture_name='doomShroomPreview'),
Level('Infinite Runaround',
gametype=RunaroundGame,
settings={'preset': 'endless'},
preview_texture_name='towerDPreview'),
Level('Race',
displayname='${GAME}',
gametype=RaceGame,
settings={
'map': 'Big G',
'Laps': 3,
'Bomb Spawning': 0
},
preview_texture_name='bigGPreview'),
Level('Pro Race',
displayname='Pro ${GAME}',
gametype=RaceGame,
settings={
'map': 'Big G',
'Laps': 3,
'Bomb Spawning': 1000
},
preview_texture_name='bigGPreview'),
Level('Lake Frigid Race',
displayname='${GAME}',
gametype=RaceGame,
settings={
'map': 'Lake Frigid',
'Laps': 6,
'Mine Spawning': 2000,
'Bomb Spawning': 0
},
preview_texture_name='lakeFrigidPreview'),
Level('Football',
displayname='${GAME}',
gametype=FootballCoopGame,
settings={'preset': 'tournament'},
preview_texture_name='footballStadiumPreview'),
Level('Pro Football',
displayname='Pro ${GAME}',
gametype=FootballCoopGame,
settings={'preset': 'tournament_pro'},
preview_texture_name='footballStadiumPreview'),
Level('Runaround',
displayname='${GAME}',
gametype=RunaroundGame,
settings={'preset': 'tournament'},
preview_texture_name='towerDPreview'),
Level('Uber Runaround',
displayname='Uber ${GAME}',
gametype=RunaroundGame,
settings={'preset': 'tournament_uber'},
preview_texture_name='towerDPreview'),
Level('The Last Stand',
displayname='${GAME}',
gametype=TheLastStandGame,
settings={'preset': 'tournament'},
preview_texture_name='rampagePreview'),
Level('Tournament Infinite Onslaught',
displayname='Infinite Onslaught',
gametype=OnslaughtGame,
settings={'preset': 'endless_tournament'},
preview_texture_name='doomShroomPreview'),
Level('Tournament Infinite Runaround',
displayname='Infinite Runaround',
gametype=RunaroundGame,
settings={'preset': 'endless_tournament'},
preview_texture_name='towerDPreview'),
Level('Target Practice',
displayname='Pro ${GAME}',
gametype=TargetPracticeGame,
settings={},
preview_texture_name='doomShroomPreview'),
Level('Target Practice B',
displayname='${GAME}',
gametype=TargetPracticeGame,
settings={
'Target Count': 2,
'Enable Impact Bombs': False,
'Enable Triple Bombs': False
},
preview_texture_name='doomShroomPreview'),
Level('Meteor Shower',
displayname='${GAME}',
gametype=MeteorShowerGame,
settings={},
preview_texture_name='rampagePreview'),
Level('Epic Meteor Shower',
displayname='${GAME}',
gametype=MeteorShowerGame,
settings={'Epic Mode': True},
preview_texture_name='rampagePreview'),
Level('Easter Egg Hunt',
displayname='${GAME}',
gametype=EasterEggHuntGame,
settings={},
preview_texture_name='towerDPreview'),
Level('Pro Easter Egg Hunt',
displayname='Pro ${GAME}',
gametype=EasterEggHuntGame,
settings={'Pro Mode': True},
preview_texture_name='towerDPreview'),
Level(
name='Ninja Fight', # (unique id not seen by player)
displayname='${GAME}', # (readable name seen by player)
gametype=NinjaFightGame,
settings={'preset': 'regular'},
preview_texture_name='courtyardPreview'),
Level(name='Pro Ninja Fight',
displayname='Pro ${GAME}',
gametype=NinjaFightGame,
settings={'preset': 'pro'},
preview_texture_name='courtyardPreview')
],
))

View file

@ -56,6 +56,14 @@ class CloudSubsystem:
) -> None:
...
@overload
def send_message_cb(
self,
msg: bacommon.cloud.PingMessage,
on_response: Callable[[bacommon.cloud.PingResponse | Exception], None],
) -> None:
...
def send_message_cb(
self,
msg: Message,

View file

@ -204,6 +204,12 @@ def no_game_circle_message() -> None:
_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 empty_call() -> None:
pass

View file

@ -44,7 +44,7 @@ class MetadataSubsystem:
"""
def __init__(self) -> None:
self.metascan: ScanResults | None = None
self.scanresults: ScanResults | None = None
self.extra_scan_dirs: list[str] = []
def on_app_running(self) -> None:
@ -58,7 +58,7 @@ class MetadataSubsystem:
Should be called only once at launch."""
app = _ba.app
if self.metascan is not None:
if self.scanresults is not None:
print('WARNING: meta scan run more than once.')
pythondirs = ([app.python_directory_app, app.python_directory_user] +
self.extra_scan_dirs)
@ -133,7 +133,7 @@ class MetadataSubsystem:
def get_scan_results(self) -> ScanResults:
"""Return meta scan results; block if the scan is not yet complete."""
if self.metascan is None:
if self.scanresults is None:
print('WARNING: ba.meta.get_scan_results()'
' called before scan completed.'
' This can cause hitches.')
@ -141,12 +141,12 @@ class MetadataSubsystem:
# Now wait a bit for the scan to complete.
# Eventually error though if it doesn't.
starttime = time.time()
while self.metascan is None:
while self.scanresults is None:
time.sleep(0.05)
if time.time() - starttime > 10.0:
raise TimeoutError(
'timeout waiting for meta scan to complete.')
return self.metascan
return self.scanresults
def get_game_types(self) -> list[type[ba.GameActivity]]:
"""Return available game types."""
@ -206,7 +206,7 @@ class ScanThread(threading.Thread):
# We also, however, immediately make results available.
# This is because the game thread may be blocked waiting
# for them so we can't push a call or we'd get deadlock.
_ba.app.meta.metascan = results
_ba.app.meta.scanresults = results
class DirectoryScan:
@ -288,7 +288,7 @@ 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:
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.\n')
@ -403,13 +403,13 @@ class DirectoryScan:
if len(lines) > 1:
self.results.warnings += (
'Warning: ' + str(subpath) +
': multiple "# ba_meta api require <NUM>" lines found;'
': multiple "# ba_meta require api <NUM>" lines found;'
' ignoring module.\n')
elif not lines and toplevel and meta_lines:
# If we're a top-level module containing meta lines but
# no valid api require, complain.
# no valid "require api" line found, complain.
self.results.warnings += (
'Warning: ' + str(subpath) +
': no valid "# ba_meta api require <NUM>" line found;'
': no valid "# ba_meta require api <NUM>" line found;'
' ignoring module.\n')
return None

View file

@ -211,7 +211,7 @@ class MusicSubsystem:
return 'Mac' in uas
if entry_type in ('musicFile', 'musicFolder'):
return ('android' in uas
and _ba.android_get_external_storage_path() is not None)
and _ba.android_get_external_files_dir() is not None)
if entry_type == 'default':
return True
return False

View file

@ -3,6 +3,7 @@
"""Networking related functionality."""
from __future__ import annotations
import ssl
import copy
import threading
import weakref
@ -29,14 +30,32 @@ class NetworkSubsystem:
# as it is updated by a background thread.
self.zone_pings_lock = threading.Lock()
# Region IDs mapped to average pings. This will remain empty
# 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:
@ -108,30 +127,36 @@ class MasterServerCallThread(threading.Thread):
self._callback(arg)
def run(self) -> None:
# pylint: disable=too-many-branches, consider-using-with
# pylint: disable=consider-using-with
import urllib.request
import urllib.parse
import urllib.error
import json
from efro.error import is_urllib_network_error
from efro.error import is_urllib_communication_error
from ba import _general
response_data: Any = None
url: str | None = None
try:
self._data = _general.utf8_all(self._data)
_ba.set_thread_name('BA_ServerCallThread')
if self._request_type == 'get':
url = (_ba.get_master_server_address() + '/' + self._request +
'?' + urllib.parse.urlencode(self._data))
response = urllib.request.urlopen(
urllib.request.Request(
(_ba.get_master_server_address() + '/' +
self._request + '?' +
urllib.parse.urlencode(self._data)), None,
{'User-Agent': _ba.app.user_agent_string}),
url, None, {'User-Agent': _ba.app.user_agent_string}),
context=_ba.app.net.sslcontext,
timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS)
elif self._request_type == 'post':
url = _ba.get_master_server_address() + '/' + self._request
response = urllib.request.urlopen(
urllib.request.Request(
_ba.get_master_server_address() + '/' + self._request,
url,
urllib.parse.urlencode(self._data).encode(),
{'User-Agent': _ba.app.user_agent_string}),
context=_ba.app.net.sslcontext,
timeout=DEFAULT_REQUEST_TIMEOUT_SECONDS)
else:
raise TypeError('Invalid request_type: ' + self._request_type)
@ -151,29 +176,18 @@ class MasterServerCallThread(threading.Thread):
raise TypeError(f'invalid responsetype: {self._response_type}')
except Exception as exc:
do_print = False
response_data = None
# Ignore common network errors; note unexpected ones.
if is_urllib_network_error(exc):
pass
elif (self._response_type == MasterServerResponseType.JSON
and isinstance(exc, json.decoder.JSONDecodeError)):
# FIXME: should handle this better; could mean either the
# server sent us bad data or it got corrupted along the way.
pass
else:
do_print = True
if do_print:
# Any other error here is unexpected,
# so let's make a note of it,
if not is_urllib_communication_error(exc, url=url):
print(f'Error in MasterServerCallThread'
f' (response-type={self._response_type},'
f' (url={url},'
f' response-type={self._response_type},'
f' response-data={response_data}):')
import traceback
traceback.print_exc()
response_data = None
if self._callback is not None:
_ba.pushcall(_general.Call(self._run_callback, response_data),
from_other_thread=True)

View file

@ -112,13 +112,15 @@ class PluginSubsystem:
# 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))
_ba.screenmessage(
Lstr(resource='pluginsRemovedText',
subs=[('${NUM}', str(len(disappeared_plugs)))]),
color=(1, 1, 0),
)
plugnames = ', '.join(disappeared_plugs)
_ba.log(
f'{len(disappeared_plugs)} plugin(s) no longer found:'
f' {disappeared_plugs}',
f' {plugnames}.',
to_server=False)
for goneplug in disappeared_plugs:
del _ba.app.config['Plugins'][goneplug]

View file

@ -115,7 +115,7 @@ class WorkspaceSubsystem:
tpartial(
self._errmsg,
Lstr(resource='workspaceSyncReuseText',
subs=[('$WORKSPACE', workspacename)])),
subs=[('${WORKSPACE}', workspacename)])),
from_other_thread=True,
)

View file

@ -17,23 +17,29 @@ def get_human_readable_user_scripts_path() -> str:
This is NOT a valid filesystem path; may be something like "(SD Card)".
"""
from ba import _language
app = _ba.app
path: str | None = app.python_directory_user
if path is None:
return '<Not Available>'
# On newer versions of android, the user's external storage dir is probably
# only visible to the user's processes and thus not really useful printed
# in its entirety; lets print it as <External Storage>/myfilepath.
# These days, on Android, we use getExternalFilesDir() as the base of our
# app's user-scripts dir, which gives us paths like:
# /storage/emulated/0/Android/data/net.froemling.bombsquad/files
# Userspace apps tend to show that as:
# Android/data/net.froemling.bombsquad/files
# We'd like to display it that way, but I'm not sure if there's a clean
# way to get the root of the external storage area (/storage/emulated/0)
# so that we could strip it off. There is
# Environment.getExternalStorageDirectory() but that is deprecated.
# So for now let's just be conservative and trim off recognized prefixes
# and show the whole ugly path as a fallback.
# Note that we used to use externalStorageText resource but gonna try
# without it for now. (simply 'foo' instead of <External Storage>/foo).
if app.platform == 'android':
ext_storage_path: str | None = (
_ba.android_get_external_storage_path())
if (ext_storage_path is not None
and app.python_directory_user.startswith(ext_storage_path)):
path = ('<' +
_language.Lstr(resource='externalStorageText').evaluate() +
'>' + app.python_directory_user[len(ext_storage_path):])
for pre in ['/storage/emulated/0/']:
if path.startswith(pre):
path = path.removeprefix(pre)
break
return path

View file

@ -14,7 +14,7 @@ if TYPE_CHECKING:
# Version is sent to the master-server with all commands. Can be incremented
# if we need to change behavior server-side to go along with client changes.
BACLOUD_VERSION = 6
BACLOUD_VERSION = 7
@ioprepped

View file

@ -76,6 +76,22 @@ class LoginProxyCompleteMessage(Message):
proxyid: Annotated[str, IOAttrs('p')]
@ioprepped
@dataclass
class PingMessage(Message):
"""Standard ping."""
@classmethod
def get_response_types(cls) -> list[type[Response]]:
return [PingResponse]
@ioprepped
@dataclass
class PingResponse(Response):
"""pong."""
@ioprepped
@dataclass
class TestMessage(Message):

View file

@ -4,6 +4,7 @@
from __future__ import annotations
import datetime
from typing import TYPE_CHECKING, Any, Annotated
from dataclasses import dataclass, field
@ -27,6 +28,9 @@ class ServerNodeEntry:
class ServerNodeQueryResponse:
"""A response to a query about server-nodes."""
# The current utc time on the master server.
time: Annotated[datetime.datetime, IOAttrs('t')]
# If present, something went wrong, and this describes it.
error: Annotated[str | None, IOAttrs('e', store_default=False)] = None

View file

@ -5,13 +5,14 @@
from __future__ import annotations
import os
from pathlib import Path
from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated
from efro.dataclassio import ioprepped, IOAttrs
if TYPE_CHECKING:
from pathlib import Path
pass
@ioprepped
@ -40,15 +41,17 @@ class DirectoryManifest:
paths: list[str] = []
if path.is_dir():
# Build the full list of package-relative paths.
# Build the full list of relative paths.
for basename, _dirnames, filenames in os.walk(path):
for filename in filenames:
fullname = os.path.join(basename, filename)
assert fullname.startswith(pathstr)
paths.append(fullname[len(pathstr) + 1:])
# Make sure we end up with forward slashes no matter
# what the os.* stuff above here was using.
paths.append(Path(fullname[len(pathstr) + 1:]).as_posix())
elif path.exists():
# Just return a single file entry if path is not a dir.
paths.append(pathstr)
paths.append(path.as_posix())
def _get_file_info(filepath: str) -> tuple[str, DirectoryManifestFile]:
sha = hashlib.sha256()
@ -70,6 +73,18 @@ class DirectoryManifest:
with ThreadPoolExecutor(max_workers=cpus) as executor:
return cls(files=dict(executor.map(_get_file_info, paths)))
def validate(self) -> None:
"""Log any odd data in the manifest; for debugging."""
import logging
for fpath, _fentry in self.files.items():
# We want to be dealing in only forward slashes; make sure
# that's the case (wondering if we'll ever see backslashes
# for escape purposes).
if '\\' in fpath:
logging.exception("Found unusual path in manifest: '%s'.",
fpath)
break # 1 error is enough for now.
@classmethod
def get_empty_hash(cls) -> str:
"""Return the hash for an empty file."""

View file

@ -606,6 +606,7 @@ class Spaz(ba.Actor):
"""
assert self.node
self.node.boxing_gloves = True
self._has_boxing_gloves = True
if self._demo_mode: # Preserve old behavior.
self._punch_power_scale = 1.7
self._punch_cooldown = 300
@ -754,7 +755,6 @@ class Spaz(ba.Actor):
ba.WeakCall(self._bomb_wear_off),
timeformat=ba.TimeFormat.MILLISECONDS))
elif msg.poweruptype == 'punch':
self._has_boxing_gloves = True
tex = PowerupBoxFactory.get().tex_punch
self._flash_billboard(tex)
self.equip_boxing_gloves()

View file

@ -5,6 +5,9 @@
# Yes this is a long one..
# pylint: disable=too-many-lines
# ba_meta require api 7
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
import math

View file

@ -5,6 +5,9 @@
# We wear the cone of shame.
# pylint: disable=too-many-lines
# ba_meta require api 7
# (see https://ballistica.net/wiki/meta-tag-system)
from __future__ import annotations
import random

View file

@ -6,7 +6,6 @@
from __future__ import annotations
import copy
from typing import TYPE_CHECKING
import _ba
@ -18,6 +17,8 @@ from bastd.ui.store.browser import StoreBrowserWindow
if TYPE_CHECKING:
from typing import Any
from bastd.ui.coop.tournamentbutton import TournamentButton
class CoopBrowserWindow(ba.Window):
"""Window for browsing co-op levels/games/etc."""
@ -175,8 +176,6 @@ class CoopBrowserWindow(ba.Window):
'Selected Coop Campaign Level', None))
self._selected_custom_level = (cfg.get('Selected Coop Custom Level',
None))
self._selected_challenge_level = (cfg.get(
'Selected Coop Challenge Level', None))
# Don't want initial construction affecting our last-selected.
self._do_selection_callbacks = False
@ -283,6 +282,7 @@ class CoopBrowserWindow(ba.Window):
import bastd.ui.tournamentscores as _unused8
import bastd.ui.tournamententry as _unused9
import bastd.ui.play as _unused10
import bastd.ui.coop.tournamentbutton as _unused11
def _update(self) -> None:
# Do nothing if we've somehow outlived our actual UI.
@ -335,21 +335,21 @@ class CoopBrowserWindow(ba.Window):
# Decrement time on our tournament buttons.
ads_enabled = _ba.have_incentivized_ad()
for tbtn in self._tournament_buttons:
tbtn['time_remaining'] = max(0, tbtn['time_remaining'] - 1)
if tbtn['time_remaining_value_text'] is not None:
tbtn.time_remaining = max(0, tbtn.time_remaining - 1)
if tbtn.time_remaining_value_text is not None:
ba.textwidget(
edit=tbtn['time_remaining_value_text'],
text=ba.timestring(tbtn['time_remaining'],
edit=tbtn.time_remaining_value_text,
text=ba.timestring(tbtn.time_remaining,
centi=False,
suppress_format_warning=True) if
(tbtn['has_time_remaining']
(tbtn.has_time_remaining
and self._tourney_data_up_to_date) else '-')
# Also adjust the ad icon visibility.
if tbtn.get('allow_ads', False) and _ba.has_video_ads():
ba.imagewidget(edit=tbtn['entry_fee_ad_image'],
if tbtn.allow_ads and _ba.has_video_ads():
ba.imagewidget(edit=tbtn.entry_fee_ad_image,
opacity=1.0 if ads_enabled else 0.25)
ba.textwidget(edit=tbtn['entry_fee_text_remaining'],
ba.textwidget(edit=tbtn.entry_fee_text_remaining,
color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2))
self._update_hard_mode_lock_image()
@ -363,232 +363,21 @@ class CoopBrowserWindow(ba.Window):
ba.print_exception('Error updating campaign lock.')
def _update_for_data(self, data: list[dict[str, Any]] | None) -> None:
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
from ba.internal import getcampaign, get_tournament_prize_strings
# If the number of tournaments or challenges in the data differs from
# our current arrangement, refresh with the new number.
if ((data is None and self._tournament_button_count != 0)
or (data is not None and
(len(data) != self._tournament_button_count))):
self._tournament_button_count = len(
data) if data is not None else 0
self._tournament_button_count = (len(data)
if data is not None else 0)
ba.app.config['Tournament Rows'] = self._tournament_button_count
self._refresh()
# Update all of our tourney buttons based on whats in data.
for i, tbtn in enumerate(self._tournament_buttons):
assert data is not None
entry: dict[str, Any] = data[i]
prize_y_offs = (34 if 'prizeRange3' in entry else
20 if 'prizeRange2' in entry else 12)
x_offs = 90
# This seems to be a false alarm.
# pylint: disable=unbalanced-tuple-unpacking
pr1, pv1, pr2, pv2, pr3, pv3 = (
get_tournament_prize_strings(entry))
# pylint: enable=unbalanced-tuple-unpacking
enabled = 'requiredLeague' not in entry
ba.buttonwidget(edit=tbtn['button'],
color=(0.5, 0.7, 0.2) if enabled else
(0.5, 0.5, 0.5))
ba.imagewidget(edit=tbtn['lock_image'],
opacity=0.0 if enabled else 1.0)
ba.textwidget(edit=tbtn['prize_range_1_text'],
text='-' if pr1 == '' else pr1,
position=(tbtn['button_x'] + 365 + x_offs,
tbtn['button_y'] + tbtn['button_scale_y'] -
93 + prize_y_offs))
# We want to draw values containing tickets a bit smaller
# (scratch that; we now draw medals a bit bigger).
ticket_char = ba.charstr(ba.SpecialChar.TICKET_BACKING)
prize_value_scale_large = 1.0
prize_value_scale_small = 1.0
ba.textwidget(edit=tbtn['prize_value_1_text'],
text='-' if pv1 == '' else pv1,
scale=prize_value_scale_large if ticket_char
not in pv1 else prize_value_scale_small,
position=(tbtn['button_x'] + 380 + x_offs,
tbtn['button_y'] + tbtn['button_scale_y'] -
93 + prize_y_offs))
ba.textwidget(edit=tbtn['prize_range_2_text'],
text=pr2,
position=(tbtn['button_x'] + 365 + x_offs,
tbtn['button_y'] + tbtn['button_scale_y'] -
93 - 45 + prize_y_offs))
ba.textwidget(edit=tbtn['prize_value_2_text'],
text=pv2,
scale=prize_value_scale_large if ticket_char
not in pv2 else prize_value_scale_small,
position=(tbtn['button_x'] + 380 + x_offs,
tbtn['button_y'] + tbtn['button_scale_y'] -
93 - 45 + prize_y_offs))
ba.textwidget(edit=tbtn['prize_range_3_text'],
text=pr3,
position=(tbtn['button_x'] + 365 + x_offs,
tbtn['button_y'] + tbtn['button_scale_y'] -
93 - 90 + prize_y_offs))
ba.textwidget(edit=tbtn['prize_value_3_text'],
text=pv3,
scale=prize_value_scale_large if ticket_char
not in pv3 else prize_value_scale_small,
position=(tbtn['button_x'] + 380 + x_offs,
tbtn['button_y'] + tbtn['button_scale_y'] -
93 - 90 + prize_y_offs))
leader_name = '-'
leader_score: str | ba.Lstr = '-'
if entry['scores']:
score = tbtn['leader'] = copy.deepcopy(entry['scores'][0])
leader_name = score[1]
leader_score = (ba.timestring(
score[0] * 10,
centi=True,
timeformat=ba.TimeFormat.MILLISECONDS,
suppress_format_warning=True) if entry['scoreType']
== 'time' else str(score[0]))
else:
tbtn['leader'] = None
ba.textwidget(edit=tbtn['current_leader_name_text'],
text=ba.Lstr(value=leader_name))
self._tournament_leader_score_type = (entry['scoreType'])
ba.textwidget(edit=tbtn['current_leader_score_text'],
text=leader_score)
ba.buttonwidget(edit=tbtn['more_scores_button'],
label=ba.Lstr(resource=self._r + '.seeMoreText'))
out_of_time_text: str | ba.Lstr = (
'-' if 'totalTime' not in entry else ba.Lstr(
resource=self._r + '.ofTotalTimeText',
subs=[('${TOTAL}',
ba.timestring(entry['totalTime'],
centi=False,
suppress_format_warning=True))]))
ba.textwidget(edit=tbtn['time_remaining_out_of_text'],
text=out_of_time_text)
tbtn['time_remaining'] = entry['timeRemaining']
tbtn['has_time_remaining'] = entry is not None
tbtn['tournament_id'] = entry['tournamentID']
tbtn['required_league'] = (None if 'requiredLeague' not in entry
else entry['requiredLeague'])
game = ba.app.accounts_v1.tournament_info[
tbtn['tournament_id']]['game']
if game is None:
ba.textwidget(edit=tbtn['button_text'], text='-')
ba.imagewidget(edit=tbtn['image'],
texture=ba.gettexture('black'),
opacity=0.2)
else:
campaignname, levelname = game.split(':')
campaign = getcampaign(campaignname)
max_players = ba.app.accounts_v1.tournament_info[
tbtn['tournament_id']]['maxPlayers']
txt = ba.Lstr(
value='${A} ${B}',
subs=[('${A}', campaign.getlevel(levelname).displayname),
('${B}',
ba.Lstr(resource='playerCountAbbreviatedText',
subs=[('${COUNT}', str(max_players))]))])
ba.textwidget(edit=tbtn['button_text'], text=txt)
ba.imagewidget(
edit=tbtn['image'],
texture=campaign.getlevel(levelname).get_preview_texture(),
opacity=1.0 if enabled else 0.5)
fee = entry['fee']
if fee is None:
fee_var = None
elif fee == 4:
fee_var = 'price.tournament_entry_4'
elif fee == 3:
fee_var = 'price.tournament_entry_3'
elif fee == 2:
fee_var = 'price.tournament_entry_2'
elif fee == 1:
fee_var = 'price.tournament_entry_1'
else:
if fee != 0:
print('Unknown fee value:', fee)
fee_var = 'price.tournament_entry_0'
tbtn['allow_ads'] = allow_ads = entry['allowAds']
final_fee: int | None = (None if fee_var is None else
_ba.get_v1_account_misc_read_val(
fee_var, '?'))
final_fee_str: str | ba.Lstr
if fee_var is None:
final_fee_str = ''
else:
if final_fee == 0:
final_fee_str = ba.Lstr(
resource='getTicketsWindow.freeText')
else:
final_fee_str = (
ba.charstr(ba.SpecialChar.TICKET_BACKING) +
str(final_fee))
ad_tries_remaining = ba.app.accounts_v1.tournament_info[
tbtn['tournament_id']]['adTriesRemaining']
free_tries_remaining = ba.app.accounts_v1.tournament_info[
tbtn['tournament_id']]['freeTriesRemaining']
# Now, if this fee allows ads and we support video ads, show
# the 'or ad' version.
if allow_ads and _ba.has_video_ads():
ads_enabled = _ba.have_incentivized_ad()
ba.imagewidget(edit=tbtn['entry_fee_ad_image'],
opacity=1.0 if ads_enabled else 0.25)
or_text = ba.Lstr(resource='orText',
subs=[('${A}', ''),
('${B}', '')]).evaluate().strip()
ba.textwidget(edit=tbtn['entry_fee_text_or'], text=or_text)
ba.textwidget(
edit=tbtn['entry_fee_text_top'],
position=(tbtn['button_x'] + 360,
tbtn['button_y'] + tbtn['button_scale_y'] - 60),
scale=1.3,
text=final_fee_str)
# Possibly show number of ad-plays remaining.
ba.textwidget(
edit=tbtn['entry_fee_text_remaining'],
position=(tbtn['button_x'] + 360,
tbtn['button_y'] + tbtn['button_scale_y'] - 146),
text='' if ad_tries_remaining in [None, 0] else
('' + str(ad_tries_remaining)),
color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2))
else:
ba.imagewidget(edit=tbtn['entry_fee_ad_image'], opacity=0.0)
ba.textwidget(edit=tbtn['entry_fee_text_or'], text='')
ba.textwidget(
edit=tbtn['entry_fee_text_top'],
position=(tbtn['button_x'] + 360,
tbtn['button_y'] + tbtn['button_scale_y'] - 80),
scale=1.3,
text=final_fee_str)
# Possibly show number of free-plays remaining.
ba.textwidget(
edit=tbtn['entry_fee_text_remaining'],
position=(tbtn['button_x'] + 360,
tbtn['button_y'] + tbtn['button_scale_y'] - 100),
text=('' if (free_tries_remaining in [None, 0]
or final_fee != 0) else
('' + str(free_tries_remaining))),
color=(0.6, 0.6, 0.6, 1))
tbtn.update_for_data(data[i])
def _on_tournament_query_response(self,
data: dict[str, Any] | None) -> None:
@ -715,10 +504,13 @@ class CoopBrowserWindow(ba.Window):
items = [
campaignname + ':Onslaught Training',
campaignname + ':Rookie Onslaught',
campaignname + ':Rookie Football', campaignname + ':Pro Onslaught',
campaignname + ':Pro Football', campaignname + ':Pro Runaround',
campaignname + ':Uber Onslaught', campaignname + ':Uber Football',
campaignname + ':Uber Runaround'
campaignname + ':Rookie Football',
campaignname + ':Pro Onslaught',
campaignname + ':Pro Football',
campaignname + ':Pro Runaround',
campaignname + ':Uber Onslaught',
campaignname + ':Uber Football',
campaignname + ':Uber Runaround',
]
items += [campaignname + ':The Last Stand']
if self._selected_campaign_level is None:
@ -772,6 +564,7 @@ class CoopBrowserWindow(ba.Window):
# pylint: disable=too-many-locals
# pylint: disable=cyclic-import
from bastd.ui.coop.gamebutton import GameButton
from bastd.ui.coop.tournamentbutton import TournamentButton
# (Re)create the sub-container if need be.
if self._subcontainer is not None:
@ -839,7 +632,7 @@ class CoopBrowserWindow(ba.Window):
# Tournaments
self._tournament_buttons: list[dict[str, Any]] = []
self._tournament_buttons: list[TournamentButton] = []
v -= 53
# FIXME shouldn't use hard-coded strings here.
@ -919,10 +712,15 @@ class CoopBrowserWindow(ba.Window):
v2 = -2
is_last_sel = True
self._tournament_buttons.append(
self._tournament_button(sc2, h, v2, is_last_sel))
TournamentButton(sc2,
h,
v2,
is_last_sel,
on_pressed=ba.WeakCall(
self.run_tournament)))
v -= 200
# Custom Games.
# Custom Games. (called 'Practice' in UI these days).
v -= 50
ba.textwidget(parent=w_parent,
position=(h_base + 27, v + 30 + 198),
@ -949,14 +747,13 @@ class CoopBrowserWindow(ba.Window):
if _ba.get_v1_account_misc_read_val(
'easter', False) or _ba.get_purchased('games.easter_egg_hunt'):
items = [
'Challenges:Easter Egg Hunt', 'Challenges:Pro Easter Egg Hunt'
'Challenges:Easter Egg Hunt',
'Challenges:Pro Easter Egg Hunt',
] + items
# add all custom user levels here..
# items += [
# 'User:' + l.getname()
# for l in getcampaign('User').getlevels()
# ]
# If we've defined custom games, put them at the beginning.
if ba.app.custom_coop_practice_games:
items = ba.app.custom_coop_practice_games + items
self._custom_h_scroll = custom_h_scroll = h_scroll = ba.hscrollwidget(
parent=w_parent,
@ -995,19 +792,19 @@ class CoopBrowserWindow(ba.Window):
for i, tbutton in enumerate(self._tournament_buttons):
ba.widget(
edit=tbutton['button'],
edit=tbutton.button,
up_widget=self._tournament_info_button
if i == 0 else self._tournament_buttons[i - 1]['button'],
down_widget=self._tournament_buttons[(i + 1)]['button']
if i == 0 else self._tournament_buttons[i - 1].button,
down_widget=self._tournament_buttons[(i + 1)].button
if i + 1 < len(self._tournament_buttons) else custom_h_scroll)
ba.widget(
edit=tbutton['more_scores_button'],
edit=tbutton.more_scores_button,
down_widget=self._tournament_buttons[(
i + 1)]['current_leader_name_text']
i + 1)].current_leader_name_text
if i + 1 < len(self._tournament_buttons) else custom_h_scroll)
ba.widget(edit=tbutton['current_leader_name_text'],
ba.widget(edit=tbutton.current_leader_name_text,
up_widget=self._tournament_info_button if i == 0 else
self._tournament_buttons[i - 1]['more_scores_button'])
self._tournament_buttons[i - 1].more_scores_button)
for btn in self._custom_buttons:
try:
@ -1037,314 +834,6 @@ class CoopBrowserWindow(ba.Window):
def _enable_selectable_callback(self) -> None:
self._do_selection_callbacks = True
def _tournament_button(self, parent: ba.Widget, x: float, y: float,
select: bool) -> dict[str, Any]:
sclx = 300
scly = 195.0
data: dict[str, Any] = {
'tournament_id': None,
'time_remaining': 0,
'has_time_remaining': False,
'leader': None
}
data['button'] = btn = ba.buttonwidget(
parent=parent,
position=(x + 23, y + 4),
size=(sclx, scly),
label='',
button_type='square',
autoselect=True,
on_activate_call=lambda: self.run(None, tournament_button=data))
ba.widget(edit=btn,
show_buffer_bottom=50,
show_buffer_top=50,
show_buffer_left=400,
show_buffer_right=200)
if select:
ba.containerwidget(edit=parent,
selected_child=btn,
visible_child=btn)
image_width = sclx * 0.85 * 0.75
data['image'] = ba.imagewidget(
parent=parent,
draw_controller=btn,
position=(x + 21 + sclx * 0.5 - image_width * 0.5, y + scly - 150),
size=(image_width, image_width * 0.5),
model_transparent=self.lsbt,
model_opaque=self.lsbo,
texture=ba.gettexture('black'),
opacity=0.2,
mask_texture=ba.gettexture('mapPreviewMask'))
data['lock_image'] = ba.imagewidget(
parent=parent,
draw_controller=btn,
position=(x + 21 + sclx * 0.5 - image_width * 0.25,
y + scly - 150),
size=(image_width * 0.5, image_width * 0.5),
texture=ba.gettexture('lock'),
opacity=0.0)
data['button_text'] = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 20 + sclx * 0.5,
y + scly - 35),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=sclx * 0.76,
scale=0.85,
color=(0.8, 1.0, 0.8, 1.0))
header_color = (0.43, 0.4, 0.5, 1)
value_color = (0.6, 0.6, 0.6, 1)
x_offs = 0
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.entryFeeText'),
v_align='center',
maxwidth=100,
scale=0.9,
color=header_color,
flatness=1.0)
data['entry_fee_text_top'] = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360,
y + scly - 60),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=60,
scale=1.3,
color=value_color,
flatness=1.0)
data['entry_fee_text_or'] = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360,
y + scly - 90),
size=(0, 0),
h_align='center',
text='',
v_align='center',
maxwidth=60,
scale=0.5,
color=value_color,
flatness=1.0)
data['entry_fee_text_remaining'] = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360, y +
scly - 90),
size=(0, 0),
h_align='center',
text='',
v_align='center',
maxwidth=60,
scale=0.5,
color=value_color,
flatness=1.0)
data['entry_fee_ad_image'] = ba.imagewidget(
parent=parent,
size=(40, 40),
draw_controller=btn,
position=(x + 360 - 20, y + scly - 140),
opacity=0.0,
texture=ba.gettexture('tv'))
x_offs += 50
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 447 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.prizesText'),
v_align='center',
maxwidth=130,
scale=0.9,
color=header_color,
flatness=1.0)
data['button_x'] = x
data['button_y'] = y
data['button_scale_y'] = scly
xo2 = 0
prize_value_scale = 1.5
data['prize_range_1_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 355 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='right',
v_align='center',
maxwidth=50,
text='-',
scale=0.8,
color=header_color,
flatness=1.0)
data['prize_value_1_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 380 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='left',
text='-',
v_align='center',
maxwidth=100,
scale=prize_value_scale,
color=value_color,
flatness=1.0)
data['prize_range_2_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 355 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='right',
v_align='center',
maxwidth=50,
scale=0.8,
color=header_color,
flatness=1.0)
data['prize_value_2_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 380 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='left',
text='',
v_align='center',
maxwidth=100,
scale=prize_value_scale,
color=value_color,
flatness=1.0)
data['prize_range_3_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 355 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='right',
v_align='center',
maxwidth=50,
scale=0.8,
color=header_color,
flatness=1.0)
data['prize_value_3_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 380 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='left',
text='',
v_align='center',
maxwidth=100,
scale=prize_value_scale,
color=value_color,
flatness=1.0)
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 620 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.currentBestText'),
v_align='center',
maxwidth=180,
scale=0.9,
color=header_color,
flatness=1.0)
data['current_leader_name_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 620 + x_offs - (170 / 1.4) * 0.5,
y + scly - 60 - 40 * 0.5),
selectable=True,
click_activate=True,
autoselect=True,
on_activate_call=lambda: self._show_leader(tournament_button=data),
size=(170 / 1.4, 40),
h_align='center',
text='-',
v_align='center',
maxwidth=170,
scale=1.4,
color=value_color,
flatness=1.0)
data['current_leader_score_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 620 + x_offs, y + scly - 113 + 10),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=170,
scale=1.8,
color=value_color,
flatness=1.0)
data['more_scores_button'] = ba.buttonwidget(
parent=parent,
position=(x + 620 + x_offs - 60, y + scly - 50 - 125),
color=(0.5, 0.5, 0.6),
textcolor=(0.7, 0.7, 0.8),
label='-',
size=(120, 40),
autoselect=True,
up_widget=data['current_leader_name_text'],
text_scale=0.6,
on_activate_call=lambda: self._show_scores(tournament_button=data))
ba.widget(edit=data['current_leader_name_text'],
down_widget=data['more_scores_button'])
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 820 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.timeRemainingText'),
v_align='center',
maxwidth=180,
scale=0.9,
color=header_color,
flatness=1.0)
data['time_remaining_value_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 820 + x_offs, y + scly - 68),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=180,
scale=2.0,
color=value_color,
flatness=1.0)
data['time_remaining_out_of_text'] = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 820 + x_offs, y + scly - 110),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=120,
scale=0.72,
color=(0.4, 0.4, 0.5),
flatness=1.0)
return data
def _switch_to_league_rankings(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.account import show_sign_in_prompt
@ -1378,100 +867,20 @@ class CoopBrowserWindow(ba.Window):
show_tab=show_tab,
back_location='CoopBrowserWindow').get_root_widget())
def _show_leader(self, tournament_button: dict[str, Any]) -> None:
# pylint: disable=cyclic-import
from bastd.ui.account.viewer import AccountViewerWindow
tournament_id = tournament_button['tournament_id']
# FIXME: This assumes a single player entry in leader; should expand
# this to work with multiple.
if tournament_id is None or tournament_button['leader'] is None or len(
tournament_button['leader'][2]) != 1:
ba.playsound(ba.getsound('error'))
return
ba.playsound(ba.getsound('swish'))
AccountViewerWindow(
account_id=tournament_button['leader'][2][0].get('a', None),
profile_id=tournament_button['leader'][2][0].get('p', None),
position=tournament_button['current_leader_name_text'].
get_screen_space_center())
def _show_scores(self, tournament_button: dict[str, Any]) -> None:
# pylint: disable=cyclic-import
from bastd.ui.tournamentscores import TournamentScoresWindow
tournament_id = tournament_button['tournament_id']
if tournament_id is None:
ba.playsound(ba.getsound('error'))
return
TournamentScoresWindow(
tournament_id=tournament_id,
position=tournament_button['more_scores_button'].
get_screen_space_center())
def is_tourney_data_up_to_date(self) -> bool:
"""Return whether our tourney data is up to date."""
return self._tourney_data_up_to_date
def run(self,
game: str | None,
tournament_button: dict[str, Any] | None = None) -> None:
def run_game(self, game: str) -> None:
"""Run the provided game."""
# pylint: disable=too-many-branches
# pylint: disable=too-many-statements
# pylint: disable=too-many-return-statements
# pylint: disable=cyclic-import
from bastd.ui.confirm import ConfirmWindow
from bastd.ui.tournamententry import TournamentEntryWindow
from bastd.ui.purchase import PurchaseWindow
from bastd.ui.account import show_sign_in_prompt
args: dict[str, Any] = {}
# Do a bit of pre-flight for tournament options.
if tournament_button is not None:
if _ba.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return
if not self._tourney_data_up_to_date:
ba.screenmessage(
ba.Lstr(resource='tournamentCheckingStateText'),
color=(1, 1, 0))
ba.playsound(ba.getsound('error'))
return
if tournament_button['tournament_id'] is None:
ba.screenmessage(
ba.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
return
if tournament_button['required_league'] is not None:
ba.screenmessage(ba.Lstr(
resource='league.tournamentLeagueText',
subs=[
('${NAME}',
ba.Lstr(
translate=('leagueNames',
tournament_button['required_league'])))
]),
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
return
if tournament_button['time_remaining'] <= 0:
ba.screenmessage(ba.Lstr(resource='tournamentEndedText'),
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
return
# Game is whatever the tournament tells us it is.
game = ba.app.accounts_v1.tournament_info[
tournament_button['tournament_id']]['game']
if tournament_button is None and game == 'Easy:The Last Stand':
if game == 'Easy:The Last Stand':
ConfirmWindow(ba.Lstr(resource='difficultyHardUnlockOnlyText',
fallback_resource='difficultyHardOnlyText'),
cancel_button=False,
@ -1479,12 +888,11 @@ class CoopBrowserWindow(ba.Window):
height=130)
return
# Infinite onslaught/runaround require pro; bring up a store link if
# need be.
if tournament_button is None and game in (
'Challenges:Infinite Runaround',
'Challenges:Infinite Onslaught'
) and not ba.app.accounts_v1.have_pro():
# Infinite onslaught/runaround require pro; bring up a store link
# if need be.
if game in ('Challenges:Infinite Runaround',
'Challenges:Infinite Onslaught'
) and not ba.app.accounts_v1.have_pro():
if _ba.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
else:
@ -1495,7 +903,8 @@ class CoopBrowserWindow(ba.Window):
if game in ['Challenges:Meteor Shower']:
required_purchase = 'games.meteor_shower'
elif game in [
'Challenges:Target Practice', 'Challenges:Target Practice B'
'Challenges:Target Practice',
'Challenges:Target Practice B',
]:
required_purchase = 'games.target_practice'
elif game in ['Challenges:Ninja Fight']:
@ -1503,13 +912,14 @@ class CoopBrowserWindow(ba.Window):
elif game in ['Challenges:Pro Ninja Fight']:
required_purchase = 'games.ninja_fight'
elif game in [
'Challenges:Easter Egg Hunt', 'Challenges:Pro Easter Egg Hunt'
'Challenges:Easter Egg Hunt',
'Challenges:Pro Easter Egg Hunt',
]:
required_purchase = 'games.easter_egg_hunt'
else:
required_purchase = None
if (tournament_button is None and required_purchase is not None
if (required_purchase is not None
and not _ba.get_purchased(required_purchase)):
if _ba.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
@ -1519,17 +929,57 @@ class CoopBrowserWindow(ba.Window):
self._save_state()
# For tournaments, we pop up the entry window.
if tournament_button is not None:
TournamentEntryWindow(
tournament_id=tournament_button['tournament_id'],
position=tournament_button['button'].get_screen_space_center())
else:
# Otherwise just dive right in.
assert game is not None
if ba.app.launch_coop_game(game, args=args):
ba.containerwidget(edit=self._root_widget,
transition='out_left')
if ba.app.launch_coop_game(game, args=args):
ba.containerwidget(edit=self._root_widget, transition='out_left')
def run_tournament(self, tournament_button: TournamentButton) -> None:
"""Run the provided tournament game."""
from bastd.ui.account import show_sign_in_prompt
from bastd.ui.tournamententry import TournamentEntryWindow
if _ba.get_v1_account_state() != 'signed_in':
show_sign_in_prompt()
return
if not self._tourney_data_up_to_date:
ba.screenmessage(ba.Lstr(resource='tournamentCheckingStateText'),
color=(1, 1, 0))
ba.playsound(ba.getsound('error'))
return
if tournament_button.tournament_id is None:
ba.screenmessage(
ba.Lstr(resource='internal.unavailableNoConnectionText'),
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
return
if tournament_button.required_league is not None:
ba.screenmessage(
ba.Lstr(
resource='league.tournamentLeagueText',
subs=[('${NAME}',
ba.Lstr(
translate=('leagueNames',
tournament_button.required_league)))
]),
color=(1, 0, 0),
)
ba.playsound(ba.getsound('error'))
return
if tournament_button.time_remaining <= 0:
ba.screenmessage(ba.Lstr(resource='tournamentEndedText'),
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))
return
self._save_state()
assert tournament_button.tournament_id is not None
TournamentEntryWindow(
tournament_id=tournament_button.tournament_id,
position=tournament_button.button.get_screen_space_center())
def _back(self) -> None:
# pylint: disable=cyclic-import
@ -1542,24 +992,6 @@ class CoopBrowserWindow(ba.Window):
ba.app.ui.set_main_menu_window(
PlayWindow(transition='in_left').get_root_widget())
def _restore_state(self) -> None:
try:
sel_name = ba.app.ui.window_states.get(type(self),
{}).get('sel_name')
if sel_name == 'Back':
sel = self._back_button
elif sel_name == 'Scroll':
sel = self._scrollwidget
elif sel_name == 'PowerRanking':
sel = self._league_rank_button_widget
elif sel_name == 'Store':
sel = self._store_button_widget
else:
sel = self._scrollwidget
ba.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception:
ba.print_exception(f'Error restoring state for {self}.')
def _save_state(self) -> None:
cfg = ba.app.config
try:
@ -1580,16 +1012,31 @@ class CoopBrowserWindow(ba.Window):
cfg['Selected Coop Row'] = self._selected_row
cfg['Selected Coop Custom Level'] = self._selected_custom_level
cfg['Selected Coop Challenge Level'] = self._selected_challenge_level
cfg['Selected Coop Campaign Level'] = self._selected_campaign_level
cfg.commit()
def _restore_state(self) -> None:
try:
sel_name = ba.app.ui.window_states.get(type(self),
{}).get('sel_name')
if sel_name == 'Back':
sel = self._back_button
elif sel_name == 'Scroll':
sel = self._scrollwidget
elif sel_name == 'PowerRanking':
sel = self._league_rank_button_widget
elif sel_name == 'Store':
sel = self._store_button_widget
else:
sel = self._scrollwidget
ba.containerwidget(edit=self._root_widget, selected_child=sel)
except Exception:
ba.print_exception(f'Error restoring state for {self}.')
def sel_change(self, row: str, game: str) -> None:
"""(internal)"""
if self._do_selection_callbacks:
if row == 'custom':
self._selected_custom_level = game
if row == 'challenges':
self._selected_challenge_level = game
elif row == 'campaign':
self._selected_campaign_level = game

View file

@ -55,7 +55,7 @@ class GameButton:
position=(x + 23, y + 4),
size=(sclx, scly),
label='',
on_activate_call=ba.Call(window.run, game),
on_activate_call=ba.Call(window.run_game, game),
button_type='square',
autoselect=True,
on_select_call=ba.Call(window.sel_change, row, game))

View file

@ -0,0 +1,561 @@
# Released under the MIT License. See LICENSE for details.
#
"""Defines button for co-op games."""
from __future__ import annotations
from typing import TYPE_CHECKING
import copy
import ba
import _ba
if TYPE_CHECKING:
from typing import Any, Callable
class TournamentButton:
"""Button showing a tournament in coop window."""
def __init__(self, parent: ba.Widget, x: float, y: float, select: bool,
on_pressed: Callable[[TournamentButton], None]) -> None:
self._r = 'coopSelectWindow'
sclx = 300
scly = 195.0
self.on_pressed = on_pressed
self.lsbt = ba.getmodel('level_select_button_transparent')
self.lsbo = ba.getmodel('level_select_button_opaque')
self.allow_ads = False
self.tournament_id: str | None = None
self.time_remaining: int = 0
self.has_time_remaining: bool = False
self.leader: Any = None
self.required_league: str | None = None
self.button = btn = ba.buttonwidget(
parent=parent,
position=(x + 23, y + 4),
size=(sclx, scly),
label='',
button_type='square',
autoselect=True,
# on_activate_call=lambda: self.run(None, tournament_button=data)
on_activate_call=ba.WeakCall(self._pressed))
ba.widget(edit=btn,
show_buffer_bottom=50,
show_buffer_top=50,
show_buffer_left=400,
show_buffer_right=200)
if select:
ba.containerwidget(edit=parent,
selected_child=btn,
visible_child=btn)
image_width = sclx * 0.85 * 0.75
self.image = ba.imagewidget(
parent=parent,
draw_controller=btn,
position=(x + 21 + sclx * 0.5 - image_width * 0.5, y + scly - 150),
size=(image_width, image_width * 0.5),
model_transparent=self.lsbt,
model_opaque=self.lsbo,
texture=ba.gettexture('black'),
opacity=0.2,
mask_texture=ba.gettexture('mapPreviewMask'))
self.lock_image = ba.imagewidget(
parent=parent,
draw_controller=btn,
position=(x + 21 + sclx * 0.5 - image_width * 0.25,
y + scly - 150),
size=(image_width * 0.5, image_width * 0.5),
texture=ba.gettexture('lock'),
opacity=0.0)
self.button_text = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 20 + sclx * 0.5,
y + scly - 35),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=sclx * 0.76,
scale=0.85,
color=(0.8, 1.0, 0.8, 1.0))
header_color = (0.43, 0.4, 0.5, 1)
value_color = (0.6, 0.6, 0.6, 1)
x_offs = 0
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.entryFeeText'),
v_align='center',
maxwidth=100,
scale=0.9,
color=header_color,
flatness=1.0)
self.entry_fee_text_top = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360,
y + scly - 60),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=60,
scale=1.3,
color=value_color,
flatness=1.0)
self.entry_fee_text_or = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360,
y + scly - 90),
size=(0, 0),
h_align='center',
text='',
v_align='center',
maxwidth=60,
scale=0.5,
color=value_color,
flatness=1.0)
self.entry_fee_text_remaining = ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 360,
y + scly - 90),
size=(0, 0),
h_align='center',
text='',
v_align='center',
maxwidth=60,
scale=0.5,
color=value_color,
flatness=1.0)
self.entry_fee_ad_image = ba.imagewidget(parent=parent,
size=(40, 40),
draw_controller=btn,
position=(x + 360 - 20,
y + scly - 140),
opacity=0.0,
texture=ba.gettexture('tv'))
x_offs += 50
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 447 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.prizesText'),
v_align='center',
maxwidth=130,
scale=0.9,
color=header_color,
flatness=1.0)
self.button_x = x
self.button_y = y
self.button_scale_y = scly
xo2 = 0
prize_value_scale = 1.5
self.prize_range_1_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 355 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='right',
v_align='center',
maxwidth=50,
text='-',
scale=0.8,
color=header_color,
flatness=1.0)
self.prize_value_1_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 380 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='left',
text='-',
v_align='center',
maxwidth=100,
scale=prize_value_scale,
color=value_color,
flatness=1.0)
self.prize_range_2_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 355 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='right',
v_align='center',
maxwidth=50,
scale=0.8,
color=header_color,
flatness=1.0)
self.prize_value_2_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 380 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='left',
text='',
v_align='center',
maxwidth=100,
scale=prize_value_scale,
color=value_color,
flatness=1.0)
self.prize_range_3_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 355 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='right',
v_align='center',
maxwidth=50,
scale=0.8,
color=header_color,
flatness=1.0)
self.prize_value_3_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 380 + xo2 + x_offs, y + scly - 93),
size=(0, 0),
h_align='left',
text='',
v_align='center',
maxwidth=100,
scale=prize_value_scale,
color=value_color,
flatness=1.0)
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 620 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.currentBestText'),
v_align='center',
maxwidth=180,
scale=0.9,
color=header_color,
flatness=1.0)
self.current_leader_name_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 620 + x_offs - (170 / 1.4) * 0.5,
y + scly - 60 - 40 * 0.5),
selectable=True,
click_activate=True,
autoselect=True,
on_activate_call=ba.WeakCall(self._show_leader),
size=(170 / 1.4, 40),
h_align='center',
text='-',
v_align='center',
maxwidth=170,
scale=1.4,
color=value_color,
flatness=1.0)
self.current_leader_score_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 620 + x_offs, y + scly - 113 + 10),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=170,
scale=1.8,
color=value_color,
flatness=1.0)
self.more_scores_button = ba.buttonwidget(
parent=parent,
position=(x + 620 + x_offs - 60, y + scly - 50 - 125),
color=(0.5, 0.5, 0.6),
textcolor=(0.7, 0.7, 0.8),
label='-',
size=(120, 40),
autoselect=True,
up_widget=self.current_leader_name_text,
text_scale=0.6,
on_activate_call=ba.WeakCall(self._show_scores))
ba.widget(edit=self.current_leader_name_text,
down_widget=self.more_scores_button)
ba.textwidget(parent=parent,
draw_controller=btn,
position=(x + 820 + x_offs, y + scly - 20),
size=(0, 0),
h_align='center',
text=ba.Lstr(resource=self._r + '.timeRemainingText'),
v_align='center',
maxwidth=180,
scale=0.9,
color=header_color,
flatness=1.0)
self.time_remaining_value_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 820 + x_offs, y + scly - 68),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=180,
scale=2.0,
color=value_color,
flatness=1.0)
self.time_remaining_out_of_text = ba.textwidget(
parent=parent,
draw_controller=btn,
position=(x + 820 + x_offs, y + scly - 110),
size=(0, 0),
h_align='center',
text='-',
v_align='center',
maxwidth=120,
scale=0.72,
color=(0.4, 0.4, 0.5),
flatness=1.0)
def _pressed(self) -> None:
self.on_pressed(self)
def _show_leader(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.account.viewer import AccountViewerWindow
tournament_id = self.tournament_id
# FIXME: This assumes a single player entry in leader; should expand
# this to work with multiple.
if tournament_id is None or self.leader is None or len(
self.leader[2]) != 1:
ba.playsound(ba.getsound('error'))
return
ba.playsound(ba.getsound('swish'))
AccountViewerWindow(
account_id=self.leader[2][0].get('a', None),
profile_id=self.leader[2][0].get('p', None),
position=self.current_leader_name_text.get_screen_space_center())
def _show_scores(self) -> None:
# pylint: disable=cyclic-import
from bastd.ui.tournamentscores import TournamentScoresWindow
tournament_id = self.tournament_id
if tournament_id is None:
ba.playsound(ba.getsound('error'))
return
TournamentScoresWindow(
tournament_id=tournament_id,
position=self.more_scores_button.get_screen_space_center())
def update_for_data(self, entry: dict[str, Any]) -> None:
"""Update for new incoming data."""
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches
from ba.internal import getcampaign, get_tournament_prize_strings
prize_y_offs = (34 if 'prizeRange3' in entry else
20 if 'prizeRange2' in entry else 12)
x_offs = 90
# This seems to be a false alarm.
# pylint: disable=unbalanced-tuple-unpacking
pr1, pv1, pr2, pv2, pr3, pv3 = (get_tournament_prize_strings(entry))
# pylint: enable=unbalanced-tuple-unpacking
enabled = 'requiredLeague' not in entry
ba.buttonwidget(edit=self.button,
color=(0.5, 0.7, 0.2) if enabled else (0.5, 0.5, 0.5))
ba.imagewidget(edit=self.lock_image, opacity=0.0 if enabled else 1.0)
ba.textwidget(edit=self.prize_range_1_text,
text='-' if pr1 == '' else pr1,
position=(self.button_x + 365 + x_offs, self.button_y +
self.button_scale_y - 93 + prize_y_offs))
# We want to draw values containing tickets a bit smaller
# (scratch that; we now draw medals a bit bigger).
ticket_char = ba.charstr(ba.SpecialChar.TICKET_BACKING)
prize_value_scale_large = 1.0
prize_value_scale_small = 1.0
ba.textwidget(edit=self.prize_value_1_text,
text='-' if pv1 == '' else pv1,
scale=prize_value_scale_large
if ticket_char not in pv1 else prize_value_scale_small,
position=(self.button_x + 380 + x_offs, self.button_y +
self.button_scale_y - 93 + prize_y_offs))
ba.textwidget(edit=self.prize_range_2_text,
text=pr2,
position=(self.button_x + 365 + x_offs, self.button_y +
self.button_scale_y - 93 - 45 + prize_y_offs))
ba.textwidget(edit=self.prize_value_2_text,
text=pv2,
scale=prize_value_scale_large
if ticket_char not in pv2 else prize_value_scale_small,
position=(self.button_x + 380 + x_offs, self.button_y +
self.button_scale_y - 93 - 45 + prize_y_offs))
ba.textwidget(edit=self.prize_range_3_text,
text=pr3,
position=(self.button_x + 365 + x_offs, self.button_y +
self.button_scale_y - 93 - 90 + prize_y_offs))
ba.textwidget(edit=self.prize_value_3_text,
text=pv3,
scale=prize_value_scale_large
if ticket_char not in pv3 else prize_value_scale_small,
position=(self.button_x + 380 + x_offs, self.button_y +
self.button_scale_y - 93 - 90 + prize_y_offs))
leader_name = '-'
leader_score: str | ba.Lstr = '-'
if entry['scores']:
score = self.leader = copy.deepcopy(entry['scores'][0])
leader_name = score[1]
leader_score = (ba.timestring(
score[0] * 10,
centi=True,
timeformat=ba.TimeFormat.MILLISECONDS,
suppress_format_warning=True)
if entry['scoreType'] == 'time' else str(score[0]))
else:
self.leader = None
ba.textwidget(edit=self.current_leader_name_text,
text=ba.Lstr(value=leader_name))
ba.textwidget(edit=self.current_leader_score_text, text=leader_score)
ba.buttonwidget(edit=self.more_scores_button,
label=ba.Lstr(resource=self._r + '.seeMoreText'))
out_of_time_text: str | ba.Lstr = (
'-' if 'totalTime' not in entry else ba.Lstr(
resource=self._r + '.ofTotalTimeText',
subs=[('${TOTAL}',
ba.timestring(entry['totalTime'],
centi=False,
suppress_format_warning=True))]))
ba.textwidget(edit=self.time_remaining_out_of_text,
text=out_of_time_text)
self.time_remaining = entry['timeRemaining']
self.has_time_remaining = entry is not None
self.tournament_id = entry['tournamentID']
self.required_league = (None if 'requiredLeague' not in entry else
entry['requiredLeague'])
game = ba.app.accounts_v1.tournament_info[self.tournament_id]['game']
if game is None:
ba.textwidget(edit=self.button_text, text='-')
ba.imagewidget(edit=self.image,
texture=ba.gettexture('black'),
opacity=0.2)
else:
campaignname, levelname = game.split(':')
campaign = getcampaign(campaignname)
max_players = ba.app.accounts_v1.tournament_info[
self.tournament_id]['maxPlayers']
txt = ba.Lstr(value='${A} ${B}',
subs=[('${A}',
campaign.getlevel(levelname).displayname),
('${B}',
ba.Lstr(resource='playerCountAbbreviatedText',
subs=[('${COUNT}', str(max_players))
]))])
ba.textwidget(edit=self.button_text, text=txt)
ba.imagewidget(
edit=self.image,
texture=campaign.getlevel(levelname).get_preview_texture(),
opacity=1.0 if enabled else 0.5)
fee = entry['fee']
if fee is None:
fee_var = None
elif fee == 4:
fee_var = 'price.tournament_entry_4'
elif fee == 3:
fee_var = 'price.tournament_entry_3'
elif fee == 2:
fee_var = 'price.tournament_entry_2'
elif fee == 1:
fee_var = 'price.tournament_entry_1'
else:
if fee != 0:
print('Unknown fee value:', fee)
fee_var = 'price.tournament_entry_0'
self.allow_ads = allow_ads = entry['allowAds']
final_fee: int | None = (None if fee_var is None else
_ba.get_v1_account_misc_read_val(
fee_var, '?'))
final_fee_str: str | ba.Lstr
if fee_var is None:
final_fee_str = ''
else:
if final_fee == 0:
final_fee_str = ba.Lstr(resource='getTicketsWindow.freeText')
else:
final_fee_str = (ba.charstr(ba.SpecialChar.TICKET_BACKING) +
str(final_fee))
ad_tries_remaining = ba.app.accounts_v1.tournament_info[
self.tournament_id]['adTriesRemaining']
free_tries_remaining = ba.app.accounts_v1.tournament_info[
self.tournament_id]['freeTriesRemaining']
# Now, if this fee allows ads and we support video ads, show
# the 'or ad' version.
if allow_ads and _ba.has_video_ads():
ads_enabled = _ba.have_incentivized_ad()
ba.imagewidget(edit=self.entry_fee_ad_image,
opacity=1.0 if ads_enabled else 0.25)
or_text = ba.Lstr(resource='orText',
subs=[('${A}', ''),
('${B}', '')]).evaluate().strip()
ba.textwidget(edit=self.entry_fee_text_or, text=or_text)
ba.textwidget(edit=self.entry_fee_text_top,
position=(self.button_x + 360,
self.button_y + self.button_scale_y - 60),
scale=1.3,
text=final_fee_str)
# Possibly show number of ad-plays remaining.
ba.textwidget(edit=self.entry_fee_text_remaining,
position=(self.button_x + 360,
self.button_y + self.button_scale_y - 146),
text='' if ad_tries_remaining in [None, 0] else
('' + str(ad_tries_remaining)),
color=(0.6, 0.6, 0.6, 1 if ads_enabled else 0.2))
else:
ba.imagewidget(edit=self.entry_fee_ad_image, opacity=0.0)
ba.textwidget(edit=self.entry_fee_text_or, text='')
ba.textwidget(edit=self.entry_fee_text_top,
position=(self.button_x + 360,
self.button_y + self.button_scale_y - 80),
scale=1.3,
text=final_fee_str)
# Possibly show number of free-plays remaining.
ba.textwidget(
edit=self.entry_fee_text_remaining,
position=(self.button_x + 360,
self.button_y + self.button_scale_y - 100),
text=('' if (free_tries_remaining in [None, 0]
or final_fee != 0) else
('' + str(free_tries_remaining))),
color=(0.6, 0.6, 0.6, 1),
)

View file

@ -207,10 +207,15 @@ class CreditsListWindow(ba.Window):
'\n' + '\n'.join(translation_names.splitlines()[:146]) +
'\n'.join(translation_names.splitlines()[146:]) + '\n'
'\n'
' Shout Out to Awesome Mods / Modders:\n\n'
' Shout Out to Awesome Mods / Modders / Contributors:\n\n'
' BombDash ModPack\n'
' TheMikirog & SoK - BombSquad Joyride Modpack\n'
' Mrmaxmeier - BombSquad-Community-Mod-Manager\n'
' Ritiek Malhotra \n'
' Dliwk\n'
' vishal332008\n'
' itsre3\n'
' Drooopyyy\n'
'\n'
' Holiday theme vector art designed by Freepik\n'
'\n'

View file

@ -705,8 +705,8 @@ class ManualGatherTab(GatherTab):
from_other_thread=True,
)
except Exception as exc:
from efro.error import is_udp_network_error
if is_udp_network_error(exc):
from efro.error import is_udp_communication_error
if is_udp_communication_error(exc):
ba.pushcall(ba.Call(
_safe_set_text, self._checking_state_text,
ba.Lstr(resource='gatherWindow.'

View file

@ -219,9 +219,9 @@ class AddrFetchThread(threading.Thread):
sock.close()
ba.pushcall(ba.Call(self._call, val), from_other_thread=True)
except Exception as exc:
from efro.error import is_udp_network_error
from efro.error import is_udp_communication_error
# Ignore expected network errors; log others.
if is_udp_network_error(exc):
if is_udp_communication_error(exc):
pass
else:
ba.print_exception()
@ -271,8 +271,8 @@ class PingThread(threading.Thread):
ping if accessible else None),
from_other_thread=True)
except Exception as exc:
from efro.error import is_udp_network_error
if is_udp_network_error(exc):
from efro.error import is_udp_communication_error
if is_udp_communication_error(exc):
pass
else:
ba.print_exception('Error on gather ping', once=True)

View file

@ -213,8 +213,8 @@ class OnScreenKeyboardWindow(ba.Window):
# Show change instructions only if we have more than one
# keyboard option.
if (ba.app.meta.metascan is not None
and len(ba.app.meta.metascan.keyboards) > 1):
if (ba.app.meta.scanresults is not None
and len(ba.app.meta.scanresults.keyboards) > 1):
ba.textwidget(
parent=self._root_widget,
h_align='center',
@ -238,8 +238,8 @@ class OnScreenKeyboardWindow(ba.Window):
self._refresh()
def _get_keyboard(self) -> ba.Keyboard:
assert ba.app.meta.metascan is not None
classname = ba.app.meta.metascan.keyboards[self._keyboard_index]
assert ba.app.meta.scanresults is not None
classname = ba.app.meta.scanresults.keyboards[self._keyboard_index]
kbclass = ba.getclass(classname, ba.Keyboard)
return kbclass()
@ -317,11 +317,11 @@ class OnScreenKeyboardWindow(ba.Window):
self._refresh()
def _next_keyboard(self) -> None:
assert ba.app.meta.metascan is not None
assert ba.app.meta.scanresults is not None
self._keyboard_index = (self._keyboard_index + 1) % len(
ba.app.meta.metascan.keyboards)
ba.app.meta.scanresults.keyboards)
self._load_keyboard()
if len(ba.app.meta.metascan.keyboards) < 2:
if len(ba.app.meta.scanresults.keyboards) < 2:
ba.playsound(ba.getsound('error'))
ba.screenmessage(ba.Lstr(resource='keyboardNoOthersAvailableText'),
color=(1, 0, 0))

View file

@ -56,6 +56,7 @@ class AdvancedSettingsWindow(ba.Window):
scale=(2.06 if uiscale is ba.UIScale.SMALL else
1.4 if uiscale is ba.UIScale.MEDIUM else 1.0),
stack_offset=(0, -25) if uiscale is ba.UIScale.SMALL else (0, 0)))
self._prev_lang = ''
self._prev_lang_list: list[str] = []
self._complete_langs_list: list | None = None
@ -423,29 +424,6 @@ class AdvancedSettingsWindow(ba.Window):
v -= self._spacing * 2.1
this_button_width = 410
self._show_user_mods_button = ba.buttonwidget(
parent=self._subcontainer,
position=(self._sub_width / 2 - this_button_width / 2, v - 10),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.showUserModsText'),
text_scale=1.0,
on_activate_call=show_user_scripts)
if self._show_always_use_internal_keyboard:
assert self._always_use_internal_keyboard_check_box is not None
ba.widget(edit=self._always_use_internal_keyboard_check_box.widget,
down_widget=self._show_user_mods_button)
ba.widget(
edit=self._show_user_mods_button,
up_widget=self._always_use_internal_keyboard_check_box.widget)
else:
ba.widget(edit=self._show_user_mods_button,
up_widget=self._kick_idle_players_check_box.widget)
ba.widget(edit=self._kick_idle_players_check_box.widget,
down_widget=self._show_user_mods_button)
v -= self._spacing * 2.0
self._modding_guide_button = ba.buttonwidget(
parent=self._subcontainer,
position=(self._sub_width / 2 - this_button_width / 2, v - 10),
@ -454,8 +432,30 @@ class AdvancedSettingsWindow(ba.Window):
label=ba.Lstr(resource=self._r + '.moddingGuideText'),
text_scale=1.0,
on_activate_call=ba.Call(
ba.open_url,
'http://www.froemling.net/docs/bombsquad-modding-guide'))
ba.open_url, 'http://ballistica.net/wiki/modding-guide'))
if self._show_always_use_internal_keyboard:
assert self._always_use_internal_keyboard_check_box is not None
ba.widget(edit=self._always_use_internal_keyboard_check_box.widget,
down_widget=self._modding_guide_button)
ba.widget(
edit=self._modding_guide_button,
up_widget=self._always_use_internal_keyboard_check_box.widget)
else:
ba.widget(edit=self._modding_guide_button,
up_widget=self._kick_idle_players_check_box.widget)
ba.widget(edit=self._kick_idle_players_check_box.widget,
down_widget=self._modding_guide_button)
v -= self._spacing * 2.0
self._show_user_mods_button = ba.buttonwidget(
parent=self._subcontainer,
position=(self._sub_width / 2 - this_button_width / 2, v - 10),
size=(this_button_width, 60),
autoselect=True,
label=ba.Lstr(resource=self._r + '.showUserModsText'),
text_scale=1.0,
on_activate_call=show_user_scripts)
v -= self._spacing * 2.0

View file

@ -10,6 +10,7 @@ import weakref
from threading import Thread
from typing import TYPE_CHECKING
from efro.error import CleanError
import _ba
import ba
from bastd.ui.settings.testing import TestingWindow
@ -148,10 +149,12 @@ def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None:
call()
duration = time.monotonic() - starttime
_print(f'Succeeded in {duration:.2f}s.', color=(0, 1, 0))
except Exception:
except Exception as exc:
import traceback
duration = time.monotonic() - starttime
_print(traceback.format_exc(), color=(1.0, 1.0, 0.3))
msg = (str(exc)
if isinstance(exc, CleanError) else traceback.format_exc())
_print(msg, color=(1.0, 1.0, 0.3))
_print(f'Failed in {duration:.2f}s.', color=(1, 0, 0))
have_error[0] = True
@ -193,6 +196,9 @@ def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None:
_print(f'\nContacting V2 master-server ({baseaddr})...')
_print_test_results(lambda: _test_fetch(baseaddr))
_print('\nComparing local time to V2 server...')
_print_test_results(_test_v2_time)
# Get V2 nearby zone
with ba.app.net.zone_pings_lock:
zone_pings = copy.deepcopy(ba.app.net.zone_pings)
@ -206,6 +212,9 @@ def _run_diagnostics(weakwin: weakref.ref[NetTestingWindow]) -> None:
_print(f'\nChecking nearest V2 zone ping ({nearstr})...')
_print_test_results(lambda: _test_nearby_zone_ping(nearest_zone))
_print('\nSending V2 cloud message...')
_print_test_results(_test_v2_cloud_message)
if have_error[0]:
_print('\nDiagnostics complete. Some diagnostics failed.',
color=(10, 0, 0))
@ -271,12 +280,67 @@ def _test_v1_transaction() -> None:
raise RuntimeError(results[0])
def _test_v2_cloud_message() -> None:
from dataclasses import dataclass
import bacommon.cloud
@dataclass
class _Results:
errstr: str | None = None
send_time: float | None = None
response_time: float | None = None
results = _Results()
def _cb(response: bacommon.cloud.PingResponse | Exception) -> None:
# Note: this runs in another thread so need to avoid exceptions.
results.response_time = time.monotonic()
if isinstance(response, Exception):
results.errstr = str(response)
if not isinstance(response, bacommon.cloud.PingResponse):
results.errstr = f'invalid response type: {type(response)}.'
def _send() -> None:
# Note: this runs in another thread so need to avoid exceptions.
results.send_time = time.monotonic()
ba.app.cloud.send_message_cb(bacommon.cloud.PingMessage(), _cb)
# This stuff expects to be run from the logic thread.
ba.pushcall(_send, from_other_thread=True)
wait_start_time = time.monotonic()
while True:
if results.response_time is not None:
break
time.sleep(0.01)
if time.monotonic() - wait_start_time > 10.0:
raise RuntimeError('Timeout waiting for cloud message response')
if results.errstr is not None:
raise RuntimeError(results.errstr)
def _test_v2_time() -> None:
offset = ba.app.net.server_time_offset_hours
if offset is None:
raise RuntimeError('no time offset found;'
' perhaps unable to communicate with v2 server?')
if abs(offset) >= 2.0:
raise CleanError(
f'Your device time is off from world time by {offset:.1f} hours.\n'
'This may cause network operations to fail due to your device\n'
' incorrectly treating SSL certificates as not-yet-valid, etc.\n'
'Check your device time and time-zone settings to fix this.\n')
def _test_fetch(baseaddr: str) -> None:
# pylint: disable=consider-using-with
import urllib.request
response = urllib.request.urlopen(urllib.request.Request(
f'{baseaddr}/ping', None, {'User-Agent': _ba.app.user_agent_string}),
timeout=10.0)
response = urllib.request.urlopen(
urllib.request.Request(f'{baseaddr}/ping', None,
{'User-Agent': _ba.app.user_agent_string}),
context=ba.app.net.sslcontext,
timeout=10.0,
)
if response.getcode() != 200:
raise RuntimeError(
f'Got unexpected response code {response.getcode()}.')

View file

@ -93,7 +93,7 @@ class PluginSettingsWindow(ba.Window):
self._subcontainer = ba.columnwidget(parent=self._scrollwidget,
selection_loops_to_parent=True)
if ba.app.meta.metascan is None:
if ba.app.meta.scanresults is None:
ba.screenmessage('Still scanning plugins; please try again.',
color=(1, 0, 0))
ba.playsound(ba.getsound('error'))

View file

@ -160,7 +160,7 @@ class SoundtrackEntryTypeSelectWindow(ba.Window):
from ba.osmusic import OSMusicPlayer
from bastd.ui.fileselector import FileSelectorWindow
ba.containerwidget(edit=self._root_widget, transition='out_left')
base_path = _ba.android_get_external_storage_path()
base_path = _ba.android_get_external_files_dir()
ba.app.ui.set_main_menu_window(
FileSelectorWindow(
base_path,
@ -173,7 +173,7 @@ class SoundtrackEntryTypeSelectWindow(ba.Window):
def _on_music_folder_press(self) -> None:
from bastd.ui.fileselector import FileSelectorWindow
ba.containerwidget(edit=self._root_widget, transition='out_left')
base_path = _ba.android_get_external_storage_path()
base_path = _ba.android_get_external_files_dir()
ba.app.ui.set_main_menu_window(
FileSelectorWindow(base_path,
callback=self._music_folder_selector_cb,

View file

@ -182,8 +182,9 @@ class TournamentEntryWindow(popup.PopupWindow):
h_align='center',
v_align='center',
scale=0.6,
text=ba.Lstr(resource='watchAVideoText',
fallback_resource='watchAnAdText'),
# Note: AdMob now requires rewarded ad usage
# specifically says 'Ad' in it.
text=ba.Lstr(resource='watchAnAdText'),
maxwidth=95,
color=(0, 1, 0))
ad_plays_remaining_text = (

View file

@ -112,7 +112,7 @@ class TrophiesWindow(popup.PopupWindow):
sub_width: int,
trophy_types: list[list[str]]) -> int:
from ba.internal import get_trophy_string
pts = 0
total_pts = 0
for i, trophy_type in enumerate(trophy_types):
t_count = self._data['t' + trophy_type[0]]
t_mult = self._data['t' + trophy_type[0] + 'm']
@ -157,7 +157,7 @@ class TrophiesWindow(popup.PopupWindow):
h_align='center',
v_align='center')
pts = t_count * t_mult
this_pts = t_count * t_mult
ba.textwidget(parent=self._subcontainer,
position=(sub_width * 0.88,
sub_height - 20 - incr * i),
@ -167,12 +167,12 @@ class TrophiesWindow(popup.PopupWindow):
flatness=1.0,
shadow=0.0,
scale=0.5,
text=eq_text.replace('${NUMBER}', str(pts)),
text=eq_text.replace('${NUMBER}', str(this_pts)),
size=(0, 0),
h_align='center',
v_align='center')
pts += pts
return pts
total_pts += this_pts
return total_pts
def _on_cancel_press(self) -> None:
self._transition_out()

View file

@ -4,6 +4,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
import errno
if TYPE_CHECKING:
pass
@ -39,12 +40,13 @@ class CommunicationError(Exception):
"""A communication related error has occurred.
This covers anything network-related going wrong in the sending
of data or receiving of a response. This error does not imply
of data or receiving of a response. Basically anything that is out
of our control should get lumped in here. This error does not imply
that data was not received on the other end; only that a full
acknowledgement round trip was not completed.
These errors should be gracefully handled whenever possible, as
occasional network outages are generally unavoidable.
occasional network issues are unavoidable.
"""
@ -55,9 +57,9 @@ class RemoteError(Exception):
occurs remotely. The error string can consist of a remote stack
trace or a simple message depending on the context.
Communication systems should raise more specific error types when
more introspection/control is needed; this is intended somewhat as
a catch-all.
Communication systems should raise more specific error types locally
when more introspection/control is needed; this is intended somewhat
as a catch-all.
"""
def __str__(self) -> str:
@ -69,31 +71,43 @@ class IntegrityError(ValueError):
"""Data has been tampered with or corrupted in some form."""
def is_urllib_network_error(exc: BaseException) -> bool:
"""Is the provided exception from urllib a network-related error?
def is_urllib_communication_error(exc: BaseException, url: str | None) -> bool:
"""Is the provided exception from urllib a communication-related error?
Url, if provided can provide extra context for when to treat an error
as such an error.
This should be passed an exception which resulted from opening or
reading a urllib Request. It returns True for any errors that could
conceivably arise due to unavailable/poor network connections,
firewall/connectivity issues, etc. These issues can often be safely
ignored or presented to the user as general 'network-unavailable'
states.
firewall/connectivity issues, or other issues out of our control.
These errors can often be safely ignored or presented to the user
as general 'network-unavailable' states.
"""
import urllib.error
import http.client
import errno
import socket
if isinstance(
exc,
(urllib.error.URLError, ConnectionError, http.client.IncompleteRead,
http.client.BadStatusLine, socket.timeout)):
if isinstance(exc, (urllib.error.URLError, ConnectionError,
http.client.IncompleteRead, http.client.BadStatusLine,
http.client.RemoteDisconnected, socket.timeout)):
# Special case: although an HTTPError is a subclass of URLError,
# we don't return True for it. It means we have successfully
# communicated with the server but what we are asking for is
# not there/etc.
# we don't consider it a communication error. It generally means we
# have successfully communicated with the server but what we are asking
# for is not there/etc.
if isinstance(exc, urllib.error.HTTPError):
# Special sub-case: appspot.com hosting seems to give 403 errors
# (forbidden) to some countries. I'm assuming for legal reasons?..
# Let's consider that a communication error since its out of our
# control so we don't fill up logs with it.
if exc.code == 403 and url is not None and '.appspot.com' in url:
return True
return False
return True
if isinstance(exc, OSError):
if exc.errno == 10051: # Windows unreachable network error.
return True
@ -106,18 +120,17 @@ def is_urllib_network_error(exc: BaseException) -> bool:
return False
def is_udp_network_error(exc: BaseException) -> bool:
"""Is the provided exception a network-related error?
def is_udp_communication_error(exc: BaseException) -> bool:
"""Should this udp-related exception be considered a communication error?
This should be passed an exception which resulted from creating and
using a socket.SOCK_DGRAM type socket. It should return True for any
errors that could conceivably arise due to unavailable/poor network
connections, firewall/connectivity issues, etc. These issues can often
conditions, firewall/connectivity issues, etc. These issues can often
be safely ignored or presented to the user as general
'network-unavailable' states.
"""
import errno
if isinstance(exc, ConnectionRefusedError):
if isinstance(exc, ConnectionRefusedError | TimeoutError):
return True
if isinstance(exc, OSError):
if exc.errno == 10051: # Windows unreachable network error.
@ -140,8 +153,8 @@ def is_udp_network_error(exc: BaseException) -> bool:
return False
def is_asyncio_streams_network_error(exc: BaseException) -> bool:
"""Is the provided exception a network-related error?
def is_asyncio_streams_communication_error(exc: BaseException) -> bool:
"""Should this streams error be considered a communication error?
This should be passed an exception which resulted from creating and
using asyncio streams. It should return True for any errors that could
@ -149,7 +162,6 @@ def is_asyncio_streams_network_error(exc: BaseException) -> bool:
firewall/connectivity issues, etc. These issues can often be safely
ignored or presented to the user as general 'connection-lost' events.
"""
import errno
import ssl
if isinstance(exc, (

View file

@ -56,6 +56,7 @@ class ErrorResponse(Response):
OTHER = 0
CLEAN = 1
LOCAL = 2
COMMUNICATION = 3
error_message: Annotated[str, IOAttrs('m')]
error_type: Annotated[ErrorType, IOAttrs('e')] = ErrorType.OTHER

View file

@ -25,28 +25,40 @@ class MessageProtocol:
"""Wrangles a set of message types, formats, and response types.
Both endpoints must be using a compatible Protocol for communication
to succeed. To maintain Protocol compatibility between revisions,
all message types must retain the same id, message attr storage names must
not change, newly added attrs must have default values, etc.
all message types must retain the same id, message attr storage
names must not change, newly added attrs must have default values,
etc.
"""
def __init__(self,
message_types: dict[int, type[Message]],
response_types: dict[int, type[Response]],
preserve_clean_errors: bool = True,
log_remote_exceptions: bool = True,
trusted_sender: bool = False) -> None:
receiver_logs_exceptions: bool = True,
receiver_returns_stack_traces: bool = False) -> None:
"""Create a protocol with a given configuration.
Note that common response types are automatically registered
with (unchanging negative ids) so they don't need to be passed
explicitly (but can be if a different id is desired).
If 'preserve_clean_errors' is True, efro.error.CleanError errors
on the remote end will result in the same error raised locally.
All other Exception types come across as efro.error.RemoteError.
If 'preserve_clean_errors' is True, efro.error.CleanError
exceptions raised on the receiver end will result in a matching
CleanError raised back on the sender. All other Exception types
come across as efro.error.RemoteError.
If 'trusted_sender' is True, stringified remote stack traces will
be included in the responses if errors occur.
When 'receiver_logs_exceptions' is True, any uncaught Exceptions
on the receiver end will be logged there via logging.exception()
(in addition to the usual behavior of returning an ErrorResponse
to the sender). This is good to leave enabled if your
intention is to never return ErrorResponses. Looser setups
making routine use of CleanErrors or whatnot may want to
disable this, however.
If 'receiver_returns_stack_traces' is True, stringified stack
traces will be returned to the sender for exceptions occurring
on the receiver end. This can make debugging easier but should
only be used when the client is trusted to see such info.
"""
self.message_types_by_id: dict[int, type[Message]] = {}
self.message_ids_by_type: dict[type[Message], int] = {}
@ -102,9 +114,9 @@ class MessageProtocol:
assert is_ioprepped_dataclass(cls)
assert issubclass(cls, Response)
if cls not in self.response_ids_by_type:
raise ValueError(f'Possible response type {cls}'
f' needs to be included in response_types'
f' for this protocol.')
raise ValueError(
f'Possible response type {cls} needs to be included'
f' in response_types for this protocol.')
# Make sure all registered types have unique base names.
# We can take advantage of this to generate cleaner looking
@ -116,8 +128,8 @@ class MessageProtocol:
' all types are required to have unique names.')
self.preserve_clean_errors = preserve_clean_errors
self.log_remote_exceptions = log_remote_exceptions
self.trusted_sender = trusted_sender
self.receiver_logs_exceptions = receiver_logs_exceptions
self.receiver_returns_stack_traces = receiver_returns_stack_traces
@staticmethod
def encode_dict(obj: dict) -> str:
@ -134,7 +146,9 @@ class MessageProtocol:
def error_to_response(self, exc: Exception) -> Response:
"""Translate an error to a response."""
if self.log_remote_exceptions:
# Log any errors we got during handling if so desired.
if self.receiver_logs_exceptions:
logging.exception('Error handling message.')
# If anything goes wrong, return a ErrorResponse instead.
@ -142,8 +156,9 @@ class MessageProtocol:
return ErrorResponse(error_message=str(exc),
error_type=ErrorResponse.ErrorType.CLEAN)
return ErrorResponse(
error_message=(traceback.format_exc() if self.trusted_sender else
'An unknown error has occurred.'),
error_message=(traceback.format_exc()
if self.receiver_returns_stack_traces else
'An internal error has occurred.'),
error_type=ErrorResponse.ErrorType.OTHER)
def _to_dict(self, message: Any, ids_by_type: dict[type, int],

View file

@ -9,7 +9,7 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, TypeVar
from efro.error import CleanError, RemoteError
from efro.error import CleanError, RemoteError, CommunicationError
from efro.message._message import EmptyResponse, ErrorResponse
if TYPE_CHECKING:
@ -35,7 +35,7 @@ class MessageSender:
def send_raw_message(self, message: str) -> str:
# Actually send the message here.
# MyMessageSender class should provide overloads for send(), send_bg(),
# MyMessageSender class should provide overloads for send(), send_async(),
# etc. to ensure all sending happens with valid types.
obj = MyClass()
obj.msg.send(SomeMessageType())
@ -54,7 +54,12 @@ class MessageSender:
def send_method(
self, call: Callable[[Any, str],
str]) -> Callable[[Any, str], str]:
"""Function decorator for setting raw send method."""
"""Function decorator for setting raw send method.
Send methods take strings and should return strings.
Any Exception raised during the send_method manifests as
a CommunicationError for the message sender.
"""
assert self._send_raw_message_call is None
self._send_raw_message_call = call
return call
@ -62,7 +67,12 @@ class MessageSender:
def send_async_method(
self, call: Callable[[Any, str], Awaitable[str]]
) -> Callable[[Any, str], Awaitable[str]]:
"""Function decorator for setting raw send-async method."""
"""Function decorator for setting raw send-async method.
Send methods take strings and should return strings.
Any Exception raised during the send_method manifests as
a CommunicationError for the message sender.
"""
assert self._send_async_raw_message_call is None
self._send_async_raw_message_call = call
return call
@ -123,7 +133,17 @@ class MessageSender:
raise RuntimeError('send() is unimplemented for this type.')
msg_encoded = self._encode_message(bound_obj, message)
response_encoded = self._send_raw_message_call(bound_obj, msg_encoded)
try:
response_encoded = self._send_raw_message_call(
bound_obj, msg_encoded)
except Exception as exc:
# Any error in the raw send call gets recorded as either
# a local or communication error.
return ErrorResponse(
error_message=f'Error in send async method: {exc}',
error_type=(ErrorResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError) else
ErrorResponse.ErrorType.LOCAL))
return self._decode_raw_response(bound_obj, message, response_encoded)
async def send_split_part_1_async(self, bound_obj: Any,
@ -139,9 +159,17 @@ class MessageSender:
raise RuntimeError('send_async() is unimplemented for this type.')
msg_encoded = self._encode_message(bound_obj, message)
response_encoded = await self._send_async_raw_message_call(
bound_obj, msg_encoded)
try:
response_encoded = await self._send_async_raw_message_call(
bound_obj, msg_encoded)
except Exception as exc:
# Any error in the raw send call gets recorded as either
# a local or communication error.
return ErrorResponse(
error_message=f'Error in send async method: {exc}',
error_type=(ErrorResponse.ErrorType.COMMUNICATION
if isinstance(exc, CommunicationError) else
ErrorResponse.ErrorType.LOCAL))
return self._decode_raw_response(bound_obj, message, response_encoded)
def send_split_part_2(self, message: Message,
@ -183,8 +211,8 @@ class MessageSender:
# If we got to this point, we successfully communicated
# with the other end so errors represent protocol mismatches
# or other invalid data. For now let's just log it but perhaps
# we'd want to somehow embed it in the ErrorResponse to be raised
# directly to the user later.
# we'd want to somehow embed it in the ErrorResponse to be
# available directly to the user later.
logging.exception('Error decoding raw response')
response = ErrorResponse(
error_message=
@ -208,6 +236,10 @@ class MessageSender:
# Some error occurred. Raise a local Exception for it.
if isinstance(raw_response, ErrorResponse):
if (raw_response.error_type is
ErrorResponse.ErrorType.COMMUNICATION):
raise CommunicationError(raw_response.error_message)
# If something went wrong on our end of the connection,
# don't say it was a remote error.
if raw_response.error_type is ErrorResponse.ErrorType.LOCAL:

View file

@ -13,8 +13,9 @@ from dataclasses import dataclass
from threading import current_thread
from typing import TYPE_CHECKING, Annotated
from efro.error import CommunicationError, is_asyncio_streams_network_error
from efro.util import assert_never
from efro.error import (CommunicationError,
is_asyncio_streams_communication_error)
from efro.dataclassio import (dataclass_to_json, dataclass_from_json,
ioprepped, IOAttrs)
@ -596,7 +597,7 @@ class RPCEndpoint:
if isinstance(exc, _KeepaliveTimeoutError):
return True
return is_asyncio_streams_network_error(exc)
return is_asyncio_streams_communication_error(exc)
def _check_env(self) -> None:
# I was seeing that asyncio stuff wasn't working as expected if

View file

@ -637,9 +637,11 @@ def compact_id(num: int) -> str:
'abcdefghijklmnopqrstuvwxyz')
# NOTE: Even though this is available as part of typing_extensions, keeping
# it in here for now so we don't require typing_extensions as a dependency.
# Once 3.11 rolls around we can kill this and use typing.assert_never.
def assert_never(value: NoReturn) -> NoReturn:
"""Trick for checking exhaustive handling of Enums, etc.
See https://github.com/python/typing/issues/735
"""
assert False, f'Unhandled value: {value} ({type(value).__name__})'